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:
2026-07-06 22:38:04 +08:00
parent 18aac1ff16
commit 3bca137b48
11 changed files with 1694 additions and 0 deletions
+139
View File
@@ -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();
}