forked from bai/curriculum-project-hub
refactor(hub): 手搓 agent loop 换成 Vercel AI SDK
将 provider/runner/tools 三件手搓轮子替换为 AI SDK v7 的 generateText + tool:
- 删 openrouter-provider.ts(翻译层消失)、provider.ts 自定义消息类型
- runner.ts 循环体改为 generateText({stopWhen: stepCountIs}),保留 RunRequest/RunResult 公共签名
- tools.ts ToolRegistry 改为 ToolFactory + build(ctx, names?) 按 role 白名单构建 tools record
- workspace/cph/feishu 工具改写为 tool() 定义,execute 闭包捕获 per-run ToolContext
- transcript.ts 改为 ModelMessage[] JSONL(0.0.0 cutover,无迁移)
- server.ts/trigger.ts 接 modelFactory + toolWhitelist
- 测试:helpers 写 MockLanguageModel implements LanguageModelV4;5 个测试改写 + 新增 runner.test.ts
- 移除未使用的 openai 依赖
- ADR-0017 Consequences 同步:loop 委托给 AI SDK,provider seam 是 LanguageModel
tsc 干净,54 tests 全过。
This commit is contained in:
+33
-56
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* `cph` subprocess tools — the Hub↔Courseware coupling point.
|
||||
* `cph` subprocess tools - the Hub<->Courseware coupling point.
|
||||
*
|
||||
* The Hub agent does not parse engineering-file models in-process; it calls the
|
||||
* stable `cph` CLI (ADR-0016 version contract). `cph check` validates a
|
||||
* project; `cph build` renders a target artifact. Version compatibility is the
|
||||
* CLI's responsibility — it refuses incompatible `.cph-version` files with a
|
||||
* CLI's responsibility - it refuses incompatible `.cph-version` files with a
|
||||
* `cphVersionMismatch` error diagnostic (ADR-0016); the Hub merely runs the
|
||||
* subprocess and returns its stdout/stderr/exit to the model.
|
||||
*
|
||||
@@ -14,7 +14,9 @@
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { RegisteredTool } from "./tools.js";
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import type { ToolContext, ToolDefinition } from "./tools.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -53,30 +55,19 @@ async function runCph(args: readonly string[], workspaceDir: string): Promise<Ex
|
||||
}
|
||||
}
|
||||
|
||||
interface CphCheckArgs {
|
||||
readonly path?: string;
|
||||
}
|
||||
|
||||
/// `cph check <dir>` — validate a curriculum engineering file. Returns the
|
||||
/// `cph check <dir>` - validate a curriculum engineering file. Returns the
|
||||
/// checker's diagnostics (stdout) for the model to act on. Non-zero exit means
|
||||
/// the file has error diagnostics (ADR-0010); the output is still returned,
|
||||
/// not thrown — the model needs to see the diagnostics to fix them.
|
||||
export function cphCheckTool(): RegisteredTool {
|
||||
return {
|
||||
spec: {
|
||||
name: "cph_check",
|
||||
description:
|
||||
"Run `cph check` on the project's curriculum engineering file. Returns diagnostics (7 classes: structure/schema/compile/version…). Non-zero exit means error diagnostics present — read the output to fix them.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Path to the engineering file dir; defaults to the workspace root." },
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(args, ctx): Promise<string> {
|
||||
const a = (args as CphCheckArgs) ?? {};
|
||||
const target = a.path ?? ".";
|
||||
/// not thrown - the model needs to see the diagnostics to fix them.
|
||||
export function cphCheckTool(ctx: ToolContext): ToolDefinition {
|
||||
return tool({
|
||||
description:
|
||||
"Run `cph check` on the project's curriculum engineering file. Returns diagnostics (7 classes: structure/schema/compile/version...). Non-zero exit means error diagnostics present - read the output to fix them.",
|
||||
inputSchema: z.object({
|
||||
path: z.string().describe("Path to the engineering file dir; defaults to the workspace root.").optional(),
|
||||
}),
|
||||
execute: async (args): Promise<string> => {
|
||||
const target = args.path ?? ".";
|
||||
const res = await runCph(["check", target], ctx.workspaceDir);
|
||||
return JSON.stringify({
|
||||
exitCode: res.exitCode,
|
||||
@@ -84,39 +75,25 @@ export function cphCheckTool(): RegisteredTool {
|
||||
stderr: res.stderr,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface CphBuildArgs {
|
||||
readonly target: string;
|
||||
readonly path?: string;
|
||||
readonly output?: string;
|
||||
}
|
||||
|
||||
/// `cph build <dir> --target <T> -o <out>` — render a target artifact. The
|
||||
/// target (student/teacher/…) determines the build (ADR-0009). Output path
|
||||
/// `cph build <dir> --target <T> -o <out>` - render a target artifact. The
|
||||
/// target (student/teacher/...) determines the build (ADR-0009). Output path
|
||||
/// is relative to the workspace unless absolute.
|
||||
export function cphBuildTool(): RegisteredTool {
|
||||
return {
|
||||
spec: {
|
||||
name: "cph_build",
|
||||
description:
|
||||
"Run `cph build` to render a curriculum artifact (e.g. student/teacher PDF). Target selects the build; output names the result file.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
target: { type: "string", description: "Build target, e.g. 'student', 'teacher'." },
|
||||
path: { type: "string", description: "Path to the engineering file dir; defaults to the workspace root." },
|
||||
output: { type: "string", description: "Output file path (relative to workspace or absolute)." },
|
||||
},
|
||||
required: ["target"],
|
||||
},
|
||||
},
|
||||
async execute(args, ctx): Promise<string> {
|
||||
const a = args as CphBuildArgs;
|
||||
const target = a.path ?? ".";
|
||||
const out = a.output ?? `build/${a.target}.pdf`;
|
||||
const res = await runCph(["build", target, "--target", a.target, "-o", out], ctx.workspaceDir);
|
||||
export function cphBuildTool(ctx: ToolContext): ToolDefinition {
|
||||
return tool({
|
||||
description:
|
||||
"Run `cph build` to render a curriculum artifact (e.g. student/teacher PDF). Target selects the build; output names the result file.",
|
||||
inputSchema: z.object({
|
||||
target: z.string().describe("Build target, e.g. 'student', 'teacher'."),
|
||||
path: z.string().describe("Path to the engineering file dir; defaults to the workspace root.").optional(),
|
||||
output: z.string().describe("Output file path (relative to workspace or absolute).").optional(),
|
||||
}),
|
||||
execute: async (args): Promise<string> => {
|
||||
const target = args.path ?? ".";
|
||||
const out = args.output ?? `build/${args.target}.pdf`;
|
||||
const res = await runCph(["build", target, "--target", args.target, "-o", out], ctx.workspaceDir);
|
||||
return JSON.stringify({
|
||||
exitCode: res.exitCode,
|
||||
stdout: res.stdout,
|
||||
@@ -124,5 +101,5 @@ export function cphBuildTool(): RegisteredTool {
|
||||
output: out,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* OpenRouter provider adapter.
|
||||
*
|
||||
* Points an OpenAI-compatible client at OpenRouter (or any compatible baseURL).
|
||||
* A single API key + one baseURL routes to any model id OpenRouter serves; the
|
||||
* model is chosen per run (ADR-0017: role-based routing, run carries its model).
|
||||
*
|
||||
* This adapter only translates between our {@link Message} shape and the `openai`
|
||||
* SDK's `ChatCompletionMessageParam` union. The agent loop itself never imports
|
||||
* the SDK — swapping providers means implementing {@link AgentProvider}, not
|
||||
* rewriting the loop.
|
||||
*/
|
||||
import OpenAI from "openai";
|
||||
import type {
|
||||
AgentProvider,
|
||||
ChatRequest,
|
||||
ChatResponse,
|
||||
Message,
|
||||
MessagePart,
|
||||
ToolSpec,
|
||||
} from "./provider.js";
|
||||
|
||||
export interface OpenRouterOptions {
|
||||
readonly apiKey: string;
|
||||
/** Defaults to OpenRouter. Override for direct vendor APIs or local gateways. */
|
||||
readonly baseURL?: string;
|
||||
readonly id?: string;
|
||||
/** Extra headers (e.g. `HTTP-Referer`, `X-Title` for OpenRouter rankings). */
|
||||
readonly defaultHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
export class OpenRouterProvider implements AgentProvider {
|
||||
readonly id: string;
|
||||
private readonly client: OpenAI;
|
||||
|
||||
constructor(opts: OpenRouterOptions) {
|
||||
this.id = opts.id ?? "openrouter";
|
||||
const params: ConstructorParameters<typeof OpenAI>[0] = {
|
||||
apiKey: opts.apiKey,
|
||||
baseURL: opts.baseURL ?? "https://openrouter.ai/api/v1",
|
||||
};
|
||||
if (opts.defaultHeaders !== undefined) {
|
||||
params.defaultHeaders = opts.defaultHeaders;
|
||||
}
|
||||
this.client = new OpenAI(params);
|
||||
}
|
||||
|
||||
async chat(req: ChatRequest): Promise<ChatResponse> {
|
||||
const completion = (await this.client.chat.completions.create({
|
||||
model: req.model,
|
||||
messages: req.messages.map(toSDKMessage),
|
||||
tools: req.tools.map(toSDKTool),
|
||||
})) as OpenAI.Chat.Completions.ChatCompletion;
|
||||
|
||||
const choice = completion.choices[0];
|
||||
if (choice === undefined) {
|
||||
throw new Error(`${this.id}: provider returned no choices`);
|
||||
}
|
||||
const base: Pick<ChatResponse, "finishReason" | "message"> = {
|
||||
finishReason: normalizeFinish(choice.finish_reason),
|
||||
message: fromSDKMessage(choice.message),
|
||||
};
|
||||
if (completion.usage !== undefined) {
|
||||
return {
|
||||
...base,
|
||||
usage: {
|
||||
inputTokens: completion.usage.prompt_tokens,
|
||||
outputTokens: completion.usage.completion_tokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
return base;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFinish(r: string | null): ChatResponse["finishReason"] {
|
||||
switch (r) {
|
||||
case "stop":
|
||||
return "stop";
|
||||
case "tool_calls":
|
||||
return "tool_calls";
|
||||
case "length":
|
||||
return "length";
|
||||
default:
|
||||
return "error";
|
||||
}
|
||||
}
|
||||
|
||||
function toSDKMessage(m: Message): OpenAI.Chat.Completions.ChatCompletionMessageParam {
|
||||
switch (m.role) {
|
||||
case "system":
|
||||
return { role: "system", content: joinText(m.parts) };
|
||||
case "user":
|
||||
return { role: "user", content: joinText(m.parts) };
|
||||
case "assistant": {
|
||||
const text = joinText(m.parts.filter(isText));
|
||||
const calls = m.parts.filter(isToolCall).map((c) => ({
|
||||
id: c.id,
|
||||
type: "function" as const,
|
||||
function: { name: c.name, arguments: c.arguments },
|
||||
}));
|
||||
if (calls.length > 0) {
|
||||
return { role: "assistant", content: text, tool_calls: calls };
|
||||
}
|
||||
return { role: "assistant", content: text };
|
||||
}
|
||||
case "tool": {
|
||||
const result = m.parts.find(isToolResult);
|
||||
if (result === undefined) {
|
||||
throw new Error("tool message without a tool_result part");
|
||||
}
|
||||
return {
|
||||
role: "tool",
|
||||
tool_call_id: result.tool_call_id,
|
||||
content: result.content,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fromSDKMessage(m: OpenAI.Chat.Completions.ChatCompletion.Choice["message"]): Message {
|
||||
const parts: MessagePart[] = [];
|
||||
if (typeof m.content === "string" && m.content.length > 0) {
|
||||
parts.push({ type: "text", text: m.content });
|
||||
}
|
||||
if (m.tool_calls !== undefined) {
|
||||
for (const c of m.tool_calls) {
|
||||
parts.push({
|
||||
type: "tool_call",
|
||||
id: c.id,
|
||||
name: c.function.name,
|
||||
arguments: c.function.arguments,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { role: "assistant", parts };
|
||||
}
|
||||
|
||||
function toSDKTool(t: ToolSpec): OpenAI.Chat.Completions.ChatCompletionTool {
|
||||
return {
|
||||
type: "function",
|
||||
function: { name: t.name, description: t.description, parameters: t.parameters as object },
|
||||
} as OpenAI.Chat.Completions.ChatCompletionTool;
|
||||
}
|
||||
|
||||
function joinText(parts: readonly MessagePart[]): string {
|
||||
return parts.filter(isText).map((p) => p.text).join("");
|
||||
}
|
||||
function isText(p: MessagePart): p is Extract<MessagePart, { type: "text" }> {
|
||||
return p.type === "text";
|
||||
}
|
||||
function isToolCall(p: MessagePart): p is Extract<MessagePart, { type: "tool_call" }> {
|
||||
return p.type === "tool_call";
|
||||
}
|
||||
function isToolResult(p: MessagePart): p is Extract<MessagePart, { type: "tool_result" }> {
|
||||
return p.type === "tool_result";
|
||||
}
|
||||
+13
-83
@@ -1,88 +1,18 @@
|
||||
/**
|
||||
* Provider-agnostic agent seam.
|
||||
* OpenRouter model factory.
|
||||
*
|
||||
* The Hub's agent loop talks to models through this interface, not through any
|
||||
* vendor SDK. The request/response shape follows the OpenAI chat-completions
|
||||
* de-facto standard (messages + tools + tool_calls) because that is the common
|
||||
* surface OpenRouter and most OpenAI-compatible providers expose; it is the
|
||||
* lingua franca, not an OpenAI commitment.
|
||||
*
|
||||
* ADR-0017: the agent layer is provider-agnostic. `@Claude` is a trigger brand,
|
||||
* not a provider contract. Switching model = new session (provider-bound), so
|
||||
* the seam never assumes cross-provider session continuity.
|
||||
*
|
||||
* This file defines the contract; see {@link OpenRouterProvider} for the
|
||||
* concrete adapter that points an OpenAI-compatible client at OpenRouter.
|
||||
* ADR-0017: the agent layer keeps role/model resolution separate from provider
|
||||
* construction. A run carries a resolved model id, and switching model still
|
||||
* means a new provider-bound session.
|
||||
*/
|
||||
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||
import type { LanguageModel } from "ai";
|
||||
|
||||
/** Why a run terminated. Mirrors the common provider surface. */
|
||||
export type FinishReason = "stop" | "tool_calls" | "length" | "error";
|
||||
|
||||
/** A contiguous text span in a message. */
|
||||
export interface TextPart {
|
||||
readonly type: "text";
|
||||
readonly text: string;
|
||||
}
|
||||
|
||||
/** A tool call the model wants the runner to execute. `arguments` is a JSON string. */
|
||||
export interface ToolCallPart {
|
||||
readonly type: "tool_call";
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly arguments: string;
|
||||
}
|
||||
|
||||
/** The result of a tool execution, addressed back to the originating call. */
|
||||
export interface ToolResultPart {
|
||||
readonly type: "tool_result";
|
||||
readonly tool_call_id: string;
|
||||
/** Free-form string content; structured payloads are JSON-encoded here. */
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
export type MessagePart = TextPart | ToolCallPart | ToolResultPart;
|
||||
|
||||
export type Role = "system" | "user" | "assistant" | "tool";
|
||||
|
||||
/** A single chat message: a role plus an ordered list of parts. */
|
||||
export interface Message {
|
||||
readonly role: Role;
|
||||
readonly parts: readonly MessagePart[];
|
||||
}
|
||||
|
||||
/** JSON-schema description of a tool the model may call. */
|
||||
export interface ToolSpec {
|
||||
readonly name: string;
|
||||
readonly description: string;
|
||||
/** A JSON Schema object describing the tool's parameters. */
|
||||
readonly parameters: unknown;
|
||||
}
|
||||
|
||||
export interface ChatRequest {
|
||||
readonly model: string;
|
||||
readonly messages: readonly Message[];
|
||||
readonly tools: readonly ToolSpec[];
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
readonly finishReason: FinishReason;
|
||||
/** The assistant message produced. May contain tool_calls. */
|
||||
readonly message: Message;
|
||||
readonly usage?: { readonly inputTokens: number; readonly outputTokens: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* The seam a model provider implements. Implementations are expected to be
|
||||
* OpenAI-compatible HTTP clients (OpenRouter, direct vendor APIs, local
|
||||
* gateways); the Hub does not hard-depend on any single-vendor agent SDK.
|
||||
*/
|
||||
export interface AgentProvider {
|
||||
readonly id: string;
|
||||
chat(req: ChatRequest): Promise<ChatResponse>;
|
||||
}
|
||||
|
||||
/** Extract tool calls from an assistant message, if any. */
|
||||
export function toolCallsOf(msg: Message): readonly ToolCallPart[] {
|
||||
if (msg.role !== "assistant") return [];
|
||||
return msg.parts.filter((p): p is ToolCallPart => p.type === "tool_call");
|
||||
export function createModelFactory(): (modelId: string) => LanguageModel {
|
||||
const provider = createOpenAICompatible({
|
||||
name: "openrouter",
|
||||
baseURL: process.env.OPENROUTER_BASE_URL ?? "https://openrouter.ai/api/v1",
|
||||
headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY ?? ""}` },
|
||||
});
|
||||
return (modelId: string) => provider(modelId);
|
||||
}
|
||||
|
||||
+64
-94
@@ -1,22 +1,22 @@
|
||||
/**
|
||||
* Provider-agnostic agent loop.
|
||||
* Provider-bound agent runner.
|
||||
*
|
||||
* The loop is ours (ADR-0017): dispatch tools, fold results, continue until the
|
||||
* model stops, a budget is exhausted, or a tool fails unrecoverably. It depends
|
||||
* only on {@link AgentProvider} + {@link ToolRegistry}, never on a vendor SDK.
|
||||
*
|
||||
* Session/provider binding (ADR-0017): a run is bound to one model. Switching
|
||||
* model is a *new* run + new session; cross-run continuity is carried by project
|
||||
* memory/anchors (ADR-0003), not by this loop.
|
||||
* The AI SDK owns tool-call dispatch and message folding. The Hub owns the
|
||||
* per-run tool context, role/model selection, transcript persistence, and the
|
||||
* ADR-0017 session boundary: a run is bound to one model. Switching model is a
|
||||
* new run + new session; cross-run continuity is carried by project
|
||||
* memory/anchors (ADR-0003), not by this runner.
|
||||
*/
|
||||
import type { AgentProvider, ChatResponse, Message, ToolCallPart } from "./provider.js";
|
||||
import { generateText, stepCountIs, type LanguageModel, type ModelMessage } from "ai";
|
||||
import type { ToolContext, ToolRegistry } from "./tools.js";
|
||||
import { readTranscript, appendTranscript } from "./transcript.js";
|
||||
|
||||
export type ModelFactory = (modelId: string) => LanguageModel;
|
||||
|
||||
/** What the runner needs about the project to seed and bound a run. */
|
||||
export interface ProjectContext {
|
||||
readonly projectId: string;
|
||||
/** ADR-0001 binding — the chat this project is bound to. */
|
||||
/** ADR-0001 binding - the chat this project is bound to. */
|
||||
readonly boundChatId: string;
|
||||
/** Absolute path to the curriculum engineering-file workspace. */
|
||||
readonly workspaceDir: string;
|
||||
@@ -30,6 +30,8 @@ export interface RunRequest {
|
||||
readonly systemPrompt: string | undefined;
|
||||
/** Iteration cap before the run is returned as `length`. Default 25. */
|
||||
readonly maxIterations?: number;
|
||||
/** Per-role tool whitelist (ADR-0017). Undefined means all registered tools. */
|
||||
readonly toolWhitelist?: readonly string[] | undefined;
|
||||
/** Path to the session transcript JSONL. If set, prior messages are loaded
|
||||
* from it before the run, and new messages are appended after. */
|
||||
readonly transcriptPath?: string;
|
||||
@@ -40,20 +42,15 @@ export type RunStatus = "completed" | "length" | "failed";
|
||||
export interface RunResult {
|
||||
readonly status: RunStatus;
|
||||
/** Full transcript of the run, for audit (ADR Audit, content OPEN). */
|
||||
readonly messages: readonly Message[];
|
||||
readonly messages: readonly ModelMessage[];
|
||||
readonly usage: { inputTokens: number; outputTokens: number };
|
||||
readonly error?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_ITERATIONS = 25;
|
||||
|
||||
/**
|
||||
* Run one agent task to completion (or budget exhaustion).
|
||||
*
|
||||
* @throws if the provider call itself fails before any tool runs.
|
||||
*/
|
||||
export async function runAgent(
|
||||
provider: AgentProvider,
|
||||
modelFactory: ModelFactory,
|
||||
tools: ToolRegistry,
|
||||
req: RunRequest,
|
||||
): Promise<RunResult> {
|
||||
@@ -63,108 +60,81 @@ export async function runAgent(
|
||||
boundChatId: req.project.boundChatId,
|
||||
workspaceDir: req.project.workspaceDir,
|
||||
};
|
||||
const messages: Message[] = [];
|
||||
const aiTools = tools.build(ctx, req.toolWhitelist);
|
||||
|
||||
// Load prior session messages from transcript (continuity across @bot calls
|
||||
// in the same session). ADR-0003: session messages ≠ group chat history.
|
||||
let loadedCount = 0;
|
||||
if (req.transcriptPath !== undefined) {
|
||||
const prior = await readTranscript(req.transcriptPath);
|
||||
loadedCount = prior.length;
|
||||
messages.push(...prior);
|
||||
}
|
||||
// in the same session). ADR-0003: session messages != group chat history.
|
||||
let messages: ModelMessage[] = req.transcriptPath !== undefined ? await readTranscript(req.transcriptPath) : [];
|
||||
const loadedCount = messages.length;
|
||||
if (req.systemPrompt !== undefined && messages.length === 0) {
|
||||
// System prompt only at the very start of a session; on resume it's
|
||||
// already in the transcript.
|
||||
messages.push({ role: "system", parts: [{ type: "text", text: req.systemPrompt }] });
|
||||
messages = [{ role: "system", content: req.systemPrompt }];
|
||||
}
|
||||
messages.push({ role: "user", parts: [{ type: "text", text: req.prompt }] });
|
||||
messages = [...messages, { role: "user", content: req.prompt }];
|
||||
|
||||
const cap = req.maxIterations ?? DEFAULT_MAX_ITERATIONS;
|
||||
let inputTokens = 0;
|
||||
let outputTokens = 0;
|
||||
|
||||
for (let i = 0; i < cap; i++) {
|
||||
let res: ChatResponse;
|
||||
try {
|
||||
res = await provider.chat({
|
||||
model: req.model,
|
||||
messages,
|
||||
tools: tools.specs(),
|
||||
});
|
||||
} catch (e) {
|
||||
const result: RunResult = {
|
||||
status: "failed",
|
||||
messages,
|
||||
usage: { inputTokens, outputTokens },
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
await appendTranscriptIfSet(req, messages, loadedCount);
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
const result = await generateText({
|
||||
model: modelFactory(req.model),
|
||||
messages,
|
||||
allowSystemInMessages: true,
|
||||
tools: aiTools,
|
||||
stopWhen: stepCountIs(cap),
|
||||
});
|
||||
const allMessages: ModelMessage[] = [...messages, ...result.responseMessages];
|
||||
await appendTranscriptIfSet(req, allMessages, loadedCount);
|
||||
|
||||
messages.push(res.message);
|
||||
if (res.usage !== undefined) {
|
||||
inputTokens += res.usage.inputTokens;
|
||||
outputTokens += res.usage.outputTokens;
|
||||
}
|
||||
const stoppedByStepCap = result.finishReason === "tool-calls" && result.steps.length >= cap;
|
||||
const status: RunStatus =
|
||||
result.finishReason === "stop"
|
||||
? "completed"
|
||||
: result.finishReason === "length" || stoppedByStepCap
|
||||
? "length"
|
||||
: "failed";
|
||||
const error =
|
||||
status === "failed"
|
||||
? `finishReason=${result.finishReason}`
|
||||
: stoppedByStepCap
|
||||
? `exceeded maxIterations=${cap}`
|
||||
: undefined;
|
||||
|
||||
if (res.finishReason === "stop") {
|
||||
await appendTranscriptIfSet(req, messages, loadedCount);
|
||||
return { status: "completed", messages, usage: { inputTokens, outputTokens } };
|
||||
}
|
||||
if (res.finishReason !== "tool_calls") {
|
||||
await appendTranscriptIfSet(req, messages, loadedCount);
|
||||
return { status: "length", messages, usage: { inputTokens, outputTokens } };
|
||||
}
|
||||
|
||||
const calls = toolCallsOfAssistant(res.message);
|
||||
for (const call of calls) {
|
||||
const args = safeParseArgs(call);
|
||||
const content = await tools.execute(call.name, args, ctx);
|
||||
messages.push({
|
||||
role: "tool",
|
||||
parts: [{ type: "tool_result", tool_call_id: call.id, content }],
|
||||
});
|
||||
}
|
||||
return {
|
||||
status,
|
||||
messages: allMessages,
|
||||
usage: {
|
||||
inputTokens: result.usage.inputTokens ?? 0,
|
||||
outputTokens: result.usage.outputTokens ?? 0,
|
||||
},
|
||||
...(error !== undefined ? { error } : {}),
|
||||
};
|
||||
} catch (e) {
|
||||
await appendTranscriptIfSet(req, messages, loadedCount);
|
||||
return {
|
||||
status: "failed",
|
||||
messages,
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
|
||||
await appendTranscriptIfSet(req, messages, loadedCount);
|
||||
return {
|
||||
status: "length",
|
||||
messages,
|
||||
usage: { inputTokens, outputTokens },
|
||||
error: `exceeded maxIterations=${cap}`,
|
||||
};
|
||||
}
|
||||
|
||||
/// Append only the messages produced this run (skip the `loadedCount` prior
|
||||
/// ones already in the file). Errors are swallowed — a transcript write
|
||||
/// ones already in the file). Errors are swallowed - a transcript write
|
||||
/// failure must not lose the run result.
|
||||
async function appendTranscriptIfSet(req: RunRequest, messages: readonly Message[], loadedCount: number): Promise<void> {
|
||||
async function appendTranscriptIfSet(req: RunRequest, messages: readonly ModelMessage[], loadedCount: number): Promise<void> {
|
||||
if (req.transcriptPath === undefined) return;
|
||||
const newMessages = messages.slice(loadedCount);
|
||||
if (newMessages.length === 0) return;
|
||||
try {
|
||||
await appendTranscript(req.transcriptPath, newMessages);
|
||||
} catch {
|
||||
// Swallow — transcript is best-effort persistence; the run result is
|
||||
// Swallow - transcript is best-effort persistence; the run result is
|
||||
// returned regardless. ADR-0002 lock ensures no concurrent writer.
|
||||
}
|
||||
}
|
||||
|
||||
function toolCallsOfAssistant(msg: Message): readonly ToolCallPart[] {
|
||||
if (msg.role !== "assistant") return [];
|
||||
return msg.parts.filter((p): p is ToolCallPart => p.type === "tool_call");
|
||||
}
|
||||
|
||||
function safeParseArgs(call: ToolCallPart): unknown {
|
||||
try {
|
||||
return JSON.parse(call.arguments);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function cryptoRandomId(): string {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
+41
-81
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Tool registry — the agent's narrow, project-scoped tool surface.
|
||||
* Tool registry - the agent's narrow, project-scoped tool surface.
|
||||
*
|
||||
* Unlike a general "Claude Code", the Hub agent operates on a curriculum
|
||||
* engineering-file tree (ADR-0007) and reads Feishu context on demand
|
||||
@@ -10,13 +10,13 @@
|
||||
* coupled only via the stable CLI contract ADR-0016),
|
||||
* - Feishu context reads, which must honor ADR-0003's authorization invariant.
|
||||
*
|
||||
* That last invariant is encoded here: a Feishu context tool handler rejects any
|
||||
* chat id that is not the run's project's bound chat (ADR-0001 binding). This is
|
||||
* `McpReadRequest.Authorized` from `spec/System/Memory.lean` landing in code.
|
||||
* The AI SDK owns tool-call dispatch; this registry owns Hub-specific tool
|
||||
* construction and per-role whitelisting (ADR-0017).
|
||||
*/
|
||||
import type { ToolSpec } from "./provider.js";
|
||||
import { tool, type Tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
/** Per-run execution context handed to every tool handler. */
|
||||
/** Per-run execution context closed over by every tool execute function. */
|
||||
export interface ToolContext {
|
||||
readonly runId: string;
|
||||
readonly projectId: string;
|
||||
@@ -26,64 +26,34 @@ export interface ToolContext {
|
||||
readonly workspaceDir: string;
|
||||
}
|
||||
|
||||
/** A tool handler: receives parsed args + run context, returns content for the model. */
|
||||
export type ToolHandler = (args: unknown, ctx: ToolContext) => Promise<string>;
|
||||
|
||||
export interface RegisteredTool {
|
||||
readonly spec: ToolSpec;
|
||||
readonly execute: ToolHandler;
|
||||
}
|
||||
export type ToolDefinition = Tool;
|
||||
export type ToolFactory = (ctx: ToolContext) => ToolDefinition;
|
||||
|
||||
export class ToolRegistry {
|
||||
private readonly byName = new Map<string, RegisteredTool>();
|
||||
private readonly factories = new Map<string, ToolFactory>();
|
||||
|
||||
register(tool: RegisteredTool): void {
|
||||
if (this.byName.has(tool.spec.name)) {
|
||||
throw new Error(`duplicate tool: ${tool.spec.name}`);
|
||||
register(name: string, factory: ToolFactory): void {
|
||||
if (this.factories.has(name)) {
|
||||
throw new Error(`duplicate tool: ${name}`);
|
||||
}
|
||||
this.byName.set(tool.spec.name, tool);
|
||||
}
|
||||
|
||||
specs(): readonly ToolSpec[] {
|
||||
return [...this.byName.values()].map((t) => t.spec);
|
||||
}
|
||||
|
||||
has(name: string): boolean {
|
||||
return this.byName.has(name);
|
||||
}
|
||||
|
||||
/** Execute a tool by name. Throws on unknown tool or authorization failure. */
|
||||
async execute(name: string, args: unknown, ctx: ToolContext): Promise<string> {
|
||||
const tool = this.byName.get(name);
|
||||
if (tool === undefined) {
|
||||
return JSON.stringify({ error: `unknown tool: ${name}` });
|
||||
}
|
||||
return tool.execute(args, ctx);
|
||||
this.factories.set(name, factory);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a per-run view restricted to `names`. Names not registered are
|
||||
* silently dropped (a role's whitelist may name tools that a particular Hub
|
||||
* instance doesn't register). The returned registry shares the handler
|
||||
* references — no copy of the closures.
|
||||
*
|
||||
* Per-role tool whitelisting (ADR-0017: role-based routing is product
|
||||
* config). The runner receives a subset registry so the model literally
|
||||
* never sees tools outside its role's whitelist: the tool list sent to the
|
||||
* provider is `subset.specs()`, and `execute` on a name not in the subset
|
||||
* returns an error.
|
||||
* Build the AI SDK `tools` record. If `names` is given, include only those
|
||||
* tools (per-role whitelist, ADR-0017). Names not registered are silently
|
||||
* dropped because role config may name tools a Hub instance does not provide.
|
||||
*/
|
||||
subset(names: readonly string[]): ToolRegistry {
|
||||
const allowed = new Set(names);
|
||||
const view = new ToolRegistry();
|
||||
for (const [name, tool] of this.byName) {
|
||||
if (allowed.has(name)) {
|
||||
// Bypass the duplicate check: we're constructing from an already-valid
|
||||
// registry, not re-registering.
|
||||
view.byName.set(name, tool);
|
||||
build(ctx: ToolContext, names: readonly string[] | undefined): Record<string, ToolDefinition> {
|
||||
const built: Record<string, ToolDefinition> = {};
|
||||
const selected = names ?? [...this.factories.keys()];
|
||||
for (const name of selected) {
|
||||
const factory = this.factories.get(name);
|
||||
if (factory !== undefined) {
|
||||
built[name] = factory(ctx);
|
||||
}
|
||||
}
|
||||
return view;
|
||||
return built;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,8 +82,9 @@ export interface FeishuContextArgs {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Feishu context-read tool. The handler enforces ADR-0003's invariant
|
||||
* before any API call: the requested `chat_id` must equal `ctx.boundChatId`.
|
||||
* Build the Feishu context-read tool factory. The execute closure enforces
|
||||
* ADR-0003's invariant before any API call: the requested `chat_id` must equal
|
||||
* `ctx.boundChatId`.
|
||||
*
|
||||
* The actual Feishu API call (read message / reply chain / thread / recent
|
||||
* group messages) is injected as `read`, so the invariant check is separable
|
||||
@@ -123,32 +94,21 @@ export interface FeishuContextArgs {
|
||||
export function feishuContextTool(
|
||||
read: (args: FeishuContextArgs, ctx: ToolContext, rt: unknown) => Promise<string>,
|
||||
rt: unknown,
|
||||
): RegisteredTool {
|
||||
return {
|
||||
spec: {
|
||||
name: "feishu_read_context",
|
||||
): ToolFactory {
|
||||
return (ctx) =>
|
||||
tool({
|
||||
description:
|
||||
"Read on-demand Feishu context (a trigger message, status card, reply, or thread) for the current project's bound chat. The chat id must match the project binding.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
chat_id: { type: "string", description: "The Feishu chat id to read from." },
|
||||
anchor: {
|
||||
type: "string",
|
||||
enum: ["trigger_message", "status_card", "reply", "thread"],
|
||||
description: "Which kind of anchor to read.",
|
||||
},
|
||||
id: { type: "string", description: "The anchor id (message id or run id)." },
|
||||
},
|
||||
required: ["chat_id", "anchor", "id"],
|
||||
inputSchema: z.object({
|
||||
chat_id: z.string().describe("The Feishu chat id to read from."),
|
||||
anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of anchor to read."),
|
||||
id: z.string().describe("The anchor id (message id or run id)."),
|
||||
}),
|
||||
execute: async (args): Promise<string> => {
|
||||
if (args.chat_id !== ctx.boundChatId) {
|
||||
throw new UnauthorizedChatRead(args.chat_id, ctx.boundChatId);
|
||||
}
|
||||
return read(args, ctx, rt);
|
||||
},
|
||||
},
|
||||
async execute(args, ctx): Promise<string> {
|
||||
const a = args as FeishuContextArgs;
|
||||
if (a.chat_id !== ctx.boundChatId) {
|
||||
throw new UnauthorizedChatRead(a.chat_id, ctx.boundChatId);
|
||||
}
|
||||
return read(a, ctx, rt);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Session transcript — append-only JSONL file in the workspace.
|
||||
*
|
||||
* Each line is one {@link Message}, JSON-encoded. The transcript lives at
|
||||
* Each line is one AI SDK {@link ModelMessage}, JSON-encoded. The transcript lives at
|
||||
* `workspaceDir/.cph/sessions/<sessionId>.jsonl`, following the same "workspace
|
||||
* is a directory tree" principle as the engineering file itself (ADR-0007).
|
||||
*
|
||||
@@ -15,9 +15,9 @@
|
||||
* distinct; storing session messages does not conflict with "the Hub avoids
|
||||
* becoming a full chat history store."
|
||||
*/
|
||||
import { readFile, writeFile, mkdir, appendFile } from "node:fs/promises";
|
||||
import { readFile, mkdir, appendFile } from "node:fs/promises";
|
||||
import { join, dirname } from "node:path";
|
||||
import type { Message } from "./provider.js";
|
||||
import type { ModelMessage } from "ai";
|
||||
|
||||
/// Subdirectory within a workspace where session transcripts live.
|
||||
export const TRANSCRIPT_DIR = ".cph/sessions";
|
||||
@@ -33,19 +33,19 @@ export function transcriptPath(workspaceDir: string, sessionId: string): string
|
||||
* skipped — a partially-written last line (crash mid-append) won't break the
|
||||
* next run.
|
||||
*/
|
||||
export async function readTranscript(path: string): Promise<Message[]> {
|
||||
export async function readTranscript(path: string): Promise<ModelMessage[]> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(path, "utf8");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const messages: Message[] = [];
|
||||
const messages: ModelMessage[] = [];
|
||||
for (const line of content.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === "") continue;
|
||||
try {
|
||||
messages.push(JSON.parse(trimmed) as Message);
|
||||
messages.push(JSON.parse(trimmed) as ModelMessage);
|
||||
} catch {
|
||||
// Skip corrupt line — likely a partial write from a crash.
|
||||
}
|
||||
@@ -57,7 +57,7 @@ export async function readTranscript(path: string): Promise<Message[]> {
|
||||
* Append messages to a transcript file. Creates the parent directory if needed.
|
||||
* Each message is written as one JSON line.
|
||||
*/
|
||||
export async function appendTranscript(path: string, messages: readonly Message[]): Promise<void> {
|
||||
export async function appendTranscript(path: string, messages: readonly ModelMessage[]): Promise<void> {
|
||||
await mkdir(dirname(path), { recursive: true });
|
||||
const lines = messages.map((m) => JSON.stringify(m)).join("\n") + "\n";
|
||||
await appendFile(path, lines, "utf8");
|
||||
|
||||
+36
-66
@@ -1,16 +1,17 @@
|
||||
/**
|
||||
* Workspace file tools — bounded read/write/list against the project's
|
||||
* Workspace file tools - bounded read/write/list against the project's
|
||||
* curriculum engineering-file tree (ADR-0007 directory tree).
|
||||
*
|
||||
* All paths are **confined to `ctx.workspaceDir`**: the handler resolves the
|
||||
* requested relative path against the workspace root and rejects any path that
|
||||
* escapes it (via `..` or absolute paths). This is the agent's only file
|
||||
* surface — there is no general bash escape hatch, by design (the Hub agent is
|
||||
* narrower than a general "Claude Code").
|
||||
* surface; the Hub agent is narrower than a general coding agent.
|
||||
*/
|
||||
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
|
||||
import { join, resolve, relative, isAbsolute } from "node:path";
|
||||
import type { RegisteredTool } from "./tools.js";
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
import type { ToolContext, ToolDefinition } from "./tools.js";
|
||||
|
||||
/** Thrown when a requested path escapes the project workspace root. */
|
||||
export class PathEscape extends Error {
|
||||
@@ -38,68 +39,42 @@ function confine(requestedPath: string, workspaceDir: string): string {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
interface ReadFileArgs {
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
export function readFileTool(): RegisteredTool {
|
||||
return {
|
||||
spec: {
|
||||
name: "read_file",
|
||||
description: "Read a file from the project's curriculum engineering-file workspace. Path is relative to the workspace root.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string", description: "Relative path within the workspace." } },
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
async execute(args, ctx): Promise<string> {
|
||||
const a = args as ReadFileArgs;
|
||||
export function readFileTool(ctx: ToolContext): ToolDefinition {
|
||||
return tool({
|
||||
description: "Read a file from the project's curriculum engineering-file workspace. Path is relative to the workspace root.",
|
||||
inputSchema: z.object({
|
||||
path: z.string().describe("Relative path within the workspace."),
|
||||
}),
|
||||
execute: async (args): Promise<string> => {
|
||||
try {
|
||||
const full = confine(a.path, ctx.workspaceDir);
|
||||
const full = confine(args.path, ctx.workspaceDir);
|
||||
return await readFile(full, "utf8");
|
||||
} catch (e) {
|
||||
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
interface WriteFileArgs {
|
||||
readonly path: string;
|
||||
readonly content: string;
|
||||
}
|
||||
|
||||
export function writeFileTool(): RegisteredTool {
|
||||
return {
|
||||
spec: {
|
||||
name: "write_file",
|
||||
description: "Write a file in the project's curriculum engineering-file workspace. Path is relative to the workspace root. Creates parent directories.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Relative path within the workspace." },
|
||||
content: { type: "string", description: "The full file content to write." },
|
||||
},
|
||||
required: ["path", "content"],
|
||||
},
|
||||
},
|
||||
async execute(args, ctx): Promise<string> {
|
||||
const a = args as WriteFileArgs;
|
||||
export function writeFileTool(ctx: ToolContext): ToolDefinition {
|
||||
return tool({
|
||||
description:
|
||||
"Write a file in the project's curriculum engineering-file workspace. Path is relative to the workspace root. Creates parent directories.",
|
||||
inputSchema: z.object({
|
||||
path: z.string().describe("Relative path within the workspace."),
|
||||
content: z.string().describe("The full file content to write."),
|
||||
}),
|
||||
execute: async (args): Promise<string> => {
|
||||
try {
|
||||
const full = confine(a.path, ctx.workspaceDir);
|
||||
const full = confine(args.path, ctx.workspaceDir);
|
||||
await mkdir(join(full, ".."), { recursive: true });
|
||||
await writeFile(full, a.content, "utf8");
|
||||
return JSON.stringify({ ok: true, path: a.path, bytes: a.content.length });
|
||||
await writeFile(full, args.content, "utf8");
|
||||
return JSON.stringify({ ok: true, path: args.path, bytes: args.content.length });
|
||||
} catch (e) {
|
||||
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface ListFilesArgs {
|
||||
readonly path?: string;
|
||||
});
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
@@ -107,19 +82,14 @@ interface Entry {
|
||||
readonly kind: "file" | "dir";
|
||||
}
|
||||
|
||||
export function listFilesTool(): RegisteredTool {
|
||||
return {
|
||||
spec: {
|
||||
name: "list_files",
|
||||
description: "List files and directories at a path within the workspace. Defaults to the workspace root.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { path: { type: "string", description: "Relative path within the workspace; defaults to root." } },
|
||||
},
|
||||
},
|
||||
async execute(args, ctx): Promise<string> {
|
||||
const a = (args as ListFilesArgs) ?? {};
|
||||
const sub = a.path ?? ".";
|
||||
export function listFilesTool(ctx: ToolContext): ToolDefinition {
|
||||
return tool({
|
||||
description: "List files and directories at a path within the workspace. Defaults to the workspace root.",
|
||||
inputSchema: z.object({
|
||||
path: z.string().describe("Relative path within the workspace; defaults to root.").optional(),
|
||||
}),
|
||||
execute: async (args): Promise<string> => {
|
||||
const sub = args.path ?? ".";
|
||||
try {
|
||||
const full = confine(sub, ctx.workspaceDir);
|
||||
const entries = await readdir(full, { withFileTypes: true });
|
||||
@@ -132,5 +102,5 @@ export function listFilesTool(): RegisteredTool {
|
||||
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user