Files
curriculum-project-hub/hub/src/agent/provider.ts
T
hongjr03 3bca137b48 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 通过。
2026-07-06 22:38:04 +08:00

89 lines
3.0 KiB
TypeScript

/**
* Provider-agnostic agent seam.
*
* 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.
*/
/** 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");
}