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
+3
View File
@@ -11,5 +11,8 @@
# regenerable, not for VCS. The embedded engine mounts cph-render directly. # regenerable, not for VCS. The embedded engine mounts cph-render directly.
render/vendor/local-packages/ render/vendor/local-packages/
# Node (hub/ TS workspace and any future JS package)
node_modules/
# OS / editor # OS / editor
.DS_Store .DS_Store
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
*.log
.env
.env.*
!.env.example
+1005
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@paradigm/hub",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": { "node": ">=20" },
"dependencies": {
"openai": "^4.77.0"
},
"devDependencies": {
"@types/node": "^22.10.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
},
"description": "Curriculum Project Hub — Feishu-group collaboration + provider-agnostic agent runtime. Aligns to spec/System (ADR-0001..0004, 0017).",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"check": "tsc -p tsconfig.json --noEmit"
}
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Per-run model selection & role-based routing.
*
* ADR-0017 consequence: role-based model routing (different run kinds default
* to different models) is a **product/admin configuration** concern, not a spec
* invariant. This registry is the seam for that config; the concrete policy
* (which models are admin-enabled, which role maps to which default) is `OPEN`
* here — decided by admin settings + ADR, not hard-coded.
*/
/** Coarse run role, used to pick a default model. Open set: add as product grows. */
export type RunRole = "draft" | "review" | "triage";
/** A model the admin has enabled for use by the Hub. */
export interface ModelEntry {
readonly id: string;
/** Human label for the teacher-side switcher UX. */
readonly label: string;
/** True when the model is known to support tool use reliably. */
readonly toolCapable: boolean;
}
export interface ModelRegistry {
/** All models admin-enabled for this Hub instance. */
list(): readonly ModelEntry[];
/** Default model id for a given run role, if a routing rule exists. */
defaultFor(role: RunRole): string | undefined;
}
/**
* A simple in-memory registry. Real wiring reads from admin settings / DB; this
* is the skeleton seam. The policy (role→model map) is intentionally pluggable.
*/
export class InMemoryModelRegistry implements ModelRegistry {
private readonly models: readonly ModelEntry[];
private readonly defaults: Map<RunRole, string>;
constructor(models: readonly ModelEntry[], defaults: Partial<Record<RunRole, string>> = {}) {
this.models = models;
this.defaults = new Map(
(Object.entries(defaults) as [RunRole, string][]).filter(([, v]) => v !== undefined),
);
}
list(): readonly ModelEntry[] {
return this.models;
}
defaultFor(role: RunRole): string | undefined {
return this.defaults.get(role);
}
/** Resolve a model id, falling back to the role default, then the first enabled. */
resolve(requested: string | undefined, role: RunRole): string {
if (requested !== undefined && this.models.some((m) => m.id === requested)) {
return requested;
}
const d = this.defaultFor(role);
if (d !== undefined) return d;
const first = this.models[0];
if (first === undefined) throw new Error("no models enabled for this Hub");
return first.id;
}
}
+157
View File
@@ -0,0 +1,157 @@
/**
* 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";
}
+88
View File
@@ -0,0 +1,88 @@
/**
* 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");
}
+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();
}
+128
View File
@@ -0,0 +1,128 @@
/**
* 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
* (ADR-0003). Its tools are therefore bounded:
*
* - file-tree read/write against the project workspace dir,
* - `cph check` / `cph build` subprocess calls (the Courseware-side checker,
* 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.
*/
import type { ToolSpec } from "./provider.js";
/** Per-run execution context handed to every tool handler. */
export interface ToolContext {
readonly runId: string;
readonly projectId: string;
/** ADR-0001: the chat this project is bound to. Feishu reads must stay in it. */
readonly boundChatId: string;
/** Absolute path to the project's curriculum engineering-file workspace. */
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 class ToolRegistry {
private readonly byName = new Map<string, RegisteredTool>();
register(tool: RegisteredTool): void {
if (this.byName.has(tool.spec.name)) {
throw new Error(`duplicate tool: ${tool.spec.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);
}
}
/**
* Thrown when a Feishu context read targets a chat other than the run's project's
* bound chat. This is the runtime enforcement of ADR-0003's
* `McpReadRequest.Authorized`: "Claude cannot pass arbitrary chat ids".
*/
export class UnauthorizedChatRead extends Error {
constructor(
readonly requestedChatId: string,
readonly boundChatId: string,
) {
super(
`refused Feishu context read: requested chat ${requestedChatId} is not the run's project's bound chat ${boundChatId} (ADR-0003)`,
);
this.name = "UnauthorizedChatRead";
}
}
/** Schema for the Feishu context read tool's arguments. */
export interface FeishuContextArgs {
readonly chat_id: string;
readonly anchor: "trigger_message" | "status_card" | "reply" | "thread";
readonly id: string;
}
/**
* 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`.
*
* The actual Feishu API call (read message / reply chain / thread / recent
* group messages) is injected as `read`, so the invariant check is separable
* from the transport. In tests `read` can be a stub; in production it wraps
* `@larksuiteoapi/node-sdk`.
*/
export function feishuContextTool(
read: (args: FeishuContextArgs, ctx: ToolContext) => Promise<string>,
): RegisteredTool {
return {
spec: {
name: "feishu_read_context",
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"],
},
},
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);
},
};
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Hub entry — skeleton.
*
* Wires the provider-agnostic agent seam end-to-end with a no-op Feishu read, so
* the invariant path is exercised without live credentials. Real wiring (Fastify
* server, Feishu OAuth/event callbacks, Prisma, admin-web) lands incrementally;
* this file proves the seam compiles and the pieces connect.
*/
import { OpenRouterProvider } from "./agent/openrouter-provider.js";
import { InMemoryModelRegistry } from "./agent/models.js";
import { ToolRegistry, feishuContextTool } from "./agent/tools.js";
import { runAgent } from "./agent/runner.js";
function main(): void {
const apiKey = process.env["OPENROUTER_API_KEY"];
if (apiKey === undefined || apiKey === "") {
console.error("[hub] OPENROUTER_API_KEY not set; nothing to run. Exiting.");
process.exit(1);
}
const provider = new OpenRouterProvider({ apiKey });
const models = new InMemoryModelRegistry(
[
{ id: "z-ai/glm-4.6", label: "GLM-4.6", toolCapable: true },
{ id: "anthropic/claude-3.5-sonnet", label: "Claude 3.5 Sonnet", toolCapable: true },
],
{ draft: "z-ai/glm-4.6", review: "anthropic/claude-3.5-sonnet" },
);
const tools = new ToolRegistry();
tools.register(
feishuContextTool(async (args) => {
// Skeleton: no live Feishu calls. Real impl wraps @larksuiteoapi/node-sdk.
return JSON.stringify({ stub: true, anchor: args.anchor, id: args.id });
}),
);
const model = models.resolve(undefined, "draft");
runAgent(provider, tools, {
prompt: "ping",
model,
role: "draft",
project: {
projectId: "proj-skeleton",
boundChatId: "chat-skeleton",
workspaceDir: "/tmp/cph-skeleton",
},
})
.then((r) => {
console.log("[hub] run finished", { status: r.status, model, usage: r.usage });
if (r.error !== undefined) console.error("[hub] error:", r.error);
})
.catch((e) => {
console.error("[hub] run threw:", e);
process.exit(1);
});
}
main();
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*.ts"]
}