forked from EduCraft/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:
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
Generated
+1005
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user