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:
@@ -11,5 +11,8 @@
|
||||
# regenerable, not for VCS. The embedded engine mounts cph-render directly.
|
||||
render/vendor/local-packages/
|
||||
|
||||
# Node (hub/ TS workspace and any future JS package)
|
||||
node_modules/
|
||||
|
||||
# OS / editor
|
||||
.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