From a2c8fa8eafe46ebebe38d42811129c09a210f3d5 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Tue, 7 Jul 2026 20:07:02 +0800 Subject: [PATCH] =?UTF-8?q?refactor(hub):=20=E6=89=8B=E6=90=93=20agent=20l?= =?UTF-8?q?oop=20=E6=8D=A2=E6=88=90=20Vercel=20AI=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 provider/runner/tools 三件手搓轮子替换为 AI SDK v7 的 generateText + tool: - 删 openrouter-provider.ts(翻译层消失)、provider.ts 自定义消息类型 - runner.ts 循环体改为 generateText({stopWhen: stepCountIs}),保留 RunRequest/RunResult 公共签名 - tools.ts ToolRegistry 改为 ToolFactory + build(ctx, names?) 按 role 白名单构建 tools record - workspace/cph/feishu 工具改写为 tool() 定义,execute 闭包捕获 per-run ToolContext - transcript.ts 改为 ModelMessage[] JSONL(0.0.0 cutover,无迁移) - server.ts/trigger.ts 接 modelFactory + toolWhitelist - 测试:helpers 写 MockLanguageModel implements LanguageModelV4;5 个测试改写 + 新增 runner.test.ts - 移除未使用的 openai 依赖 - ADR-0017 Consequences 同步:loop 委托给 AI SDK,provider seam 是 LanguageModel tsc 干净,54 tests 全过。 --- hub/package-lock.json | 293 ++++++++++----------------- hub/package.json | 4 +- hub/src/agent/cph.ts | 89 +++----- hub/src/agent/openrouter-provider.ts | 157 -------------- hub/src/agent/provider.ts | 96 ++------- hub/src/agent/runner.ts | 158 ++++++--------- hub/src/agent/tools.ts | 122 ++++------- hub/src/agent/transcript.ts | 14 +- hub/src/agent/workspace.ts | 102 ++++------ hub/src/feishu/trigger.ts | 18 +- hub/src/server.ts | 24 +-- hub/test/integration/cph.test.ts | 33 ++- hub/test/integration/helpers.ts | 96 +++++++-- hub/test/integration/trigger.test.ts | 33 +-- hub/test/unit/runner.test.ts | 39 ++++ hub/test/unit/transcript.test.ts | 6 +- hub/test/unit/workspace.test.ts | 40 ++-- 17 files changed, 516 insertions(+), 808 deletions(-) delete mode 100644 hub/src/agent/openrouter-provider.ts create mode 100644 hub/test/unit/runner.test.ts diff --git a/hub/package-lock.json b/hub/package-lock.json index b796983..57de513 100644 --- a/hub/package-lock.json +++ b/hub/package-lock.json @@ -8,12 +8,14 @@ "name": "@paradigm/hub", "version": "0.0.0", "dependencies": { + "@ai-sdk/openai-compatible": "^3.0.5", "@fastify/cookie": "^11.0.2", "@larksuiteoapi/node-sdk": "^1.66.1", "@prisma/client": "^6.19.3", + "ai": "^7.0.16", "dotenv": "^17.4.2", "fastify": "^5.8.5", - "openai": "^4.77.0" + "zod": "^4.4.3" }, "devDependencies": { "prisma": "^6.19.3", @@ -25,6 +27,69 @@ "node": ">=20" } }, + "node_modules/@ai-sdk/gateway": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-4.0.12.tgz", + "integrity": "sha512-Y7Fy8xJwPz7ZC0DhSQG3HIVk+drup42hrIj6yqKlib3CxwiR0F7nYyUI8+kPrEtbZEoyKoRstvT4/o0HEyFBHA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.2", + "@ai-sdk/provider-utils": "5.0.5", + "@vercel/oidc": "3.2.0" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/openai-compatible": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-3.0.5.tgz", + "integrity": "sha512-4UtDxT6Ga7U225o5fBEgtCFZHba4/iTDXRqMrU62yEfTrFks4o+F4jt0D3OWmUasR9LPFzLy0KnEITxFDtc89g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.2", + "@ai-sdk/provider-utils": "5.0.5" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-4.0.2.tgz", + "integrity": "sha512-pfPoy9J1B1xV7cqJ8MYHOsDYrMv5tR3+EMNfI249OhkD2uRakvav3Fo7XpD2luuN/YNCBY7KfEQc7vEV7KEtyw==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-5.0.5.tgz", + "integrity": "sha512-oI0t3dvCoqWNV1I8o1Rybi2DXDvHES5r/TrwtJW90tuFLVepgJlftPxrcjh8vaSvjqC2diTuA2vXyjKAyHJm4A==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "4.0.2", + "@standard-schema/spec": "^1.1.0", + "@workflow/serde": "4.1.0", + "eventsource-parser": "^3.0.8" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" + } + }, "node_modules/@emnapi/core": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", @@ -1117,7 +1182,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, "license": "MIT" }, "node_modules/@tybys/wasm-util": { @@ -1165,14 +1229,13 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" + "node_modules/@vercel/oidc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz", + "integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20" } }, "node_modules/@vitest/expect": { @@ -1288,17 +1351,11 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } + "node_modules/@workflow/serde": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz", + "integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==", + "license": "Apache-2.0" }, "node_modules/abstract-logging": { "version": "2.0.1", @@ -1306,16 +1363,21 @@ "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", "license": "MIT" }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", + "node_modules/ai": { + "version": "7.0.16", + "resolved": "https://registry.npmjs.org/ai/-/ai-7.0.16.tgz", + "integrity": "sha512-OuA+uz6ZUVId+SVeB5bThi25Ofee/FmLvffAA368tlgqYCe2qAhqZUpfHL0dd9QC/iPiszdvCQYENJavSo/0gw==", + "license": "Apache-2.0", "dependencies": { - "humanize-ms": "^1.2.1" + "@ai-sdk/gateway": "4.0.12", + "@ai-sdk/provider": "4.0.2", + "@ai-sdk/provider-utils": "5.0.5" }, "engines": { - "node": ">= 8.0.0" + "node": ">=22" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4.1.8" } }, "node_modules/ajv": { @@ -1766,13 +1828,13 @@ "@types/estree": "^1.0.0" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", "license": "MIT", "engines": { - "node": ">=6" + "node": ">=18.0.0" } }, "node_modules/expect-type": { @@ -2002,25 +2064,6 @@ "node": ">= 6" } }, - "node_modules/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "license": "MIT" - }, - "node_modules/formdata-node": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", - "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "license": "MIT", - "dependencies": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.3" - }, - "engines": { - "node": ">= 12.20" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2151,15 +2194,6 @@ "node": ">= 0.4" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/ipaddr.js": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", @@ -2179,6 +2213,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-ref-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", @@ -2578,12 +2618,6 @@ "node": ">= 0.6" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/nanoid": { "version": "3.3.15", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", @@ -2603,46 +2637,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, "node_modules/node-fetch-native": { "version": "1.6.7", "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", @@ -2717,51 +2711,6 @@ "node": ">=14.0.0" } }, - "node_modules/openai": { - "version": "4.104.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz", - "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", - "license": "Apache-2.0", - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7" - }, - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.23.8" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/openai/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/openai/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -3343,12 +3292,6 @@ "node": ">=20" } }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3564,31 +3507,6 @@ } } }, - "node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -3626,6 +3544,15 @@ "optional": true } } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/hub/package.json b/hub/package.json index da6c5da..cf04eb2 100644 --- a/hub/package.json +++ b/hub/package.json @@ -7,12 +7,14 @@ "node": ">=20" }, "dependencies": { + "@ai-sdk/openai-compatible": "^3.0.5", "@fastify/cookie": "^11.0.2", "@larksuiteoapi/node-sdk": "^1.66.1", "@prisma/client": "^6.19.3", + "ai": "^7.0.16", "dotenv": "^17.4.2", "fastify": "^5.8.5", - "openai": "^4.77.0" + "zod": "^4.4.3" }, "devDependencies": { "prisma": "^6.19.3", diff --git a/hub/src/agent/cph.ts b/hub/src/agent/cph.ts index 12eeb08..79e12a1 100644 --- a/hub/src/agent/cph.ts +++ b/hub/src/agent/cph.ts @@ -1,10 +1,10 @@ /** - * `cph` subprocess tools — the Hub↔Courseware coupling point. + * `cph` subprocess tools - the Hub<->Courseware coupling point. * * The Hub agent does not parse engineering-file models in-process; it calls the * stable `cph` CLI (ADR-0016 version contract). `cph check` validates a * project; `cph build` renders a target artifact. Version compatibility is the - * CLI's responsibility — it refuses incompatible `.cph-version` files with a + * CLI's responsibility - it refuses incompatible `.cph-version` files with a * `cphVersionMismatch` error diagnostic (ADR-0016); the Hub merely runs the * subprocess and returns its stdout/stderr/exit to the model. * @@ -14,7 +14,9 @@ */ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import type { RegisteredTool } from "./tools.js"; +import { tool } from "ai"; +import { z } from "zod"; +import type { ToolContext, ToolDefinition } from "./tools.js"; const execFileAsync = promisify(execFile); @@ -53,30 +55,19 @@ async function runCph(args: readonly string[], workspaceDir: string): Promise` — validate a curriculum engineering file. Returns the +/// `cph check ` - validate a curriculum engineering file. Returns the /// checker's diagnostics (stdout) for the model to act on. Non-zero exit means /// the file has error diagnostics (ADR-0010); the output is still returned, -/// not thrown — the model needs to see the diagnostics to fix them. -export function cphCheckTool(): RegisteredTool { - return { - spec: { - name: "cph_check", - description: - "Run `cph check` on the project's curriculum engineering file. Returns diagnostics (7 classes: structure/schema/compile/version…). Non-zero exit means error diagnostics present — read the output to fix them.", - parameters: { - type: "object", - properties: { - path: { type: "string", description: "Path to the engineering file dir; defaults to the workspace root." }, - }, - }, - }, - async execute(args, ctx): Promise { - const a = (args as CphCheckArgs) ?? {}; - const target = a.path ?? "."; +/// not thrown - the model needs to see the diagnostics to fix them. +export function cphCheckTool(ctx: ToolContext): ToolDefinition { + return tool({ + description: + "Run `cph check` on the project's curriculum engineering file. Returns diagnostics (7 classes: structure/schema/compile/version...). Non-zero exit means error diagnostics present - read the output to fix them.", + inputSchema: z.object({ + path: z.string().describe("Path to the engineering file dir; defaults to the workspace root.").optional(), + }), + execute: async (args): Promise => { + const target = args.path ?? "."; const res = await runCph(["check", target], ctx.workspaceDir); return JSON.stringify({ exitCode: res.exitCode, @@ -84,39 +75,25 @@ export function cphCheckTool(): RegisteredTool { stderr: res.stderr, }); }, - }; + }); } -interface CphBuildArgs { - readonly target: string; - readonly path?: string; - readonly output?: string; -} - -/// `cph build --target -o ` — render a target artifact. The -/// target (student/teacher/…) determines the build (ADR-0009). Output path +/// `cph build --target -o ` - render a target artifact. The +/// target (student/teacher/...) determines the build (ADR-0009). Output path /// is relative to the workspace unless absolute. -export function cphBuildTool(): RegisteredTool { - return { - spec: { - name: "cph_build", - description: - "Run `cph build` to render a curriculum artifact (e.g. student/teacher PDF). Target selects the build; output names the result file.", - parameters: { - type: "object", - properties: { - target: { type: "string", description: "Build target, e.g. 'student', 'teacher'." }, - path: { type: "string", description: "Path to the engineering file dir; defaults to the workspace root." }, - output: { type: "string", description: "Output file path (relative to workspace or absolute)." }, - }, - required: ["target"], - }, - }, - async execute(args, ctx): Promise { - const a = args as CphBuildArgs; - const target = a.path ?? "."; - const out = a.output ?? `build/${a.target}.pdf`; - const res = await runCph(["build", target, "--target", a.target, "-o", out], ctx.workspaceDir); +export function cphBuildTool(ctx: ToolContext): ToolDefinition { + return tool({ + description: + "Run `cph build` to render a curriculum artifact (e.g. student/teacher PDF). Target selects the build; output names the result file.", + inputSchema: z.object({ + target: z.string().describe("Build target, e.g. 'student', 'teacher'."), + path: z.string().describe("Path to the engineering file dir; defaults to the workspace root.").optional(), + output: z.string().describe("Output file path (relative to workspace or absolute).").optional(), + }), + execute: async (args): Promise => { + const target = args.path ?? "."; + const out = args.output ?? `build/${args.target}.pdf`; + const res = await runCph(["build", target, "--target", args.target, "-o", out], ctx.workspaceDir); return JSON.stringify({ exitCode: res.exitCode, stdout: res.stdout, @@ -124,5 +101,5 @@ export function cphBuildTool(): RegisteredTool { output: out, }); }, - }; + }); } diff --git a/hub/src/agent/openrouter-provider.ts b/hub/src/agent/openrouter-provider.ts deleted file mode 100644 index 911bcab..0000000 --- a/hub/src/agent/openrouter-provider.ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * 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; -} - -export class OpenRouterProvider implements AgentProvider { - readonly id: string; - private readonly client: OpenAI; - - constructor(opts: OpenRouterOptions) { - this.id = opts.id ?? "openrouter"; - const params: ConstructorParameters[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 { - 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 = { - 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 { - return p.type === "text"; -} -function isToolCall(p: MessagePart): p is Extract { - return p.type === "tool_call"; -} -function isToolResult(p: MessagePart): p is Extract { - return p.type === "tool_result"; -} diff --git a/hub/src/agent/provider.ts b/hub/src/agent/provider.ts index 9032509..a7e15ac 100644 --- a/hub/src/agent/provider.ts +++ b/hub/src/agent/provider.ts @@ -1,88 +1,18 @@ /** - * Provider-agnostic agent seam. + * OpenRouter model factory. * - * 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. + * ADR-0017: the agent layer keeps role/model resolution separate from provider + * construction. A run carries a resolved model id, and switching model still + * means a new provider-bound session. */ +import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; +import type { LanguageModel } from "ai"; -/** 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; -} - -/** 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"); +export function createModelFactory(): (modelId: string) => LanguageModel { + const provider = createOpenAICompatible({ + name: "openrouter", + baseURL: process.env.OPENROUTER_BASE_URL ?? "https://openrouter.ai/api/v1", + headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY ?? ""}` }, + }); + return (modelId: string) => provider(modelId); } diff --git a/hub/src/agent/runner.ts b/hub/src/agent/runner.ts index d56277e..6c5a723 100644 --- a/hub/src/agent/runner.ts +++ b/hub/src/agent/runner.ts @@ -1,22 +1,22 @@ /** - * Provider-agnostic agent loop. + * Provider-bound agent runner. * - * 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. + * The AI SDK owns tool-call dispatch and message folding. The Hub owns the + * per-run tool context, role/model selection, transcript persistence, and the + * ADR-0017 session boundary: 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 runner. */ -import type { AgentProvider, ChatResponse, Message, ToolCallPart } from "./provider.js"; +import { generateText, stepCountIs, type LanguageModel, type ModelMessage } from "ai"; import type { ToolContext, ToolRegistry } from "./tools.js"; import { readTranscript, appendTranscript } from "./transcript.js"; +export type ModelFactory = (modelId: string) => LanguageModel; + /** 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. */ + /** ADR-0001 binding - the chat this project is bound to. */ readonly boundChatId: string; /** Absolute path to the curriculum engineering-file workspace. */ readonly workspaceDir: string; @@ -30,6 +30,8 @@ export interface RunRequest { readonly systemPrompt: string | undefined; /** Iteration cap before the run is returned as `length`. Default 25. */ readonly maxIterations?: number; + /** Per-role tool whitelist (ADR-0017). Undefined means all registered tools. */ + readonly toolWhitelist?: readonly string[] | undefined; /** Path to the session transcript JSONL. If set, prior messages are loaded * from it before the run, and new messages are appended after. */ readonly transcriptPath?: string; @@ -40,20 +42,15 @@ 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 messages: readonly ModelMessage[]; 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, + modelFactory: ModelFactory, tools: ToolRegistry, req: RunRequest, ): Promise { @@ -63,108 +60,81 @@ export async function runAgent( boundChatId: req.project.boundChatId, workspaceDir: req.project.workspaceDir, }; - const messages: Message[] = []; + const aiTools = tools.build(ctx, req.toolWhitelist); + // Load prior session messages from transcript (continuity across @bot calls - // in the same session). ADR-0003: session messages ≠ group chat history. - let loadedCount = 0; - if (req.transcriptPath !== undefined) { - const prior = await readTranscript(req.transcriptPath); - loadedCount = prior.length; - messages.push(...prior); - } + // in the same session). ADR-0003: session messages != group chat history. + let messages: ModelMessage[] = req.transcriptPath !== undefined ? await readTranscript(req.transcriptPath) : []; + const loadedCount = messages.length; if (req.systemPrompt !== undefined && messages.length === 0) { // System prompt only at the very start of a session; on resume it's // already in the transcript. - messages.push({ role: "system", parts: [{ type: "text", text: req.systemPrompt }] }); + messages = [{ role: "system", content: req.systemPrompt }]; } - messages.push({ role: "user", parts: [{ type: "text", text: req.prompt }] }); + messages = [...messages, { role: "user", content: 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) { - const result: RunResult = { - status: "failed", - messages, - usage: { inputTokens, outputTokens }, - error: e instanceof Error ? e.message : String(e), - }; - await appendTranscriptIfSet(req, messages, loadedCount); - return result; - } + try { + const result = await generateText({ + model: modelFactory(req.model), + messages, + allowSystemInMessages: true, + tools: aiTools, + stopWhen: stepCountIs(cap), + }); + const allMessages: ModelMessage[] = [...messages, ...result.responseMessages]; + await appendTranscriptIfSet(req, allMessages, loadedCount); - messages.push(res.message); - if (res.usage !== undefined) { - inputTokens += res.usage.inputTokens; - outputTokens += res.usage.outputTokens; - } + const stoppedByStepCap = result.finishReason === "tool-calls" && result.steps.length >= cap; + const status: RunStatus = + result.finishReason === "stop" + ? "completed" + : result.finishReason === "length" || stoppedByStepCap + ? "length" + : "failed"; + const error = + status === "failed" + ? `finishReason=${result.finishReason}` + : stoppedByStepCap + ? `exceeded maxIterations=${cap}` + : undefined; - if (res.finishReason === "stop") { - await appendTranscriptIfSet(req, messages, loadedCount); - return { status: "completed", messages, usage: { inputTokens, outputTokens } }; - } - if (res.finishReason !== "tool_calls") { - await appendTranscriptIfSet(req, messages, loadedCount); - 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, + messages: allMessages, + usage: { + inputTokens: result.usage.inputTokens ?? 0, + outputTokens: result.usage.outputTokens ?? 0, + }, + ...(error !== undefined ? { error } : {}), + }; + } catch (e) { + await appendTranscriptIfSet(req, messages, loadedCount); + return { + status: "failed", + messages, + usage: { inputTokens: 0, outputTokens: 0 }, + error: e instanceof Error ? e.message : String(e), + }; } - - await appendTranscriptIfSet(req, messages, loadedCount); - return { - status: "length", - messages, - usage: { inputTokens, outputTokens }, - error: `exceeded maxIterations=${cap}`, - }; } /// Append only the messages produced this run (skip the `loadedCount` prior -/// ones already in the file). Errors are swallowed — a transcript write +/// ones already in the file). Errors are swallowed - a transcript write /// failure must not lose the run result. -async function appendTranscriptIfSet(req: RunRequest, messages: readonly Message[], loadedCount: number): Promise { +async function appendTranscriptIfSet(req: RunRequest, messages: readonly ModelMessage[], loadedCount: number): Promise { if (req.transcriptPath === undefined) return; const newMessages = messages.slice(loadedCount); if (newMessages.length === 0) return; try { await appendTranscript(req.transcriptPath, newMessages); } catch { - // Swallow — transcript is best-effort persistence; the run result is + // Swallow - transcript is best-effort persistence; the run result is // returned regardless. ADR-0002 lock ensures no concurrent writer. } } -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(); } diff --git a/hub/src/agent/tools.ts b/hub/src/agent/tools.ts index f4bf4c9..f1377af 100644 --- a/hub/src/agent/tools.ts +++ b/hub/src/agent/tools.ts @@ -1,5 +1,5 @@ /** - * Tool registry — the agent's narrow, project-scoped tool surface. + * 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 @@ -10,13 +10,13 @@ * 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. + * The AI SDK owns tool-call dispatch; this registry owns Hub-specific tool + * construction and per-role whitelisting (ADR-0017). */ -import type { ToolSpec } from "./provider.js"; +import { tool, type Tool } from "ai"; +import { z } from "zod"; -/** Per-run execution context handed to every tool handler. */ +/** Per-run execution context closed over by every tool execute function. */ export interface ToolContext { readonly runId: string; readonly projectId: string; @@ -26,64 +26,34 @@ export interface ToolContext { readonly workspaceDir: string; } -/** A tool handler: receives parsed args + run context, returns content for the model. */ -export type ToolHandler = (args: unknown, ctx: ToolContext) => Promise; - -export interface RegisteredTool { - readonly spec: ToolSpec; - readonly execute: ToolHandler; -} +export type ToolDefinition = Tool; +export type ToolFactory = (ctx: ToolContext) => ToolDefinition; export class ToolRegistry { - private readonly byName = new Map(); + private readonly factories = new Map(); - register(tool: RegisteredTool): void { - if (this.byName.has(tool.spec.name)) { - throw new Error(`duplicate tool: ${tool.spec.name}`); + register(name: string, factory: ToolFactory): void { + if (this.factories.has(name)) { + throw new Error(`duplicate tool: ${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 { - const tool = this.byName.get(name); - if (tool === undefined) { - return JSON.stringify({ error: `unknown tool: ${name}` }); - } - return tool.execute(args, ctx); + this.factories.set(name, factory); } /** - * Return a per-run view restricted to `names`. Names not registered are - * silently dropped (a role's whitelist may name tools that a particular Hub - * instance doesn't register). The returned registry shares the handler - * references — no copy of the closures. - * - * Per-role tool whitelisting (ADR-0017: role-based routing is product - * config). The runner receives a subset registry so the model literally - * never sees tools outside its role's whitelist: the tool list sent to the - * provider is `subset.specs()`, and `execute` on a name not in the subset - * returns an error. + * Build the AI SDK `tools` record. If `names` is given, include only those + * tools (per-role whitelist, ADR-0017). Names not registered are silently + * dropped because role config may name tools a Hub instance does not provide. */ - subset(names: readonly string[]): ToolRegistry { - const allowed = new Set(names); - const view = new ToolRegistry(); - for (const [name, tool] of this.byName) { - if (allowed.has(name)) { - // Bypass the duplicate check: we're constructing from an already-valid - // registry, not re-registering. - view.byName.set(name, tool); + build(ctx: ToolContext, names: readonly string[] | undefined): Record { + const built: Record = {}; + const selected = names ?? [...this.factories.keys()]; + for (const name of selected) { + const factory = this.factories.get(name); + if (factory !== undefined) { + built[name] = factory(ctx); } } - return view; + return built; } } @@ -112,8 +82,9 @@ export interface FeishuContextArgs { } /** - * 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`. + * Build the Feishu context-read tool factory. The execute closure 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 @@ -123,32 +94,21 @@ export interface FeishuContextArgs { export function feishuContextTool( read: (args: FeishuContextArgs, ctx: ToolContext, rt: unknown) => Promise, rt: unknown, -): RegisteredTool { - return { - spec: { - name: "feishu_read_context", +): ToolFactory { + return (ctx) => + tool({ 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"], + inputSchema: z.object({ + chat_id: z.string().describe("The Feishu chat id to read from."), + anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of anchor to read."), + id: z.string().describe("The anchor id (message id or run id)."), + }), + execute: async (args): Promise => { + if (args.chat_id !== ctx.boundChatId) { + throw new UnauthorizedChatRead(args.chat_id, ctx.boundChatId); + } + return read(args, ctx, rt); }, - }, - async execute(args, ctx): Promise { - const a = args as FeishuContextArgs; - if (a.chat_id !== ctx.boundChatId) { - throw new UnauthorizedChatRead(a.chat_id, ctx.boundChatId); - } - return read(a, ctx, rt); - }, - }; + }); } diff --git a/hub/src/agent/transcript.ts b/hub/src/agent/transcript.ts index ef7b646..f828de5 100644 --- a/hub/src/agent/transcript.ts +++ b/hub/src/agent/transcript.ts @@ -1,7 +1,7 @@ /** * Session transcript — append-only JSONL file in the workspace. * - * Each line is one {@link Message}, JSON-encoded. The transcript lives at + * Each line is one AI SDK {@link ModelMessage}, JSON-encoded. The transcript lives at * `workspaceDir/.cph/sessions/.jsonl`, following the same "workspace * is a directory tree" principle as the engineering file itself (ADR-0007). * @@ -15,9 +15,9 @@ * distinct; storing session messages does not conflict with "the Hub avoids * becoming a full chat history store." */ -import { readFile, writeFile, mkdir, appendFile } from "node:fs/promises"; +import { readFile, mkdir, appendFile } from "node:fs/promises"; import { join, dirname } from "node:path"; -import type { Message } from "./provider.js"; +import type { ModelMessage } from "ai"; /// Subdirectory within a workspace where session transcripts live. export const TRANSCRIPT_DIR = ".cph/sessions"; @@ -33,19 +33,19 @@ export function transcriptPath(workspaceDir: string, sessionId: string): string * skipped — a partially-written last line (crash mid-append) won't break the * next run. */ -export async function readTranscript(path: string): Promise { +export async function readTranscript(path: string): Promise { let content: string; try { content = await readFile(path, "utf8"); } catch { return []; } - const messages: Message[] = []; + const messages: ModelMessage[] = []; for (const line of content.split("\n")) { const trimmed = line.trim(); if (trimmed === "") continue; try { - messages.push(JSON.parse(trimmed) as Message); + messages.push(JSON.parse(trimmed) as ModelMessage); } catch { // Skip corrupt line — likely a partial write from a crash. } @@ -57,7 +57,7 @@ export async function readTranscript(path: string): Promise { * Append messages to a transcript file. Creates the parent directory if needed. * Each message is written as one JSON line. */ -export async function appendTranscript(path: string, messages: readonly Message[]): Promise { +export async function appendTranscript(path: string, messages: readonly ModelMessage[]): Promise { await mkdir(dirname(path), { recursive: true }); const lines = messages.map((m) => JSON.stringify(m)).join("\n") + "\n"; await appendFile(path, lines, "utf8"); diff --git a/hub/src/agent/workspace.ts b/hub/src/agent/workspace.ts index f2bcf1f..d63d213 100644 --- a/hub/src/agent/workspace.ts +++ b/hub/src/agent/workspace.ts @@ -1,16 +1,17 @@ /** - * Workspace file tools — bounded read/write/list against the project's + * Workspace file tools - bounded read/write/list against the project's * curriculum engineering-file tree (ADR-0007 directory tree). * * All paths are **confined to `ctx.workspaceDir`**: the handler resolves the * requested relative path against the workspace root and rejects any path that * escapes it (via `..` or absolute paths). This is the agent's only file - * surface — there is no general bash escape hatch, by design (the Hub agent is - * narrower than a general "Claude Code"). + * surface; the Hub agent is narrower than a general coding agent. */ import { readFile, writeFile, readdir, mkdir } from "node:fs/promises"; import { join, resolve, relative, isAbsolute } from "node:path"; -import type { RegisteredTool } from "./tools.js"; +import { tool } from "ai"; +import { z } from "zod"; +import type { ToolContext, ToolDefinition } from "./tools.js"; /** Thrown when a requested path escapes the project workspace root. */ export class PathEscape extends Error { @@ -38,68 +39,42 @@ function confine(requestedPath: string, workspaceDir: string): string { return resolved; } -interface ReadFileArgs { - readonly path: string; -} - -export function readFileTool(): RegisteredTool { - return { - spec: { - name: "read_file", - description: "Read a file from the project's curriculum engineering-file workspace. Path is relative to the workspace root.", - parameters: { - type: "object", - properties: { path: { type: "string", description: "Relative path within the workspace." } }, - required: ["path"], - }, - }, - async execute(args, ctx): Promise { - const a = args as ReadFileArgs; +export function readFileTool(ctx: ToolContext): ToolDefinition { + return tool({ + description: "Read a file from the project's curriculum engineering-file workspace. Path is relative to the workspace root.", + inputSchema: z.object({ + path: z.string().describe("Relative path within the workspace."), + }), + execute: async (args): Promise => { try { - const full = confine(a.path, ctx.workspaceDir); + const full = confine(args.path, ctx.workspaceDir); return await readFile(full, "utf8"); } catch (e) { return JSON.stringify({ error: e instanceof Error ? e.message : String(e) }); } }, - }; + }); } -interface WriteFileArgs { - readonly path: string; - readonly content: string; -} - -export function writeFileTool(): RegisteredTool { - return { - spec: { - name: "write_file", - description: "Write a file in the project's curriculum engineering-file workspace. Path is relative to the workspace root. Creates parent directories.", - parameters: { - type: "object", - properties: { - path: { type: "string", description: "Relative path within the workspace." }, - content: { type: "string", description: "The full file content to write." }, - }, - required: ["path", "content"], - }, - }, - async execute(args, ctx): Promise { - const a = args as WriteFileArgs; +export function writeFileTool(ctx: ToolContext): ToolDefinition { + return tool({ + description: + "Write a file in the project's curriculum engineering-file workspace. Path is relative to the workspace root. Creates parent directories.", + inputSchema: z.object({ + path: z.string().describe("Relative path within the workspace."), + content: z.string().describe("The full file content to write."), + }), + execute: async (args): Promise => { try { - const full = confine(a.path, ctx.workspaceDir); + const full = confine(args.path, ctx.workspaceDir); await mkdir(join(full, ".."), { recursive: true }); - await writeFile(full, a.content, "utf8"); - return JSON.stringify({ ok: true, path: a.path, bytes: a.content.length }); + await writeFile(full, args.content, "utf8"); + return JSON.stringify({ ok: true, path: args.path, bytes: args.content.length }); } catch (e) { return JSON.stringify({ error: e instanceof Error ? e.message : String(e) }); } }, - }; -} - -interface ListFilesArgs { - readonly path?: string; + }); } interface Entry { @@ -107,19 +82,14 @@ interface Entry { readonly kind: "file" | "dir"; } -export function listFilesTool(): RegisteredTool { - return { - spec: { - name: "list_files", - description: "List files and directories at a path within the workspace. Defaults to the workspace root.", - parameters: { - type: "object", - properties: { path: { type: "string", description: "Relative path within the workspace; defaults to root." } }, - }, - }, - async execute(args, ctx): Promise { - const a = (args as ListFilesArgs) ?? {}; - const sub = a.path ?? "."; +export function listFilesTool(ctx: ToolContext): ToolDefinition { + return tool({ + description: "List files and directories at a path within the workspace. Defaults to the workspace root.", + inputSchema: z.object({ + path: z.string().describe("Relative path within the workspace; defaults to root.").optional(), + }), + execute: async (args): Promise => { + const sub = args.path ?? "."; try { const full = confine(sub, ctx.workspaceDir); const entries = await readdir(full, { withFileTypes: true }); @@ -132,5 +102,5 @@ export function listFilesTool(): RegisteredTool { return JSON.stringify({ error: e instanceof Error ? e.message : String(e) }); } }, - }; + }); } diff --git a/hub/src/feishu/trigger.ts b/hub/src/feishu/trigger.ts index 6270b84..019ec29 100644 --- a/hub/src/feishu/trigger.ts +++ b/hub/src/feishu/trigger.ts @@ -10,23 +10,22 @@ * ADR-0003-authorized tool (chat must match binding). * 6. On finish/fail/timeout: release the lock, post a status card. * - * The agent provider/model come from the ModelRegistry (ADR-0017 role-based + * The agent model id comes from the ModelRegistry (ADR-0017 role-based * routing, role-as-data). The trigger does not pick a model directly. */ import type { PrismaClient } from "@prisma/client"; import type { FastifyBaseLogger } from "fastify"; import { sendText, sendCard, buildRunStatusCard, type FeishuRuntime, type MessageReceiveEvent } from "./client.js"; -import type { AgentProvider } from "../agent/provider.js"; import type { ToolRegistry } from "../agent/tools.js"; import type { ModelRegistry } from "../agent/models.js"; -import { runAgent, type ProjectContext } from "../agent/runner.js"; +import { runAgent, type ModelFactory, type ProjectContext } from "../agent/runner.js"; import { acquireLock, currentLockRunId, releaseLock } from "../lock.js"; import { canTriggerAgent, canTriggerRole } from "../permission.js"; import { transcriptPath } from "../agent/transcript.js"; interface TriggerDeps { readonly prisma: PrismaClient; - readonly provider: AgentProvider; + readonly modelFactory: ModelFactory; readonly tools: ToolRegistry; readonly models: ModelRegistry; readonly logger: FastifyBaseLogger; @@ -150,13 +149,11 @@ export function makeTriggerHandler(deps: TriggerDeps) { } const role = deps.models.role(roleId); const model = deps.models.resolve(undefined, roleId); - const providerId = deps.provider.id; + const providerId = "openrouter"; // Per-role bundle (ADR-0017): systemPrompt seeds persona; tools whitelist - // is enforced by constructing a per-run registry subset. `tools: undefined` - // means the full registered set (unrestricted role). + // is enforced inside runAgent when it builds the SDK tools record. `tools: + // undefined` means the full registered set (unrestricted role). const systemPrompt = role?.systemPrompt; - const runTools = - role?.tools !== undefined ? deps.tools.subset(role.tools) : deps.tools; // ADR-0017: provider+model-bound session. Reuse if one exists for this // (project, provider, model) triple; otherwise create. @@ -220,11 +217,12 @@ export function makeTriggerHandler(deps: TriggerDeps) { // Run the agent — fire-and-forget from the ws handler's perspective. // Per-run tools + systemPrompt come from the role bundle (ADR-0017). - runAgent(deps.provider, runTools, { + runAgent(deps.modelFactory, deps.tools, { prompt: cleanPrompt, model, project: projectCtx, systemPrompt, + toolWhitelist: role?.tools, transcriptPath: transcriptPath(project.workspaceDir, session.id), }) .then(async (result) => { diff --git a/hub/src/server.ts b/hub/src/server.ts index 25755e5..dd3bc12 100644 --- a/hub/src/server.ts +++ b/hub/src/server.ts @@ -2,8 +2,8 @@ * Hub entry — Fastify server + Feishu WebSocket listener. * * Wires the full trigger path: lark ws receives `im.message.receive_v1` → - * trigger handler resolves chat→project (ADR-0001), creates AgentRun + acquires - * the project lock (ADR-0002), runs the provider-agnostic agent loop + * trigger handler resolves chat->project (ADR-0001), creates AgentRun + acquires + * the project lock (ADR-0002), runs the AI SDK-backed agent runner * (ADR-0017), and releases the lock on finish. Feishu context reads inside the * loop are ADR-0003-authorized (chat must match binding). * @@ -13,7 +13,7 @@ import "dotenv/config"; import Fastify from "fastify"; import { prisma } from "./db.js"; -import { OpenRouterProvider } from "./agent/openrouter-provider.js"; +import { createModelFactory } from "./agent/provider.js"; import { InMemoryModelRegistry } from "./agent/models.js"; import { ToolRegistry, feishuContextTool } from "./agent/tools.js"; import { readFileTool, writeFileTool, listFilesTool } from "./agent/workspace.js"; @@ -34,7 +34,7 @@ function requireEnv(name: string): string { async function main(): Promise { const databaseUrl = requireEnv("DATABASE_URL"); void databaseUrl; // prisma reads DATABASE_URL from env directly - const openrouterKey = requireEnv("OPENROUTER_API_KEY"); + requireEnv("OPENROUTER_API_KEY"); const feishuAppId = requireEnv("FEISHU_APP_ID"); const feishuAppSecret = requireEnv("FEISHU_APP_SECRET"); const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID"); @@ -45,7 +45,7 @@ async function main(): Promise { app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() })); // --- Agent seam wiring --- - const provider = new OpenRouterProvider({ apiKey: openrouterKey }); + const modelFactory = createModelFactory(); const models = new InMemoryModelRegistry( [ { id: "z-ai/glm-4.6", label: "GLM-4.6", toolCapable: true }, @@ -79,17 +79,17 @@ async function main(): Promise { const tools = new ToolRegistry(); // ADR-0003: the handler enforces chat==boundChat before any API call; // real read wraps @larksuiteoapi/node-sdk's im.v1.message get/list. - tools.register(feishuContextTool(readFeishuContext, feishuRt)); + tools.register("feishu_read_context", feishuContextTool(readFeishuContext, feishuRt)); // Workspace file tools (ADR-0007 directory tree, path-confined). - tools.register(readFileTool()); - tools.register(writeFileTool()); - tools.register(listFilesTool()); + tools.register("read_file", readFileTool); + tools.register("write_file", writeFileTool); + tools.register("list_files", listFilesTool); // cph CLI tools (ADR-0016 version contract enforced by cph itself). - tools.register(cphCheckTool()); - tools.register(cphBuildTool()); + tools.register("cph_check", cphCheckTool); + tools.register("cph_build", cphBuildTool); // --- Feishu listener (reuses the same lark client) --- - const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: app.log }); + const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: app.log }); startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger); app.listen({ port, host: "0.0.0.0" }, (err, address) => { diff --git a/hub/test/integration/cph.test.ts b/hub/test/integration/cph.test.ts index 5bb7a38..9a9cd26 100644 --- a/hub/test/integration/cph.test.ts +++ b/hub/test/integration/cph.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { cp, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { ToolRegistry } from "../../src/agent/tools.js"; +import { ToolRegistry, type ToolContext } from "../../src/agent/tools.js"; import { cphCheckTool, cphBuildTool } from "../../src/agent/cph.js"; const EXAMPLES_DIR = join(process.cwd(), "..", "examples", "TH-141"); @@ -12,16 +12,21 @@ describe("cph subprocess tools (integration, real cph binary)", () => { let tools: ToolRegistry; beforeEach(async () => { - // Use the real TH-141 example as the workspace. - ws = EXAMPLES_DIR; + // Use a writable copy of the real TH-141 example as the workspace. + ws = await mkdtemp(join(tmpdir(), "hub-cph-example-")); + await cp(EXAMPLES_DIR, ws, { recursive: true }); tools = new ToolRegistry(); - tools.register(cphCheckTool()); - tools.register(cphBuildTool()); + tools.register("cph_check", cphCheckTool); + tools.register("cph_build", cphBuildTool); + }); + + afterEach(async () => { + await rm(ws, { recursive: true, force: true }); }); it("cph_check runs on the TH-141 example and returns diagnostics", async () => { const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws }; - const out = await tools.execute("cph_check", {}, ctx); + const out = await executeTool(tools, "cph_check", {}, ctx); const result = JSON.parse(out) as { exitCode: number; stdout: string; stderr: string }; // cph check exits 0 on a legal lesson (no error diagnostics, ADR-0010). @@ -31,7 +36,7 @@ describe("cph subprocess tools (integration, real cph binary)", () => { it("cph_build renders the student target PDF", async () => { const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws }; - const out = await tools.execute("cph_build", { target: "student", output: "build/test-student.pdf" }, ctx); + const out = await executeTool(tools, "cph_build", { target: "student", output: "build/test-student.pdf" }, ctx); const result = JSON.parse(out) as { exitCode: number; stdout: string; stderr: string; output: string }; expect(result.exitCode).toBe(0); @@ -41,7 +46,7 @@ describe("cph subprocess tools (integration, real cph binary)", () => { const empty = await mkdtemp(join(tmpdir(), "hub-cph-empty-")); try { const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: empty }; - const out = await tools.execute("cph_check", {}, ctx); + const out = await executeTool(tools, "cph_check", {}, ctx); const result = JSON.parse(out) as { exitCode: number }; expect(result.exitCode).not.toBe(0); } finally { @@ -49,3 +54,11 @@ describe("cph subprocess tools (integration, real cph binary)", () => { } }, 15000); }); + +async function executeTool(tools: ToolRegistry, name: string, args: unknown, ctx: ToolContext): Promise { + const def = tools.build(ctx)[name]; + if (def?.execute === undefined) { + throw new Error(`missing executable tool: ${name}`); + } + return def.execute(args, { toolCallId: "call", messages: [], context: undefined }) as Promise; +} diff --git a/hub/test/integration/helpers.ts b/hub/test/integration/helpers.ts index cd94378..b2dbbc7 100644 --- a/hub/test/integration/helpers.ts +++ b/hub/test/integration/helpers.ts @@ -3,12 +3,19 @@ * * Each test gets a clean DB (tables truncated before the test), a mock * FeishuRuntime (sendText/sendCard are no-ops that record calls), and a mock - * AgentProvider (chat() returns a fixed "stop" response — no real network). + * AI SDK model factory (doGenerate() returns canned responses - no network). */ import { PrismaClient } from "@prisma/client"; import type { FastifyBaseLogger } from "fastify"; -import type { FeishuRuntime } from "../src/feishu/client.js"; -import type { AgentProvider, ChatRequest, ChatResponse, Message } from "../src/agent/provider.js"; +import type { + LanguageModelV4, + LanguageModelV4CallOptions, + LanguageModelV4Content, + LanguageModelV4GenerateResult, + LanguageModelV4StreamResult, +} from "@ai-sdk/provider"; +import type { FeishuRuntime } from "../../src/feishu/client.js"; +import type { ModelFactory } from "../../src/agent/runner.js"; export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test"; @@ -84,19 +91,82 @@ export function mockFeishuRuntime(): MockFeishuRuntime { return rt; } -/** A provider that always returns a fixed "stop" response — no network. */ -export class MockProvider implements AgentProvider { - readonly id = "mock"; - readonly calls: ChatRequest[] = []; +export interface MockToolCall { + readonly toolCallId: string; + readonly toolName: string; + readonly input: unknown; +} - async chat(req: ChatRequest): Promise { - this.calls.push(req); - const message: Message = { - role: "assistant", - parts: [{ type: "text", text: "mock response" }], +export interface MockModelResponse { + readonly text?: string; + readonly toolCalls?: readonly MockToolCall[]; + readonly finishReason?: "stop" | "length" | "content-filter" | "tool-calls" | "error" | "other"; + readonly inputTokens?: number; + readonly outputTokens?: number; +} + +export class MockLanguageModel implements LanguageModelV4 { + readonly specificationVersion = "v4"; + readonly provider = "mock"; + readonly supportedUrls: Record = {}; + readonly calls: LanguageModelV4CallOptions[] = []; + + constructor( + readonly modelId: string, + private readonly responses: readonly MockModelResponse[] = [{ text: "mock response" }], + ) {} + + async doGenerate(options: LanguageModelV4CallOptions): Promise { + this.calls.push(options); + const configured = this.responses[this.calls.length - 1]; + const last = this.responses[this.responses.length - 1]; + const response = + configured ?? + ((last?.toolCalls?.length ?? 0) > 0 + ? { text: "mock response" } + : last ?? { text: "mock response" }); + const content: LanguageModelV4Content[] = []; + if (response.text !== undefined) { + content.push({ type: "text", text: response.text }); + } + for (const call of response.toolCalls ?? []) { + content.push({ + type: "tool-call", + toolCallId: call.toolCallId, + toolName: call.toolName, + input: JSON.stringify(call.input), + }); + } + const finishReason = response.finishReason ?? ((response.toolCalls?.length ?? 0) > 0 ? "tool-calls" : "stop"); + return { + content, + finishReason: { unified: finishReason, raw: finishReason }, + usage: { + inputTokens: { total: response.inputTokens ?? 10, noCache: undefined, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: response.outputTokens ?? 5, text: undefined, reasoning: undefined }, + }, + response: { id: `mock-${this.calls.length}`, timestamp: new Date(), modelId: this.modelId }, + warnings: [], }; - return { finishReason: "stop", message, usage: { inputTokens: 10, outputTokens: 5 } }; } + + async doStream(_options: LanguageModelV4CallOptions): Promise { + throw new Error("MockLanguageModel does not implement streaming"); + } +} + +export function createMockModelFactory( + responses: readonly MockModelResponse[] = [{ text: "mock response" }], +): { readonly modelFactory: ModelFactory; readonly models: ReadonlyMap } { + const models = new Map(); + return { + models, + modelFactory: (modelId) => { + const model = new MockLanguageModel(modelId, responses); + models.set(modelId, model); + return model; + }, + }; } /** Create a project + binding + user + grant for a test. */ diff --git a/hub/test/integration/trigger.test.ts b/hub/test/integration/trigger.test.ts index 155ddbb..c6f30b4 100644 --- a/hub/test/integration/trigger.test.ts +++ b/hub/test/integration/trigger.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, beforeEach, afterAll, vi } from "vitest"; -import { prisma, resetDb, mockFeishuRuntime, MockProvider, seedProject, silentLogger } from "./helpers.js"; +import { prisma, resetDb, mockFeishuRuntime, createMockModelFactory, seedProject, silentLogger } from "./helpers.js"; import { InMemoryModelRegistry } from "../../src/agent/models.js"; -import { ToolRegistry, feishuContextTool } from "../../src/agent/tools.js"; +import { ToolRegistry } from "../../src/agent/tools.js"; import { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js"; import type { MessageReceiveEvent } from "../../src/feishu/client.js"; +import type { ModelFactory } from "../../src/agent/runner.js"; const bot = { key: "@_user_1", id: { open_id: "ou_bot" }, name: "Bot" }; @@ -22,14 +23,14 @@ function makeEvent(chatId: string, text: string, senderOpenId = "ou_test_user"): } describe("trigger full lifecycle (integration)", () => { - let provider: MockProvider; + let modelFactory: ModelFactory; let tools: ToolRegistry; let models: InMemoryModelRegistry; let rt: ReturnType; beforeEach(async () => { await resetDb(); - provider = new MockProvider(); + modelFactory = createMockModelFactory().modelFactory; tools = new ToolRegistry(); models = new InMemoryModelRegistry( [{ id: "mock-model", label: "Mock", toolCapable: true }], @@ -43,7 +44,7 @@ describe("trigger full lifecycle (integration)", () => { it("creates a run, acquires + releases the lock, sends status card", async () => { await seedProject("proj-1", "chat-1"); - const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger }); + const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger }); await trigger(makeEvent("chat-1", "@_user_1 写教案"), rt); @@ -65,7 +66,7 @@ describe("trigger full lifecycle (integration)", () => { it("rejects a sender without edit grant (ADR-0004)", async () => { await seedProject("proj-2", "chat-2", { role: "READ" }); - const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger }); + const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger }); await trigger(makeEvent("chat-2", "@_user_1 写教案"), rt); @@ -84,7 +85,7 @@ describe("trigger full lifecycle (integration)", () => { data: { projectId: "proj-3", runId: existingRun.id }, }); - const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger }); + const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger }); await trigger(makeEvent("chat-3", "@_user_1 写教案"), rt); expect(rt.sentTexts).toContain("项目正在处理中,请稍候。"); @@ -95,7 +96,7 @@ describe("trigger full lifecycle (integration)", () => { it("ignores messages from unbound chats (ADR-0001)", async () => { await seedProject("proj-4", "chat-4"); - const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger }); + const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger }); await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案"), rt); @@ -107,7 +108,7 @@ describe("trigger full lifecycle (integration)", () => { it("ignores messages without @bot mention", async () => { await seedProject("proj-5", "chat-5"); - const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger }); + const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger }); const event: MessageReceiveEvent = { message: { @@ -129,7 +130,7 @@ describe("trigger full lifecycle (integration)", () => { it("/new archives current session (no run created)", async () => { await seedProject("proj-6", "chat-6"); - const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger }); + const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger }); // First @bot creates a session + run. await trigger(makeEvent("chat-6", "@_user_1 写教案"), rt); @@ -152,7 +153,7 @@ describe("trigger full lifecycle (integration)", () => { it("/resume un-archives the most recent session", async () => { await seedProject("proj-7", "chat-7"); - const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger }); + const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger }); // Create + archive a session via /new. await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt); @@ -172,7 +173,7 @@ describe("trigger full lifecycle (integration)", () => { it("/reset archives current session", async () => { await seedProject("proj-8", "chat-8"); - const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger }); + const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger }); await trigger(makeEvent("chat-8", "@_user_1 写教案"), rt); await vi.waitFor(async () => { @@ -191,7 +192,7 @@ describe("trigger full lifecycle (integration)", () => { it("unknown slash command falls through to agent", async () => { await seedProject("proj-9", "chat-9"); - const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger }); + const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger }); await trigger(makeEvent("chat-9", "@_user_1 /unknown"), rt); // Should create a run (falls through as a normal prompt). @@ -208,7 +209,7 @@ describe("trigger full lifecycle (integration)", () => { await prisma.roleTriggerGrant.create({ data: { projectId: "proj-10", roleId: "review", principal: "ou_other" }, }); - const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger }); + const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger }); await trigger(makeEvent("chat-10", "@_user_1 /review 看看这节"), rt); @@ -222,7 +223,7 @@ describe("trigger full lifecycle (integration)", () => { await prisma.roleTriggerGrant.create({ data: { projectId: "proj-11", roleId: "review", principal: "ou_test_user" }, }); - const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger }); + const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger }); await trigger(makeEvent("chat-11", "@_user_1 /review 看看这节"), rt); @@ -236,7 +237,7 @@ describe("trigger full lifecycle (integration)", () => { it("extractRole: /draft sets roleId=draft, strips command from prompt", async () => { await seedProject("proj-12", "chat-12"); - const trigger = makeTriggerHandler({ prisma, provider, tools, models, logger: silentLogger }); + const trigger = makeTriggerHandler({ prisma, modelFactory, tools, models, logger: silentLogger }); await trigger(makeEvent("chat-12", "@_user_1 /draft 写第三单元"), rt); diff --git a/hub/test/unit/runner.test.ts b/hub/test/unit/runner.test.ts new file mode 100644 index 0000000..8b015d6 --- /dev/null +++ b/hub/test/unit/runner.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runAgent } from "../../src/agent/runner.js"; +import { ToolRegistry } from "../../src/agent/tools.js"; +import { readFileTool } from "../../src/agent/workspace.js"; +import { createMockModelFactory } from "../integration/helpers.js"; + +describe("runAgent with AI SDK tool loop", () => { + it("executes SDK tools and returns the final assistant response", async () => { + const ws = await mkdtemp(join(tmpdir(), "hub-runner-")); + try { + await writeFile(join(ws, "lesson.txt"), "hello lesson", "utf8"); + const tools = new ToolRegistry(); + tools.register("read_file", readFileTool); + const { modelFactory, models } = createMockModelFactory([ + { + toolCalls: [{ toolCallId: "call-1", toolName: "read_file", input: { path: "lesson.txt" } }], + }, + { text: "done", finishReason: "stop" }, + ]); + + const result = await runAgent(modelFactory, tools, { + prompt: "read lesson.txt", + model: "mock-model", + project: { projectId: "p", boundChatId: "c", workspaceDir: ws }, + systemPrompt: "system", + maxIterations: 5, + }); + + expect(result.status).toBe("completed"); + expect(result.messages.at(-1)).toMatchObject({ role: "assistant" }); + expect(models.get("mock-model")?.calls).toHaveLength(2); + } finally { + await rm(ws, { recursive: true, force: true }); + } + }); +}); diff --git a/hub/test/unit/transcript.test.ts b/hub/test/unit/transcript.test.ts index 1490d68..a8093b0 100644 --- a/hub/test/unit/transcript.test.ts +++ b/hub/test/unit/transcript.test.ts @@ -3,10 +3,10 @@ import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { readTranscript, appendTranscript, transcriptPath, TRANSCRIPT_DIR } from "../../src/agent/transcript.js"; -import type { Message } from "../../src/agent/provider.js"; +import type { ModelMessage } from "ai"; -const sample: Message = { role: "user", parts: [{ type: "text", text: "hello" }] }; -const reply: Message = { role: "assistant", parts: [{ type: "text", text: "hi" }] }; +const sample: ModelMessage = { role: "user", content: "hello" }; +const reply: ModelMessage = { role: "assistant", content: "hi" }; describe("transcript JSONL", () => { let dir: string; diff --git a/hub/test/unit/workspace.test.ts b/hub/test/unit/workspace.test.ts index 5b0ca3f..0542cef 100644 --- a/hub/test/unit/workspace.test.ts +++ b/hub/test/unit/workspace.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest"; import { mkdtemp, writeFile, mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { ToolRegistry } from "../../src/agent/tools.js"; +import { ToolRegistry, type ToolContext } from "../../src/agent/tools.js"; import { readFileTool, writeFileTool, listFilesTool, PathEscape } from "../../src/agent/workspace.js"; describe("workspace path confinement", () => { @@ -12,9 +12,9 @@ describe("workspace path confinement", () => { beforeEach(async () => { ws = await mkdtemp(join(tmpdir(), "hub-unit-")); tools = new ToolRegistry(); - tools.register(readFileTool()); - tools.register(writeFileTool()); - tools.register(listFilesTool()); + tools.register("read_file", readFileTool); + tools.register("write_file", writeFileTool); + tools.register("list_files", listFilesTool); await mkdir(join(ws, "sub")); await writeFile(join(ws, "a.txt"), "hello"); await writeFile(join(ws, "sub", "b.txt"), "world"); @@ -23,42 +23,42 @@ describe("workspace path confinement", () => { const ctx = () => ({ runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws }); it("reads a file inside the workspace", async () => { - expect(await tools.execute("read_file", { path: "a.txt" }, ctx())).toBe("hello"); + expect(await executeTool(tools, "read_file", { path: "a.txt" }, ctx())).toBe("hello"); }); it("reads nested files", async () => { - expect(await tools.execute("read_file", { path: "sub/b.txt" }, ctx())).toBe("world"); + expect(await executeTool(tools, "read_file", { path: "sub/b.txt" }, ctx())).toBe("world"); }); it("rejects .. escapes as error JSON, not a throw", async () => { - const out = await tools.execute("read_file", { path: "../../etc/passwd" }, ctx()); + const out = await executeTool(tools, "read_file", { path: "../../etc/passwd" }, ctx()); expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace"); }); it("rejects absolute paths outside the workspace", async () => { - const out = await tools.execute("read_file", { path: "/etc/passwd" }, ctx()); + const out = await executeTool(tools, "read_file", { path: "/etc/passwd" }, ctx()); expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace"); }); it("writes a file and reads it back", async () => { - await tools.execute("write_file", { path: "sub/c.txt", content: "new" }, ctx()); - expect(await tools.execute("read_file", { path: "sub/c.txt" }, ctx())).toBe("new"); + await executeTool(tools, "write_file", { path: "sub/c.txt", content: "new" }, ctx()); + expect(await executeTool(tools, "read_file", { path: "sub/c.txt" }, ctx())).toBe("new"); }); it("creates parent dirs on write", async () => { - await tools.execute("write_file", { path: "deep/nested/d.txt", content: "x" }, ctx()); - expect(await tools.execute("read_file", { path: "deep/nested/d.txt" }, ctx())).toBe("x"); + await executeTool(tools, "write_file", { path: "deep/nested/d.txt", content: "x" }, ctx()); + expect(await executeTool(tools, "read_file", { path: "deep/nested/d.txt" }, ctx())).toBe("x"); }); it("lists the workspace root", async () => { - const out = await tools.execute("list_files", {}, ctx()); + const out = await executeTool(tools, "list_files", {}, ctx()); const entries = JSON.parse(out) as Array<{ name: string; kind: string }>; expect(entries).toContainEqual({ name: "a.txt", kind: "file" }); expect(entries).toContainEqual({ name: "sub", kind: "dir" }); }); it("lists a subdirectory", async () => { - const out = await tools.execute("list_files", { path: "sub" }, ctx()); + const out = await executeTool(tools, "list_files", { path: "sub" }, ctx()); const entries = JSON.parse(out) as Array<{ name: string; kind: string }>; expect(entries).toContainEqual({ name: "b.txt", kind: "file" }); }); @@ -69,12 +69,20 @@ describe("workspace path confinement", () => { }); it("read of nonexistent file returns error JSON", async () => { - const out = await tools.execute("read_file", { path: "nope.txt" }, ctx()); + const out = await executeTool(tools, "read_file", { path: "nope.txt" }, ctx()); expect((JSON.parse(out) as { error: string }).error).toBeTruthy(); }); it("write with .. escape returns error JSON", async () => { - const out = await tools.execute("write_file", { path: "../escape.txt", content: "x" }, ctx()); + const out = await executeTool(tools, "write_file", { path: "../escape.txt", content: "x" }, ctx()); expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace"); }); }); + +async function executeTool(tools: ToolRegistry, name: string, args: unknown, ctx: ToolContext): Promise { + const def = tools.build(ctx)[name]; + if (def?.execute === undefined) { + throw new Error(`missing executable tool: ${name}`); + } + return def.execute(args, { toolCallId: "call", messages: [], context: undefined }) as Promise; +}