forked from bai/curriculum-project-hub
refactor(hub): 手搓 agent loop 换成 Vercel AI SDK
将 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 全过。
This commit is contained in:
Generated
+110
-183
@@ -8,12 +8,14 @@
|
|||||||
"name": "@paradigm/hub",
|
"name": "@paradigm/hub",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/openai-compatible": "^3.0.5",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@larksuiteoapi/node-sdk": "^1.66.1",
|
"@larksuiteoapi/node-sdk": "^1.66.1",
|
||||||
"@prisma/client": "^6.19.3",
|
"@prisma/client": "^6.19.3",
|
||||||
|
"ai": "^7.0.16",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"openai": "^4.77.0"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
@@ -25,6 +27,69 @@
|
|||||||
"node": ">=20"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||||
@@ -1117,7 +1182,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
@@ -1165,14 +1229,13 @@
|
|||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/node-fetch": {
|
"node_modules/@vercel/oidc": {
|
||||||
"version": "2.6.13",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
|
"resolved": "https://registry.npmjs.org/@vercel/oidc/-/oidc-3.2.0.tgz",
|
||||||
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
|
"integrity": "sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==",
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"engines": {
|
||||||
"@types/node": "*",
|
"node": ">= 20"
|
||||||
"form-data": "^4.0.4"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
@@ -1288,17 +1351,11 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/abort-controller": {
|
"node_modules/@workflow/serde": {
|
||||||
"version": "3.0.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@workflow/serde/-/serde-4.1.0.tgz",
|
||||||
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
|
"integrity": "sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==",
|
||||||
"license": "MIT",
|
"license": "Apache-2.0"
|
||||||
"dependencies": {
|
|
||||||
"event-target-shim": "^5.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.5"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/abstract-logging": {
|
"node_modules/abstract-logging": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
@@ -1306,16 +1363,21 @@
|
|||||||
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/agentkeepalive": {
|
"node_modules/ai": {
|
||||||
"version": "4.6.0",
|
"version": "7.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/ai/-/ai-7.0.16.tgz",
|
||||||
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
|
"integrity": "sha512-OuA+uz6ZUVId+SVeB5bThi25Ofee/FmLvffAA368tlgqYCe2qAhqZUpfHL0dd9QC/iPiszdvCQYENJavSo/0gw==",
|
||||||
"license": "MIT",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"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": {
|
"engines": {
|
||||||
"node": ">= 8.0.0"
|
"node": ">=22"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25.76 || ^4.1.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
@@ -1766,13 +1828,13 @@
|
|||||||
"@types/estree": "^1.0.0"
|
"@types/estree": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/event-target-shim": {
|
"node_modules/eventsource-parser": {
|
||||||
"version": "5.0.1",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz",
|
||||||
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
|
"integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expect-type": {
|
"node_modules/expect-type": {
|
||||||
@@ -2002,25 +2064,6 @@
|
|||||||
"node": ">= 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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -2151,15 +2194,6 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
|
||||||
@@ -2179,6 +2213,12 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"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": {
|
"node_modules/json-schema-ref-resolver": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
|
"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": ">= 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": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.15",
|
"version": "3.3.15",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
"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": "^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": {
|
"node_modules/node-fetch-native": {
|
||||||
"version": "1.6.7",
|
"version": "1.6.7",
|
||||||
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
"resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz",
|
||||||
@@ -2717,51 +2711,6 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/pathe": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||||
@@ -3343,12 +3292,6 @@
|
|||||||
"node": ">=20"
|
"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": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"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": {
|
"node_modules/why-is-node-running": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||||
@@ -3626,6 +3544,15 @@
|
|||||||
"optional": true
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -7,12 +7,14 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/openai-compatible": "^3.0.5",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@larksuiteoapi/node-sdk": "^1.66.1",
|
"@larksuiteoapi/node-sdk": "^1.66.1",
|
||||||
"@prisma/client": "^6.19.3",
|
"@prisma/client": "^6.19.3",
|
||||||
|
"ai": "^7.0.16",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"openai": "^4.77.0"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
|
|||||||
+30
-53
@@ -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
|
* 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
|
* stable `cph` CLI (ADR-0016 version contract). `cph check` validates a
|
||||||
* project; `cph build` renders a target artifact. Version compatibility is the
|
* 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
|
* `cphVersionMismatch` error diagnostic (ADR-0016); the Hub merely runs the
|
||||||
* subprocess and returns its stdout/stderr/exit to the model.
|
* subprocess and returns its stdout/stderr/exit to the model.
|
||||||
*
|
*
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
*/
|
*/
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
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);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -53,30 +55,19 @@ async function runCph(args: readonly string[], workspaceDir: string): Promise<Ex
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CphCheckArgs {
|
/// `cph check <dir>` - validate a curriculum engineering file. Returns the
|
||||||
readonly path?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `cph check <dir>` — validate a curriculum engineering file. Returns the
|
|
||||||
/// checker's diagnostics (stdout) for the model to act on. Non-zero exit means
|
/// 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,
|
/// the file has error diagnostics (ADR-0010); the output is still returned,
|
||||||
/// not thrown — the model needs to see the diagnostics to fix them.
|
/// not thrown - the model needs to see the diagnostics to fix them.
|
||||||
export function cphCheckTool(): RegisteredTool {
|
export function cphCheckTool(ctx: ToolContext): ToolDefinition {
|
||||||
return {
|
return tool({
|
||||||
spec: {
|
|
||||||
name: "cph_check",
|
|
||||||
description:
|
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.",
|
"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: {
|
inputSchema: z.object({
|
||||||
type: "object",
|
path: z.string().describe("Path to the engineering file dir; defaults to the workspace root.").optional(),
|
||||||
properties: {
|
}),
|
||||||
path: { type: "string", description: "Path to the engineering file dir; defaults to the workspace root." },
|
execute: async (args): Promise<string> => {
|
||||||
},
|
const target = args.path ?? ".";
|
||||||
},
|
|
||||||
},
|
|
||||||
async execute(args, ctx): Promise<string> {
|
|
||||||
const a = (args as CphCheckArgs) ?? {};
|
|
||||||
const target = a.path ?? ".";
|
|
||||||
const res = await runCph(["check", target], ctx.workspaceDir);
|
const res = await runCph(["check", target], ctx.workspaceDir);
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
exitCode: res.exitCode,
|
exitCode: res.exitCode,
|
||||||
@@ -84,39 +75,25 @@ export function cphCheckTool(): RegisteredTool {
|
|||||||
stderr: res.stderr,
|
stderr: res.stderr,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CphBuildArgs {
|
/// `cph build <dir> --target <T> -o <out>` - render a target artifact. The
|
||||||
readonly target: string;
|
/// target (student/teacher/...) determines the build (ADR-0009). Output path
|
||||||
readonly path?: string;
|
|
||||||
readonly output?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `cph build <dir> --target <T> -o <out>` — render a target artifact. The
|
|
||||||
/// target (student/teacher/…) determines the build (ADR-0009). Output path
|
|
||||||
/// is relative to the workspace unless absolute.
|
/// is relative to the workspace unless absolute.
|
||||||
export function cphBuildTool(): RegisteredTool {
|
export function cphBuildTool(ctx: ToolContext): ToolDefinition {
|
||||||
return {
|
return tool({
|
||||||
spec: {
|
|
||||||
name: "cph_build",
|
|
||||||
description:
|
description:
|
||||||
"Run `cph build` to render a curriculum artifact (e.g. student/teacher PDF). Target selects the build; output names the result file.",
|
"Run `cph build` to render a curriculum artifact (e.g. student/teacher PDF). Target selects the build; output names the result file.",
|
||||||
parameters: {
|
inputSchema: z.object({
|
||||||
type: "object",
|
target: z.string().describe("Build target, e.g. 'student', 'teacher'."),
|
||||||
properties: {
|
path: z.string().describe("Path to the engineering file dir; defaults to the workspace root.").optional(),
|
||||||
target: { type: "string", description: "Build target, e.g. 'student', 'teacher'." },
|
output: z.string().describe("Output file path (relative to workspace or absolute).").optional(),
|
||||||
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)." },
|
execute: async (args): Promise<string> => {
|
||||||
},
|
const target = args.path ?? ".";
|
||||||
required: ["target"],
|
const out = args.output ?? `build/${args.target}.pdf`;
|
||||||
},
|
const res = await runCph(["build", target, "--target", args.target, "-o", out], ctx.workspaceDir);
|
||||||
},
|
|
||||||
async execute(args, ctx): Promise<string> {
|
|
||||||
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);
|
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
exitCode: res.exitCode,
|
exitCode: res.exitCode,
|
||||||
stdout: res.stdout,
|
stdout: res.stdout,
|
||||||
@@ -124,5 +101,5 @@ export function cphBuildTool(): RegisteredTool {
|
|||||||
output: out,
|
output: out,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<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";
|
|
||||||
}
|
|
||||||
+13
-83
@@ -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
|
* ADR-0017: the agent layer keeps role/model resolution separate from provider
|
||||||
* vendor SDK. The request/response shape follows the OpenAI chat-completions
|
* construction. A run carries a resolved model id, and switching model still
|
||||||
* de-facto standard (messages + tools + tool_calls) because that is the common
|
* means a new provider-bound session.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
|
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
|
||||||
|
import type { LanguageModel } from "ai";
|
||||||
|
|
||||||
/** Why a run terminated. Mirrors the common provider surface. */
|
export function createModelFactory(): (modelId: string) => LanguageModel {
|
||||||
export type FinishReason = "stop" | "tool_calls" | "length" | "error";
|
const provider = createOpenAICompatible({
|
||||||
|
name: "openrouter",
|
||||||
/** A contiguous text span in a message. */
|
baseURL: process.env.OPENROUTER_BASE_URL ?? "https://openrouter.ai/api/v1",
|
||||||
export interface TextPart {
|
headers: { Authorization: `Bearer ${process.env.OPENROUTER_API_KEY ?? ""}` },
|
||||||
readonly type: "text";
|
});
|
||||||
readonly text: string;
|
return (modelId: string) => provider(modelId);
|
||||||
}
|
|
||||||
|
|
||||||
/** 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");
|
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-89
@@ -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
|
* The AI SDK owns tool-call dispatch and message folding. The Hub owns the
|
||||||
* model stops, a budget is exhausted, or a tool fails unrecoverably. It depends
|
* per-run tool context, role/model selection, transcript persistence, and the
|
||||||
* only on {@link AgentProvider} + {@link ToolRegistry}, never on a vendor SDK.
|
* 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
|
||||||
* Session/provider binding (ADR-0017): a run is bound to one model. Switching
|
* memory/anchors (ADR-0003), not by this runner.
|
||||||
* 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 { generateText, stepCountIs, type LanguageModel, type ModelMessage } from "ai";
|
||||||
import type { ToolContext, ToolRegistry } from "./tools.js";
|
import type { ToolContext, ToolRegistry } from "./tools.js";
|
||||||
import { readTranscript, appendTranscript } from "./transcript.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. */
|
/** What the runner needs about the project to seed and bound a run. */
|
||||||
export interface ProjectContext {
|
export interface ProjectContext {
|
||||||
readonly projectId: string;
|
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;
|
readonly boundChatId: string;
|
||||||
/** Absolute path to the curriculum engineering-file workspace. */
|
/** Absolute path to the curriculum engineering-file workspace. */
|
||||||
readonly workspaceDir: string;
|
readonly workspaceDir: string;
|
||||||
@@ -30,6 +30,8 @@ export interface RunRequest {
|
|||||||
readonly systemPrompt: string | undefined;
|
readonly systemPrompt: string | undefined;
|
||||||
/** Iteration cap before the run is returned as `length`. Default 25. */
|
/** Iteration cap before the run is returned as `length`. Default 25. */
|
||||||
readonly maxIterations?: number;
|
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
|
/** Path to the session transcript JSONL. If set, prior messages are loaded
|
||||||
* from it before the run, and new messages are appended after. */
|
* from it before the run, and new messages are appended after. */
|
||||||
readonly transcriptPath?: string;
|
readonly transcriptPath?: string;
|
||||||
@@ -40,20 +42,15 @@ export type RunStatus = "completed" | "length" | "failed";
|
|||||||
export interface RunResult {
|
export interface RunResult {
|
||||||
readonly status: RunStatus;
|
readonly status: RunStatus;
|
||||||
/** Full transcript of the run, for audit (ADR Audit, content OPEN). */
|
/** 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 usage: { inputTokens: number; outputTokens: number };
|
||||||
readonly error?: string;
|
readonly error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_MAX_ITERATIONS = 25;
|
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(
|
export async function runAgent(
|
||||||
provider: AgentProvider,
|
modelFactory: ModelFactory,
|
||||||
tools: ToolRegistry,
|
tools: ToolRegistry,
|
||||||
req: RunRequest,
|
req: RunRequest,
|
||||||
): Promise<RunResult> {
|
): Promise<RunResult> {
|
||||||
@@ -63,108 +60,81 @@ export async function runAgent(
|
|||||||
boundChatId: req.project.boundChatId,
|
boundChatId: req.project.boundChatId,
|
||||||
workspaceDir: req.project.workspaceDir,
|
workspaceDir: req.project.workspaceDir,
|
||||||
};
|
};
|
||||||
const messages: Message[] = [];
|
const aiTools = tools.build(ctx, req.toolWhitelist);
|
||||||
|
|
||||||
// Load prior session messages from transcript (continuity across @bot calls
|
// Load prior session messages from transcript (continuity across @bot calls
|
||||||
// in the same session). ADR-0003: session messages ≠ group chat history.
|
// in the same session). ADR-0003: session messages != group chat history.
|
||||||
let loadedCount = 0;
|
let messages: ModelMessage[] = req.transcriptPath !== undefined ? await readTranscript(req.transcriptPath) : [];
|
||||||
if (req.transcriptPath !== undefined) {
|
const loadedCount = messages.length;
|
||||||
const prior = await readTranscript(req.transcriptPath);
|
|
||||||
loadedCount = prior.length;
|
|
||||||
messages.push(...prior);
|
|
||||||
}
|
|
||||||
if (req.systemPrompt !== undefined && messages.length === 0) {
|
if (req.systemPrompt !== undefined && messages.length === 0) {
|
||||||
// System prompt only at the very start of a session; on resume it's
|
// System prompt only at the very start of a session; on resume it's
|
||||||
// already in the transcript.
|
// 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;
|
const cap = req.maxIterations ?? DEFAULT_MAX_ITERATIONS;
|
||||||
let inputTokens = 0;
|
|
||||||
let outputTokens = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < cap; i++) {
|
|
||||||
let res: ChatResponse;
|
|
||||||
try {
|
try {
|
||||||
res = await provider.chat({
|
const result = await generateText({
|
||||||
model: req.model,
|
model: modelFactory(req.model),
|
||||||
messages,
|
messages,
|
||||||
tools: tools.specs(),
|
allowSystemInMessages: true,
|
||||||
|
tools: aiTools,
|
||||||
|
stopWhen: stepCountIs(cap),
|
||||||
});
|
});
|
||||||
} catch (e) {
|
const allMessages: ModelMessage[] = [...messages, ...result.responseMessages];
|
||||||
const result: RunResult = {
|
await appendTranscriptIfSet(req, allMessages, loadedCount);
|
||||||
status: "failed",
|
|
||||||
messages,
|
const stoppedByStepCap = result.finishReason === "tool-calls" && result.steps.length >= cap;
|
||||||
usage: { inputTokens, outputTokens },
|
const status: RunStatus =
|
||||||
error: e instanceof Error ? e.message : String(e),
|
result.finishReason === "stop"
|
||||||
|
? "completed"
|
||||||
|
: result.finishReason === "length" || stoppedByStepCap
|
||||||
|
? "length"
|
||||||
|
: "failed";
|
||||||
|
const error =
|
||||||
|
status === "failed"
|
||||||
|
? `finishReason=${result.finishReason}`
|
||||||
|
: stoppedByStepCap
|
||||||
|
? `exceeded maxIterations=${cap}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
messages: allMessages,
|
||||||
|
usage: {
|
||||||
|
inputTokens: result.usage.inputTokens ?? 0,
|
||||||
|
outputTokens: result.usage.outputTokens ?? 0,
|
||||||
|
},
|
||||||
|
...(error !== undefined ? { error } : {}),
|
||||||
};
|
};
|
||||||
await appendTranscriptIfSet(req, messages, loadedCount);
|
} catch (e) {
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
messages.push(res.message);
|
|
||||||
if (res.usage !== undefined) {
|
|
||||||
inputTokens += res.usage.inputTokens;
|
|
||||||
outputTokens += res.usage.outputTokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 }],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await appendTranscriptIfSet(req, messages, loadedCount);
|
await appendTranscriptIfSet(req, messages, loadedCount);
|
||||||
return {
|
return {
|
||||||
status: "length",
|
status: "failed",
|
||||||
messages,
|
messages,
|
||||||
usage: { inputTokens, outputTokens },
|
usage: { inputTokens: 0, outputTokens: 0 },
|
||||||
error: `exceeded maxIterations=${cap}`,
|
error: e instanceof Error ? e.message : String(e),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Append only the messages produced this run (skip the `loadedCount` prior
|
/// 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.
|
/// failure must not lose the run result.
|
||||||
async function appendTranscriptIfSet(req: RunRequest, messages: readonly Message[], loadedCount: number): Promise<void> {
|
async function appendTranscriptIfSet(req: RunRequest, messages: readonly ModelMessage[], loadedCount: number): Promise<void> {
|
||||||
if (req.transcriptPath === undefined) return;
|
if (req.transcriptPath === undefined) return;
|
||||||
const newMessages = messages.slice(loadedCount);
|
const newMessages = messages.slice(loadedCount);
|
||||||
if (newMessages.length === 0) return;
|
if (newMessages.length === 0) return;
|
||||||
try {
|
try {
|
||||||
await appendTranscript(req.transcriptPath, newMessages);
|
await appendTranscript(req.transcriptPath, newMessages);
|
||||||
} catch {
|
} 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.
|
// 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 {
|
function cryptoRandomId(): string {
|
||||||
return globalThis.crypto.randomUUID();
|
return globalThis.crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-80
@@ -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
|
* Unlike a general "Claude Code", the Hub agent operates on a curriculum
|
||||||
* engineering-file tree (ADR-0007) and reads Feishu context on demand
|
* engineering-file tree (ADR-0007) and reads Feishu context on demand
|
||||||
@@ -10,13 +10,13 @@
|
|||||||
* coupled only via the stable CLI contract ADR-0016),
|
* coupled only via the stable CLI contract ADR-0016),
|
||||||
* - Feishu context reads, which must honor ADR-0003's authorization invariant.
|
* - Feishu context reads, which must honor ADR-0003's authorization invariant.
|
||||||
*
|
*
|
||||||
* That last invariant is encoded here: a Feishu context tool handler rejects any
|
* The AI SDK owns tool-call dispatch; this registry owns Hub-specific tool
|
||||||
* chat id that is not the run's project's bound chat (ADR-0001 binding). This is
|
* construction and per-role whitelisting (ADR-0017).
|
||||||
* `McpReadRequest.Authorized` from `spec/System/Memory.lean` landing in code.
|
|
||||||
*/
|
*/
|
||||||
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 {
|
export interface ToolContext {
|
||||||
readonly runId: string;
|
readonly runId: string;
|
||||||
readonly projectId: string;
|
readonly projectId: string;
|
||||||
@@ -26,64 +26,34 @@ export interface ToolContext {
|
|||||||
readonly workspaceDir: string;
|
readonly workspaceDir: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A tool handler: receives parsed args + run context, returns content for the model. */
|
export type ToolDefinition = Tool;
|
||||||
export type ToolHandler = (args: unknown, ctx: ToolContext) => Promise<string>;
|
export type ToolFactory = (ctx: ToolContext) => ToolDefinition;
|
||||||
|
|
||||||
export interface RegisteredTool {
|
|
||||||
readonly spec: ToolSpec;
|
|
||||||
readonly execute: ToolHandler;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ToolRegistry {
|
export class ToolRegistry {
|
||||||
private readonly byName = new Map<string, RegisteredTool>();
|
private readonly factories = new Map<string, ToolFactory>();
|
||||||
|
|
||||||
register(tool: RegisteredTool): void {
|
register(name: string, factory: ToolFactory): void {
|
||||||
if (this.byName.has(tool.spec.name)) {
|
if (this.factories.has(name)) {
|
||||||
throw new Error(`duplicate tool: ${tool.spec.name}`);
|
throw new Error(`duplicate tool: ${name}`);
|
||||||
}
|
}
|
||||||
this.byName.set(tool.spec.name, tool);
|
this.factories.set(name, factory);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return a per-run view restricted to `names`. Names not registered are
|
* Build the AI SDK `tools` record. If `names` is given, include only those
|
||||||
* silently dropped (a role's whitelist may name tools that a particular Hub
|
* tools (per-role whitelist, ADR-0017). Names not registered are silently
|
||||||
* instance doesn't register). The returned registry shares the handler
|
* dropped because role config may name tools a Hub instance does not provide.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
subset(names: readonly string[]): ToolRegistry {
|
build(ctx: ToolContext, names: readonly string[] | undefined): Record<string, ToolDefinition> {
|
||||||
const allowed = new Set(names);
|
const built: Record<string, ToolDefinition> = {};
|
||||||
const view = new ToolRegistry();
|
const selected = names ?? [...this.factories.keys()];
|
||||||
for (const [name, tool] of this.byName) {
|
for (const name of selected) {
|
||||||
if (allowed.has(name)) {
|
const factory = this.factories.get(name);
|
||||||
// Bypass the duplicate check: we're constructing from an already-valid
|
if (factory !== undefined) {
|
||||||
// registry, not re-registering.
|
built[name] = factory(ctx);
|
||||||
view.byName.set(name, tool);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return view;
|
return built;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,8 +82,9 @@ export interface FeishuContextArgs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the Feishu context-read tool. The handler enforces ADR-0003's invariant
|
* Build the Feishu context-read tool factory. The execute closure enforces
|
||||||
* before any API call: the requested `chat_id` must equal `ctx.boundChatId`.
|
* 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
|
* The actual Feishu API call (read message / reply chain / thread / recent
|
||||||
* group messages) is injected as `read`, so the invariant check is separable
|
* group messages) is injected as `read`, so the invariant check is separable
|
||||||
@@ -123,32 +94,21 @@ export interface FeishuContextArgs {
|
|||||||
export function feishuContextTool(
|
export function feishuContextTool(
|
||||||
read: (args: FeishuContextArgs, ctx: ToolContext, rt: unknown) => Promise<string>,
|
read: (args: FeishuContextArgs, ctx: ToolContext, rt: unknown) => Promise<string>,
|
||||||
rt: unknown,
|
rt: unknown,
|
||||||
): RegisteredTool {
|
): ToolFactory {
|
||||||
return {
|
return (ctx) =>
|
||||||
spec: {
|
tool({
|
||||||
name: "feishu_read_context",
|
|
||||||
description:
|
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.",
|
"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: {
|
inputSchema: z.object({
|
||||||
type: "object",
|
chat_id: z.string().describe("The Feishu chat id to read from."),
|
||||||
properties: {
|
anchor: z.enum(["trigger_message", "status_card", "reply", "thread"]).describe("Which kind of anchor to read."),
|
||||||
chat_id: { type: "string", description: "The Feishu chat id to read from." },
|
id: z.string().describe("The anchor id (message id or run id)."),
|
||||||
anchor: {
|
}),
|
||||||
type: "string",
|
execute: async (args): Promise<string> => {
|
||||||
enum: ["trigger_message", "status_card", "reply", "thread"],
|
if (args.chat_id !== ctx.boundChatId) {
|
||||||
description: "Which kind of anchor to read.",
|
throw new UnauthorizedChatRead(args.chat_id, ctx.boundChatId);
|
||||||
},
|
|
||||||
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, rt);
|
return read(args, ctx, rt);
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Session transcript — append-only JSONL file in the workspace.
|
* 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/<sessionId>.jsonl`, following the same "workspace
|
* `workspaceDir/.cph/sessions/<sessionId>.jsonl`, following the same "workspace
|
||||||
* is a directory tree" principle as the engineering file itself (ADR-0007).
|
* 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
|
* distinct; storing session messages does not conflict with "the Hub avoids
|
||||||
* becoming a full chat history store."
|
* 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 { join, dirname } from "node:path";
|
||||||
import type { Message } from "./provider.js";
|
import type { ModelMessage } from "ai";
|
||||||
|
|
||||||
/// Subdirectory within a workspace where session transcripts live.
|
/// Subdirectory within a workspace where session transcripts live.
|
||||||
export const TRANSCRIPT_DIR = ".cph/sessions";
|
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
|
* skipped — a partially-written last line (crash mid-append) won't break the
|
||||||
* next run.
|
* next run.
|
||||||
*/
|
*/
|
||||||
export async function readTranscript(path: string): Promise<Message[]> {
|
export async function readTranscript(path: string): Promise<ModelMessage[]> {
|
||||||
let content: string;
|
let content: string;
|
||||||
try {
|
try {
|
||||||
content = await readFile(path, "utf8");
|
content = await readFile(path, "utf8");
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const messages: Message[] = [];
|
const messages: ModelMessage[] = [];
|
||||||
for (const line of content.split("\n")) {
|
for (const line of content.split("\n")) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (trimmed === "") continue;
|
if (trimmed === "") continue;
|
||||||
try {
|
try {
|
||||||
messages.push(JSON.parse(trimmed) as Message);
|
messages.push(JSON.parse(trimmed) as ModelMessage);
|
||||||
} catch {
|
} catch {
|
||||||
// Skip corrupt line — likely a partial write from a crash.
|
// Skip corrupt line — likely a partial write from a crash.
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ export async function readTranscript(path: string): Promise<Message[]> {
|
|||||||
* Append messages to a transcript file. Creates the parent directory if needed.
|
* Append messages to a transcript file. Creates the parent directory if needed.
|
||||||
* Each message is written as one JSON line.
|
* Each message is written as one JSON line.
|
||||||
*/
|
*/
|
||||||
export async function appendTranscript(path: string, messages: readonly Message[]): Promise<void> {
|
export async function appendTranscript(path: string, messages: readonly ModelMessage[]): Promise<void> {
|
||||||
await mkdir(dirname(path), { recursive: true });
|
await mkdir(dirname(path), { recursive: true });
|
||||||
const lines = messages.map((m) => JSON.stringify(m)).join("\n") + "\n";
|
const lines = messages.map((m) => JSON.stringify(m)).join("\n") + "\n";
|
||||||
await appendFile(path, lines, "utf8");
|
await appendFile(path, lines, "utf8");
|
||||||
|
|||||||
+34
-64
@@ -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).
|
* curriculum engineering-file tree (ADR-0007 directory tree).
|
||||||
*
|
*
|
||||||
* All paths are **confined to `ctx.workspaceDir`**: the handler resolves the
|
* All paths are **confined to `ctx.workspaceDir`**: the handler resolves the
|
||||||
* requested relative path against the workspace root and rejects any path that
|
* 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
|
* 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
|
* surface; the Hub agent is narrower than a general coding agent.
|
||||||
* narrower than a general "Claude Code").
|
|
||||||
*/
|
*/
|
||||||
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
|
import { readFile, writeFile, readdir, mkdir } from "node:fs/promises";
|
||||||
import { join, resolve, relative, isAbsolute } from "node:path";
|
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. */
|
/** Thrown when a requested path escapes the project workspace root. */
|
||||||
export class PathEscape extends Error {
|
export class PathEscape extends Error {
|
||||||
@@ -38,68 +39,42 @@ function confine(requestedPath: string, workspaceDir: string): string {
|
|||||||
return resolved;
|
return resolved;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReadFileArgs {
|
export function readFileTool(ctx: ToolContext): ToolDefinition {
|
||||||
readonly path: string;
|
return tool({
|
||||||
}
|
|
||||||
|
|
||||||
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.",
|
description: "Read a file from the project's curriculum engineering-file workspace. Path is relative to the workspace root.",
|
||||||
parameters: {
|
inputSchema: z.object({
|
||||||
type: "object",
|
path: z.string().describe("Relative path within the workspace."),
|
||||||
properties: { path: { type: "string", description: "Relative path within the workspace." } },
|
}),
|
||||||
required: ["path"],
|
execute: async (args): Promise<string> => {
|
||||||
},
|
|
||||||
},
|
|
||||||
async execute(args, ctx): Promise<string> {
|
|
||||||
const a = args as ReadFileArgs;
|
|
||||||
try {
|
try {
|
||||||
const full = confine(a.path, ctx.workspaceDir);
|
const full = confine(args.path, ctx.workspaceDir);
|
||||||
return await readFile(full, "utf8");
|
return await readFile(full, "utf8");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WriteFileArgs {
|
export function writeFileTool(ctx: ToolContext): ToolDefinition {
|
||||||
readonly path: string;
|
return tool({
|
||||||
readonly content: string;
|
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({
|
||||||
export function writeFileTool(): RegisteredTool {
|
path: z.string().describe("Relative path within the workspace."),
|
||||||
return {
|
content: z.string().describe("The full file content to write."),
|
||||||
spec: {
|
}),
|
||||||
name: "write_file",
|
execute: async (args): Promise<string> => {
|
||||||
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<string> {
|
|
||||||
const a = args as WriteFileArgs;
|
|
||||||
try {
|
try {
|
||||||
const full = confine(a.path, ctx.workspaceDir);
|
const full = confine(args.path, ctx.workspaceDir);
|
||||||
await mkdir(join(full, ".."), { recursive: true });
|
await mkdir(join(full, ".."), { recursive: true });
|
||||||
await writeFile(full, a.content, "utf8");
|
await writeFile(full, args.content, "utf8");
|
||||||
return JSON.stringify({ ok: true, path: a.path, bytes: a.content.length });
|
return JSON.stringify({ ok: true, path: args.path, bytes: args.content.length });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
|
||||||
|
|
||||||
interface ListFilesArgs {
|
|
||||||
readonly path?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Entry {
|
interface Entry {
|
||||||
@@ -107,19 +82,14 @@ interface Entry {
|
|||||||
readonly kind: "file" | "dir";
|
readonly kind: "file" | "dir";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listFilesTool(): RegisteredTool {
|
export function listFilesTool(ctx: ToolContext): ToolDefinition {
|
||||||
return {
|
return tool({
|
||||||
spec: {
|
|
||||||
name: "list_files",
|
|
||||||
description: "List files and directories at a path within the workspace. Defaults to the workspace root.",
|
description: "List files and directories at a path within the workspace. Defaults to the workspace root.",
|
||||||
parameters: {
|
inputSchema: z.object({
|
||||||
type: "object",
|
path: z.string().describe("Relative path within the workspace; defaults to root.").optional(),
|
||||||
properties: { path: { type: "string", description: "Relative path within the workspace; defaults to root." } },
|
}),
|
||||||
},
|
execute: async (args): Promise<string> => {
|
||||||
},
|
const sub = args.path ?? ".";
|
||||||
async execute(args, ctx): Promise<string> {
|
|
||||||
const a = (args as ListFilesArgs) ?? {};
|
|
||||||
const sub = a.path ?? ".";
|
|
||||||
try {
|
try {
|
||||||
const full = confine(sub, ctx.workspaceDir);
|
const full = confine(sub, ctx.workspaceDir);
|
||||||
const entries = await readdir(full, { withFileTypes: true });
|
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) });
|
return JSON.stringify({ error: e instanceof Error ? e.message : String(e) });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,23 +10,22 @@
|
|||||||
* ADR-0003-authorized tool (chat must match binding).
|
* ADR-0003-authorized tool (chat must match binding).
|
||||||
* 6. On finish/fail/timeout: release the lock, post a status card.
|
* 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.
|
* routing, role-as-data). The trigger does not pick a model directly.
|
||||||
*/
|
*/
|
||||||
import type { PrismaClient } from "@prisma/client";
|
import type { PrismaClient } from "@prisma/client";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import { sendText, sendCard, buildRunStatusCard, type FeishuRuntime, type MessageReceiveEvent } from "./client.js";
|
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 { ToolRegistry } from "../agent/tools.js";
|
||||||
import type { ModelRegistry } from "../agent/models.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 { acquireLock, currentLockRunId, releaseLock } from "../lock.js";
|
||||||
import { canTriggerAgent, canTriggerRole } from "../permission.js";
|
import { canTriggerAgent, canTriggerRole } from "../permission.js";
|
||||||
import { transcriptPath } from "../agent/transcript.js";
|
import { transcriptPath } from "../agent/transcript.js";
|
||||||
|
|
||||||
interface TriggerDeps {
|
interface TriggerDeps {
|
||||||
readonly prisma: PrismaClient;
|
readonly prisma: PrismaClient;
|
||||||
readonly provider: AgentProvider;
|
readonly modelFactory: ModelFactory;
|
||||||
readonly tools: ToolRegistry;
|
readonly tools: ToolRegistry;
|
||||||
readonly models: ModelRegistry;
|
readonly models: ModelRegistry;
|
||||||
readonly logger: FastifyBaseLogger;
|
readonly logger: FastifyBaseLogger;
|
||||||
@@ -150,13 +149,11 @@ export function makeTriggerHandler(deps: TriggerDeps) {
|
|||||||
}
|
}
|
||||||
const role = deps.models.role(roleId);
|
const role = deps.models.role(roleId);
|
||||||
const model = deps.models.resolve(undefined, 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
|
// Per-role bundle (ADR-0017): systemPrompt seeds persona; tools whitelist
|
||||||
// is enforced by constructing a per-run registry subset. `tools: undefined`
|
// is enforced inside runAgent when it builds the SDK tools record. `tools:
|
||||||
// means the full registered set (unrestricted role).
|
// undefined` means the full registered set (unrestricted role).
|
||||||
const systemPrompt = role?.systemPrompt;
|
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
|
// ADR-0017: provider+model-bound session. Reuse if one exists for this
|
||||||
// (project, provider, model) triple; otherwise create.
|
// (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.
|
// Run the agent — fire-and-forget from the ws handler's perspective.
|
||||||
// Per-run tools + systemPrompt come from the role bundle (ADR-0017).
|
// Per-run tools + systemPrompt come from the role bundle (ADR-0017).
|
||||||
runAgent(deps.provider, runTools, {
|
runAgent(deps.modelFactory, deps.tools, {
|
||||||
prompt: cleanPrompt,
|
prompt: cleanPrompt,
|
||||||
model,
|
model,
|
||||||
project: projectCtx,
|
project: projectCtx,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
|
toolWhitelist: role?.tools,
|
||||||
transcriptPath: transcriptPath(project.workspaceDir, session.id),
|
transcriptPath: transcriptPath(project.workspaceDir, session.id),
|
||||||
})
|
})
|
||||||
.then(async (result) => {
|
.then(async (result) => {
|
||||||
|
|||||||
+12
-12
@@ -2,8 +2,8 @@
|
|||||||
* Hub entry — Fastify server + Feishu WebSocket listener.
|
* Hub entry — Fastify server + Feishu WebSocket listener.
|
||||||
*
|
*
|
||||||
* Wires the full trigger path: lark ws receives `im.message.receive_v1` →
|
* Wires the full trigger path: lark ws receives `im.message.receive_v1` →
|
||||||
* trigger handler resolves chat→project (ADR-0001), creates AgentRun + acquires
|
* trigger handler resolves chat->project (ADR-0001), creates AgentRun + acquires
|
||||||
* the project lock (ADR-0002), runs the provider-agnostic agent loop
|
* 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
|
* (ADR-0017), and releases the lock on finish. Feishu context reads inside the
|
||||||
* loop are ADR-0003-authorized (chat must match binding).
|
* loop are ADR-0003-authorized (chat must match binding).
|
||||||
*
|
*
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import { prisma } from "./db.js";
|
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 { InMemoryModelRegistry } from "./agent/models.js";
|
||||||
import { ToolRegistry, feishuContextTool } from "./agent/tools.js";
|
import { ToolRegistry, feishuContextTool } from "./agent/tools.js";
|
||||||
import { readFileTool, writeFileTool, listFilesTool } from "./agent/workspace.js";
|
import { readFileTool, writeFileTool, listFilesTool } from "./agent/workspace.js";
|
||||||
@@ -34,7 +34,7 @@ function requireEnv(name: string): string {
|
|||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const databaseUrl = requireEnv("DATABASE_URL");
|
const databaseUrl = requireEnv("DATABASE_URL");
|
||||||
void databaseUrl; // prisma reads DATABASE_URL from env directly
|
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 feishuAppId = requireEnv("FEISHU_APP_ID");
|
||||||
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
const feishuAppSecret = requireEnv("FEISHU_APP_SECRET");
|
||||||
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
const feishuBotOpenId = requireEnv("FEISHU_BOT_OPEN_ID");
|
||||||
@@ -45,7 +45,7 @@ async function main(): Promise<void> {
|
|||||||
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
|
app.get("/api/healthz", async () => ({ ok: true, ts: Date.now() }));
|
||||||
|
|
||||||
// --- Agent seam wiring ---
|
// --- Agent seam wiring ---
|
||||||
const provider = new OpenRouterProvider({ apiKey: openrouterKey });
|
const modelFactory = createModelFactory();
|
||||||
const models = new InMemoryModelRegistry(
|
const models = new InMemoryModelRegistry(
|
||||||
[
|
[
|
||||||
{ id: "z-ai/glm-4.6", label: "GLM-4.6", toolCapable: true },
|
{ id: "z-ai/glm-4.6", label: "GLM-4.6", toolCapable: true },
|
||||||
@@ -79,17 +79,17 @@ async function main(): Promise<void> {
|
|||||||
const tools = new ToolRegistry();
|
const tools = new ToolRegistry();
|
||||||
// ADR-0003: the handler enforces chat==boundChat before any API call;
|
// ADR-0003: the handler enforces chat==boundChat before any API call;
|
||||||
// real read wraps @larksuiteoapi/node-sdk's im.v1.message get/list.
|
// 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).
|
// Workspace file tools (ADR-0007 directory tree, path-confined).
|
||||||
tools.register(readFileTool());
|
tools.register("read_file", readFileTool);
|
||||||
tools.register(writeFileTool());
|
tools.register("write_file", writeFileTool);
|
||||||
tools.register(listFilesTool());
|
tools.register("list_files", listFilesTool);
|
||||||
// cph CLI tools (ADR-0016 version contract enforced by cph itself).
|
// cph CLI tools (ADR-0016 version contract enforced by cph itself).
|
||||||
tools.register(cphCheckTool());
|
tools.register("cph_check", cphCheckTool);
|
||||||
tools.register(cphBuildTool());
|
tools.register("cph_build", cphBuildTool);
|
||||||
|
|
||||||
// --- Feishu listener (reuses the same lark client) ---
|
// --- 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);
|
startFeishuListenerWithClient(feishuConfig, larkClient, app.log, trigger);
|
||||||
|
|
||||||
app.listen({ port, host: "0.0.0.0" }, (err, address) => {
|
app.listen({ port, host: "0.0.0.0" }, (err, address) => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { describe, it, expect, beforeEach } from "vitest";
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||||
import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises";
|
import { cp, mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
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";
|
import { cphCheckTool, cphBuildTool } from "../../src/agent/cph.js";
|
||||||
|
|
||||||
const EXAMPLES_DIR = join(process.cwd(), "..", "examples", "TH-141");
|
const EXAMPLES_DIR = join(process.cwd(), "..", "examples", "TH-141");
|
||||||
@@ -12,16 +12,21 @@ describe("cph subprocess tools (integration, real cph binary)", () => {
|
|||||||
let tools: ToolRegistry;
|
let tools: ToolRegistry;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
// Use the real TH-141 example as the workspace.
|
// Use a writable copy of the real TH-141 example as the workspace.
|
||||||
ws = EXAMPLES_DIR;
|
ws = await mkdtemp(join(tmpdir(), "hub-cph-example-"));
|
||||||
|
await cp(EXAMPLES_DIR, ws, { recursive: true });
|
||||||
tools = new ToolRegistry();
|
tools = new ToolRegistry();
|
||||||
tools.register(cphCheckTool());
|
tools.register("cph_check", cphCheckTool);
|
||||||
tools.register(cphBuildTool());
|
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 () => {
|
it("cph_check runs on the TH-141 example and returns diagnostics", async () => {
|
||||||
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws };
|
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 };
|
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).
|
// 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 () => {
|
it("cph_build renders the student target PDF", async () => {
|
||||||
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws };
|
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 };
|
const result = JSON.parse(out) as { exitCode: number; stdout: string; stderr: string; output: string };
|
||||||
|
|
||||||
expect(result.exitCode).toBe(0);
|
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-"));
|
const empty = await mkdtemp(join(tmpdir(), "hub-cph-empty-"));
|
||||||
try {
|
try {
|
||||||
const ctx = { runId: "r", projectId: "p", boundChatId: "c", workspaceDir: empty };
|
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 };
|
const result = JSON.parse(out) as { exitCode: number };
|
||||||
expect(result.exitCode).not.toBe(0);
|
expect(result.exitCode).not.toBe(0);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -49,3 +54,11 @@ describe("cph subprocess tools (integration, real cph binary)", () => {
|
|||||||
}
|
}
|
||||||
}, 15000);
|
}, 15000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function executeTool(tools: ToolRegistry, name: string, args: unknown, ctx: ToolContext): Promise<string> {
|
||||||
|
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<string>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,12 +3,19 @@
|
|||||||
*
|
*
|
||||||
* Each test gets a clean DB (tables truncated before the test), a mock
|
* 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
|
* 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 { PrismaClient } from "@prisma/client";
|
||||||
import type { FastifyBaseLogger } from "fastify";
|
import type { FastifyBaseLogger } from "fastify";
|
||||||
import type { FeishuRuntime } from "../src/feishu/client.js";
|
import type {
|
||||||
import type { AgentProvider, ChatRequest, ChatResponse, Message } from "../src/agent/provider.js";
|
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";
|
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;
|
return rt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A provider that always returns a fixed "stop" response — no network. */
|
export interface MockToolCall {
|
||||||
export class MockProvider implements AgentProvider {
|
readonly toolCallId: string;
|
||||||
readonly id = "mock";
|
readonly toolName: string;
|
||||||
readonly calls: ChatRequest[] = [];
|
readonly input: unknown;
|
||||||
|
|
||||||
async chat(req: ChatRequest): Promise<ChatResponse> {
|
|
||||||
this.calls.push(req);
|
|
||||||
const message: Message = {
|
|
||||||
role: "assistant",
|
|
||||||
parts: [{ type: "text", text: "mock response" }],
|
|
||||||
};
|
|
||||||
return { finishReason: "stop", message, usage: { inputTokens: 10, outputTokens: 5 } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<string, RegExp[]> = {};
|
||||||
|
readonly calls: LanguageModelV4CallOptions[] = [];
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly modelId: string,
|
||||||
|
private readonly responses: readonly MockModelResponse[] = [{ text: "mock response" }],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async doGenerate(options: LanguageModelV4CallOptions): Promise<LanguageModelV4GenerateResult> {
|
||||||
|
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: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async doStream(_options: LanguageModelV4CallOptions): Promise<LanguageModelV4StreamResult> {
|
||||||
|
throw new Error("MockLanguageModel does not implement streaming");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMockModelFactory(
|
||||||
|
responses: readonly MockModelResponse[] = [{ text: "mock response" }],
|
||||||
|
): { readonly modelFactory: ModelFactory; readonly models: ReadonlyMap<string, MockLanguageModel> } {
|
||||||
|
const models = new Map<string, MockLanguageModel>();
|
||||||
|
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. */
|
/** Create a project + binding + user + grant for a test. */
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { describe, it, expect, beforeEach, afterAll, vi } from "vitest";
|
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 { 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 { makeTriggerHandler, extractPrompt } from "../../src/feishu/trigger.js";
|
||||||
import type { MessageReceiveEvent } from "../../src/feishu/client.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" };
|
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)", () => {
|
describe("trigger full lifecycle (integration)", () => {
|
||||||
let provider: MockProvider;
|
let modelFactory: ModelFactory;
|
||||||
let tools: ToolRegistry;
|
let tools: ToolRegistry;
|
||||||
let models: InMemoryModelRegistry;
|
let models: InMemoryModelRegistry;
|
||||||
let rt: ReturnType<typeof mockFeishuRuntime>;
|
let rt: ReturnType<typeof mockFeishuRuntime>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await resetDb();
|
await resetDb();
|
||||||
provider = new MockProvider();
|
modelFactory = createMockModelFactory().modelFactory;
|
||||||
tools = new ToolRegistry();
|
tools = new ToolRegistry();
|
||||||
models = new InMemoryModelRegistry(
|
models = new InMemoryModelRegistry(
|
||||||
[{ id: "mock-model", label: "Mock", toolCapable: true }],
|
[{ 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 () => {
|
it("creates a run, acquires + releases the lock, sends status card", async () => {
|
||||||
await seedProject("proj-1", "chat-1");
|
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);
|
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 () => {
|
it("rejects a sender without edit grant (ADR-0004)", async () => {
|
||||||
await seedProject("proj-2", "chat-2", { role: "READ" });
|
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);
|
await trigger(makeEvent("chat-2", "@_user_1 写教案"), rt);
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
data: { projectId: "proj-3", runId: existingRun.id },
|
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);
|
await trigger(makeEvent("chat-3", "@_user_1 写教案"), rt);
|
||||||
|
|
||||||
expect(rt.sentTexts).toContain("项目正在处理中,请稍候。");
|
expect(rt.sentTexts).toContain("项目正在处理中,请稍候。");
|
||||||
@@ -95,7 +96,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
|
|
||||||
it("ignores messages from unbound chats (ADR-0001)", async () => {
|
it("ignores messages from unbound chats (ADR-0001)", async () => {
|
||||||
await seedProject("proj-4", "chat-4");
|
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);
|
await trigger(makeEvent("chat-UNKNOWN", "@_user_1 写教案"), rt);
|
||||||
|
|
||||||
@@ -107,7 +108,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
|
|
||||||
it("ignores messages without @bot mention", async () => {
|
it("ignores messages without @bot mention", async () => {
|
||||||
await seedProject("proj-5", "chat-5");
|
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 = {
|
const event: MessageReceiveEvent = {
|
||||||
message: {
|
message: {
|
||||||
@@ -129,7 +130,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
|
|
||||||
it("/new archives current session (no run created)", async () => {
|
it("/new archives current session (no run created)", async () => {
|
||||||
await seedProject("proj-6", "chat-6");
|
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.
|
// First @bot creates a session + run.
|
||||||
await trigger(makeEvent("chat-6", "@_user_1 写教案"), rt);
|
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 () => {
|
it("/resume un-archives the most recent session", async () => {
|
||||||
await seedProject("proj-7", "chat-7");
|
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.
|
// Create + archive a session via /new.
|
||||||
await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt);
|
await trigger(makeEvent("chat-7", "@_user_1 写教案"), rt);
|
||||||
@@ -172,7 +173,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
|
|
||||||
it("/reset archives current session", async () => {
|
it("/reset archives current session", async () => {
|
||||||
await seedProject("proj-8", "chat-8");
|
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 trigger(makeEvent("chat-8", "@_user_1 写教案"), rt);
|
||||||
await vi.waitFor(async () => {
|
await vi.waitFor(async () => {
|
||||||
@@ -191,7 +192,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
|
|
||||||
it("unknown slash command falls through to agent", async () => {
|
it("unknown slash command falls through to agent", async () => {
|
||||||
await seedProject("proj-9", "chat-9");
|
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);
|
await trigger(makeEvent("chat-9", "@_user_1 /unknown"), rt);
|
||||||
// Should create a run (falls through as a normal prompt).
|
// Should create a run (falls through as a normal prompt).
|
||||||
@@ -208,7 +209,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
await prisma.roleTriggerGrant.create({
|
await prisma.roleTriggerGrant.create({
|
||||||
data: { projectId: "proj-10", roleId: "review", principal: "ou_other" },
|
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);
|
await trigger(makeEvent("chat-10", "@_user_1 /review 看看这节"), rt);
|
||||||
|
|
||||||
@@ -222,7 +223,7 @@ describe("trigger full lifecycle (integration)", () => {
|
|||||||
await prisma.roleTriggerGrant.create({
|
await prisma.roleTriggerGrant.create({
|
||||||
data: { projectId: "proj-11", roleId: "review", principal: "ou_test_user" },
|
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);
|
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 () => {
|
it("extractRole: /draft sets roleId=draft, strips command from prompt", async () => {
|
||||||
await seedProject("proj-12", "chat-12");
|
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);
|
await trigger(makeEvent("chat-12", "@_user_1 /draft 写第三单元"), rt);
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,10 +3,10 @@ import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { readTranscript, appendTranscript, transcriptPath, TRANSCRIPT_DIR } from "../../src/agent/transcript.js";
|
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 sample: ModelMessage = { role: "user", content: "hello" };
|
||||||
const reply: Message = { role: "assistant", parts: [{ type: "text", text: "hi" }] };
|
const reply: ModelMessage = { role: "assistant", content: "hi" };
|
||||||
|
|
||||||
describe("transcript JSONL", () => {
|
describe("transcript JSONL", () => {
|
||||||
let dir: string;
|
let dir: string;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach } from "vitest";
|
|||||||
import { mkdtemp, writeFile, mkdir } from "node:fs/promises";
|
import { mkdtemp, writeFile, mkdir } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
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";
|
import { readFileTool, writeFileTool, listFilesTool, PathEscape } from "../../src/agent/workspace.js";
|
||||||
|
|
||||||
describe("workspace path confinement", () => {
|
describe("workspace path confinement", () => {
|
||||||
@@ -12,9 +12,9 @@ describe("workspace path confinement", () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
ws = await mkdtemp(join(tmpdir(), "hub-unit-"));
|
ws = await mkdtemp(join(tmpdir(), "hub-unit-"));
|
||||||
tools = new ToolRegistry();
|
tools = new ToolRegistry();
|
||||||
tools.register(readFileTool());
|
tools.register("read_file", readFileTool);
|
||||||
tools.register(writeFileTool());
|
tools.register("write_file", writeFileTool);
|
||||||
tools.register(listFilesTool());
|
tools.register("list_files", listFilesTool);
|
||||||
await mkdir(join(ws, "sub"));
|
await mkdir(join(ws, "sub"));
|
||||||
await writeFile(join(ws, "a.txt"), "hello");
|
await writeFile(join(ws, "a.txt"), "hello");
|
||||||
await writeFile(join(ws, "sub", "b.txt"), "world");
|
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 });
|
const ctx = () => ({ runId: "r", projectId: "p", boundChatId: "c", workspaceDir: ws });
|
||||||
|
|
||||||
it("reads a file inside the workspace", async () => {
|
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 () => {
|
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 () => {
|
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");
|
expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects absolute paths outside the workspace", async () => {
|
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");
|
expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes a file and reads it back", async () => {
|
it("writes a file and reads it back", async () => {
|
||||||
await tools.execute("write_file", { path: "sub/c.txt", content: "new" }, ctx());
|
await executeTool(tools, "write_file", { path: "sub/c.txt", content: "new" }, ctx());
|
||||||
expect(await tools.execute("read_file", { path: "sub/c.txt" }, ctx())).toBe("new");
|
expect(await executeTool(tools, "read_file", { path: "sub/c.txt" }, ctx())).toBe("new");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates parent dirs on write", async () => {
|
it("creates parent dirs on write", async () => {
|
||||||
await tools.execute("write_file", { path: "deep/nested/d.txt", content: "x" }, ctx());
|
await executeTool(tools, "write_file", { path: "deep/nested/d.txt", content: "x" }, ctx());
|
||||||
expect(await tools.execute("read_file", { path: "deep/nested/d.txt" }, ctx())).toBe("x");
|
expect(await executeTool(tools, "read_file", { path: "deep/nested/d.txt" }, ctx())).toBe("x");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists the workspace root", async () => {
|
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 }>;
|
const entries = JSON.parse(out) as Array<{ name: string; kind: string }>;
|
||||||
expect(entries).toContainEqual({ name: "a.txt", kind: "file" });
|
expect(entries).toContainEqual({ name: "a.txt", kind: "file" });
|
||||||
expect(entries).toContainEqual({ name: "sub", kind: "dir" });
|
expect(entries).toContainEqual({ name: "sub", kind: "dir" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lists a subdirectory", async () => {
|
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 }>;
|
const entries = JSON.parse(out) as Array<{ name: string; kind: string }>;
|
||||||
expect(entries).toContainEqual({ name: "b.txt", kind: "file" });
|
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 () => {
|
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();
|
expect((JSON.parse(out) as { error: string }).error).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("write with .. escape returns error JSON", async () => {
|
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");
|
expect((JSON.parse(out) as { error: string }).error).toContain("escapes workspace");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function executeTool(tools: ToolRegistry, name: string, args: unknown, ctx: ToolContext): Promise<string> {
|
||||||
|
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<string>;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user