forked from bai/curriculum-project-hub
feat(hub): provider-agnostic agent seam 骨架(ADR-0001/0003/0017)
hub/ TS 新部件,平级于 spec/。本仓从零 TS 重写(非迁移旧服务),栈: Fastify+Prisma 待补;本提交落 provider-agnostic agent runtime 骨架: - provider.ts: AgentProvider seam,OpenAI-compatible messages+tools+tool_calls 形状(语言通解,非 OpenAI 承诺);@Claude 仅为触发品牌(ADR-0017) - openrouter-provider.ts: openai SDK 指向 OpenRouter 的具体适配器,仅做 Message↔SDK 翻译;agent loop 不 import SDK,换 provider 不改 loop - runner.ts: 自有 agent loop(dispatch tools/循环到 stop/budget),per-run model(ADR-0017 role-based routing,run 带其 model) - tools.ts: 窄工具面 + ADR-0003 McpReadRequest.Authorized 落代码: feishu_read_context 拒绝非本项目绑定 chat 的读取(实测错 chat 抛 UnauthorizedChatRead,对 chat 放行) - models.ts: ModelRegistry seam(role→default 映射策略 OPEN) - server.ts: 接线入口(stub Feishu read,无活凭证即可编译) tsc --noEmit rc=0;ADR-0003 不变式 smoke 通过。
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Provider-agnostic agent loop.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import type { AgentProvider, ChatResponse, Message, ToolCallPart } from "./provider.js";
|
||||
import type { ToolContext, ToolRegistry } from "./tools.js";
|
||||
import type { RunRole } from "./models.js";
|
||||
|
||||
/** 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. */
|
||||
readonly boundChatId: string;
|
||||
/** Absolute path to the curriculum engineering-file workspace. */
|
||||
readonly workspaceDir: string;
|
||||
}
|
||||
|
||||
export interface RunRequest {
|
||||
readonly prompt: string;
|
||||
/** Resolved model id (already chosen by the caller per ADR-0017). */
|
||||
readonly model: string;
|
||||
readonly role: RunRole;
|
||||
readonly project: ProjectContext;
|
||||
readonly systemPrompt?: string;
|
||||
/** Iteration cap before the run is returned as `length`. Default 25. */
|
||||
readonly maxIterations?: number;
|
||||
}
|
||||
|
||||
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 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,
|
||||
tools: ToolRegistry,
|
||||
req: RunRequest,
|
||||
): Promise<RunResult> {
|
||||
const ctx: ToolContext = {
|
||||
runId: cryptoRandomId(),
|
||||
projectId: req.project.projectId,
|
||||
boundChatId: req.project.boundChatId,
|
||||
workspaceDir: req.project.workspaceDir,
|
||||
};
|
||||
|
||||
const messages: Message[] = [];
|
||||
if (req.systemPrompt !== undefined) {
|
||||
messages.push({ role: "system", parts: [{ type: "text", text: req.systemPrompt }] });
|
||||
}
|
||||
messages.push({ role: "user", parts: [{ type: "text", text: 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) {
|
||||
return {
|
||||
status: "failed",
|
||||
messages,
|
||||
usage: { inputTokens, outputTokens },
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
|
||||
messages.push(res.message);
|
||||
if (res.usage !== undefined) {
|
||||
inputTokens += res.usage.inputTokens;
|
||||
outputTokens += res.usage.outputTokens;
|
||||
}
|
||||
|
||||
if (res.finishReason === "stop") {
|
||||
return { status: "completed", messages, usage: { inputTokens, outputTokens } };
|
||||
}
|
||||
if (res.finishReason !== "tool_calls") {
|
||||
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: "length",
|
||||
messages,
|
||||
usage: { inputTokens, outputTokens },
|
||||
error: `exceeded maxIterations=${cap}`,
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user