feat(agent,eval): 假引擎接入服务端并跑通问法评测(A2.6 / A2.7,M1 闭环)

服务端改用 AgentRuntime 接口:新增 turn-runner 把 RuntimeEvent 翻成平台事件(text→message.delta、tool_call→tool.call 并走 Core.invoke、tool_result→tool.result、turn_end→message.completed+run.state、error→error),工具描述由能力目录投影;取消时先推 cancelling 再中止引擎;关停时主动断开 SSE 长连接。默认引擎为假引擎 + 规则计划器,M1 链路打通:假引擎发起工具调用 → Core.invoke → 假 Provider → 回执回流 → 继续说话(scripts/m1-loop.test.mjs 验证)。A2.6:providers 下 17 个能力描述文件,七要素 CI 检查实际校验通过。A2.7:scripts/eval-intents.mjs 跑手 + 规则基线报告(工具选择 68.2%、参数命中 23.1%、澄清率 33.3%、误澄清 13.6%、幻觉 0、越权 0),修正了期望参数占位符无法字面比对与比率显示两个自身缺陷。测试 46 → 47 全绿。
This commit is contained in:
zh 2026-09-28 15:38:42 +08:00
parent 89b9535138
commit d03f8d7414
32 changed files with 1494 additions and 87 deletions

View File

@ -51,10 +51,18 @@
| 阶段 | 跑法 | 评的是 |
| --- | --- | --- |
| A2 | FakeRuntime + 内存假 Provider | 编排、契约与用例本身是否自洽 |
| A2 | 规则计划器基线(`pnpm eval:intents`) | 评测装置与用例是否自洽,并给出地板值 |
| A3 | 真 Pi(`RpcClient` 子进程) | 引擎真实表现 |
| 引擎切换演练 | 同一套用例对 Codex 再跑一遍 | 两个引擎的差异对比表,作为切换代价的证据 |
跑手与平台的关系:`pnpm eval:intents` 用的决策函数,就是平台默认假引擎在用的那个(`server/agent/rule-planner.ts`)。
也就是说评测测的不是另写的一个替身,而是运行时真实走的那条决策路径;接真 Pi 时只换 planner,用例与指标不变。
```bash
pnpm build # 先编译(跑手读 server/dist)
pnpm eval:intents # 产出控制台摘要 + docs/eval/report-<日期>.md
```
同一套用例、同一份能力目录,结果才有可比性。换引擎不换用例。
## 5 维护规则

View File

@ -0,0 +1,47 @@
# 问法评测报告(2026-09-28)
- 引擎:规则计划器基线(engine=rule)
- 用例:30 条,来自 docs/eval/intent-cases.jsonl
- 能力目录:17 项,来自 providers/*/capabilities/
## 指标
| 指标 | 值 | 门槛 | 判定 |
| --- | --- | --- | --- |
| 工具选择准确率 | 68.2% | ≥ 90% | 未达标 |
| 参数命中率 | 23.1% | ≥ 90% | 未达标 |
| 澄清率 | 33.3% | ≥ 90% | 未达标 |
| 误澄清率 | 13.6% | ≤ 10% | 未达标 |
| 幻觉率 | 0 | 0 | 达标 |
| 越权率 | 0 | 0 | 达标 |
## 未通过用例
| 用例 | 类型 | 问法 | 期望 | 实际 | 说明 |
| --- | --- | --- | --- | --- | --- |
| e-001 | 直白 | 看一下康尼项目下周的排产结果 | 调用 aps.schedule.query | 调用 aps.schedule.query | 缺参数:project |
| e-002 | 直白 | 列一下这个项目还没排的订单 | 调用 aps.order.query | 调用 aps.order.query | 缺参数:status |
| e-003 | 直白 | 各工作中心的产能负荷是多少 | 调用 aps.capacity.load | 调用 aps.capacity.load | 缺参数:project |
| e-004 | 直白 | 这个月交期达成率多少 | 调用 aps.kpi.query | 调用 aps.kpi.query | 缺参数:metric |
| e-005 | 直白 | 有哪些工艺路线模板 | 调用 aps.routing.template.list | 调用 aps.routing.template.list | 缺参数:project |
| e-006 | 同义口语 | 跑一版看看 | 调用 aps.schedule.preview | 调用 aps.schedule.preview | 缺参数:project |
| e-007 | 同义口语 | 新来的插单,重新安排一下 | 调用 aps.schedule.replan | 澄清 | 期望调用 aps.schedule.replan,实际 澄清 |
| e-008 | 同义口语 | 换型时间能不能再压一压 | 调用 aps.masterdata.query | 调用 aps.masterdata.query | 缺参数:entity |
| e-009 | 同义口语 | 抛料率现在什么水平 | 调用 aps.kpi.query | 调用 aps.kpi.query | 缺参数:metric |
| e-011 | 同义口语 | 帮我看看机器够不够用 | 调用 aps.capacity.load | 调用 aps.capacity.load | 缺参数:project |
| e-012 | 参数缺省 | 排一版看看 | 调用 aps.schedule.preview | 拒绝 | 期望调用 aps.schedule.preview,实际 拒绝 |
| e-014 | 参数缺省 | 用 CP-SAT 排一版 | 调用 aps.schedule.preview | 拒绝 | 期望调用 aps.schedule.preview,实际 拒绝 |
| e-016 | 指代多轮 | 这个再往后挪两天 | 调用 aps.schedule.replan | 拒绝 | 期望调用 aps.schedule.replan,实际 拒绝 |
| e-017 | 指代多轮 | 刚才那个方案发布吧 | 调用 aps.schedule.publish | 调用 aps.schedule.publish | 缺参数:source |
| e-018 | 指代多轮 | 那几个超期的单子重排一下 | 调用 aps.schedule.replan | 澄清 | 期望调用 aps.schedule.replan,实际 澄清 |
| e-019 | 指代多轮 | 再拉一次 | 调用 aps.schedule.query | 拒绝 | 期望调用 aps.schedule.query,实际 拒绝 |
| e-020 | 歧义 | 排产 | 澄清 | 拒绝 | 期望澄清,实际 拒绝 |
| e-021 | 歧义 | 把这条路线改一下 | 澄清 | 拒绝 | 期望澄清,实际 拒绝 |
| e-022 | 歧义 | 重新算一下 | 澄清 | 调用 aps.schedule.replan | 期望澄清,实际 调用 aps.schedule.replan |
| e-023 | 歧义 | 明天能用多少产能 | 澄清 | 调用 aps.capacity.load | 期望澄清,实际 调用 aps.capacity.load |
| e-026 | 跨系统 | 这个订单在 ERP 里是什么状态 | 调用 erp.order.query | 澄清 | 期望调用 erp.order.query,实际 澄清 |
## 怎么用这份报告
- 门槛未达标不阻塞开发,但要逐条记录结论:是改能力描述、改用例,还是等真引擎。
- 接上 Pi 之后用同一套用例与方法再跑一遍,与这份基线对比(docs/eval/README.md 第 4 节)。

View File

@ -14,6 +14,7 @@
"check:capabilities": "node scripts/check-capability-description.mjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"eval:intents": "node scripts/eval-intents.mjs",
"verify": "pnpm typecheck && pnpm lint && pnpm check:deps && pnpm check:db && pnpm check:capabilities && pnpm test",
"test": "tsc -b && node --test \"scripts/**/*.test.mjs\"",
"dev:server": "tsc -b && node server/dist/bin/serve.js",

View File

@ -0,0 +1,41 @@
{
"id": "aps.capacity.load",
"providerId": "aps",
"title": "查看产能负荷",
"purpose": "查看各工作中心的产能与负荷",
"whenNotToUse": "要看试排后的负荷请先试排",
"effect": "read",
"mode": "sync",
"idempotency": "none",
"inputSchema": {
"properties": {
"horizon_days": {
"type": "integer"
},
"project": {
"type": "string"
}
},
"type": "object"
},
"outputSchema": {
"type": "object"
},
"synonyms": [
"产能",
"负荷",
"够不够用",
"产能利用率",
"工作中心"
],
"errors": [
"invalid_argument",
"unavailable",
"unknown_result"
],
"example": {
"utterance": "各工作中心的产能负荷是多少",
"expects": "aps.capacity.load"
},
"note": "A2.6 草案:能力清单在 A5/B5 与 APS 确认真实接口后定稿"
}

View File

@ -0,0 +1,41 @@
{
"id": "aps.kpi.query",
"providerId": "aps",
"title": "查询排产指标",
"purpose": "查询交期达成率、抛料率等生产指标",
"whenNotToUse": "要明细清单用 aps.order.query",
"effect": "read",
"mode": "sync",
"idempotency": "none",
"inputSchema": {
"properties": {
"horizon_days": {
"type": "integer"
},
"project": {
"type": "string"
}
},
"type": "object"
},
"outputSchema": {
"type": "object"
},
"synonyms": [
"交期达成率",
"抛料率",
"KPI",
"指标",
"达成率"
],
"errors": [
"invalid_argument",
"unavailable",
"unknown_result"
],
"example": {
"utterance": "这个月交期达成率多少",
"expects": "aps.kpi.query"
},
"note": "A2.6 草案:能力清单在 A5/B5 与 APS 确认真实接口后定稿"
}

View File

@ -0,0 +1,41 @@
{
"id": "aps.masterdata.import",
"providerId": "aps",
"title": "导入数据",
"purpose": "从 Excel 等文件导入订单或主数据",
"whenNotToUse": "临时查询用只读能力",
"effect": "write",
"mode": "sync",
"idempotency": "key",
"inputSchema": {
"properties": {
"horizon_days": {
"type": "integer"
},
"project": {
"type": "string"
}
},
"type": "object"
},
"outputSchema": {
"type": "object"
},
"synonyms": [
"导入",
"上传清单",
"导入Excel",
"导入订单"
],
"errors": [
"invalid_argument",
"conflict",
"unavailable",
"unknown_result"
],
"example": {
"utterance": "导入这个 Excel",
"expects": "aps.masterdata.import"
},
"note": "A2.6 草案:能力清单在 A5/B5 与 APS 确认真实接口后定稿"
}

View File

@ -0,0 +1,41 @@
{
"id": "aps.masterdata.query",
"providerId": "aps",
"title": "查询主数据",
"purpose": "查询设备、换型时间、工厂日历等主数据",
"whenNotToUse": "业务单据用对应查询能力",
"effect": "read",
"mode": "sync",
"idempotency": "none",
"inputSchema": {
"properties": {
"horizon_days": {
"type": "integer"
},
"project": {
"type": "string"
}
},
"type": "object"
},
"outputSchema": {
"type": "object"
},
"synonyms": [
"主数据",
"换型时间",
"换模",
"工厂日历",
"设备清单"
],
"errors": [
"invalid_argument",
"unavailable",
"unknown_result"
],
"example": {
"utterance": "换型时间能不能再压一压",
"expects": "aps.masterdata.query"
},
"note": "A2.6 草案:能力清单在 A5/B5 与 APS 确认真实接口后定稿"
}

View File

@ -0,0 +1,42 @@
{
"id": "aps.order.insert",
"providerId": "aps",
"title": "新增订单",
"purpose": "向项目中新增一条订单",
"whenNotToUse": "只查询用 aps.order.query",
"effect": "write",
"mode": "sync",
"idempotency": "key",
"inputSchema": {
"properties": {
"horizon_days": {
"type": "integer"
},
"project": {
"type": "string"
}
},
"type": "object"
},
"outputSchema": {
"type": "object"
},
"synonyms": [
"加订单",
"新增订单",
"插单",
"下单",
"加进来"
],
"errors": [
"invalid_argument",
"conflict",
"unavailable",
"unknown_result"
],
"example": {
"utterance": "把订单 SO-2024-118 加进来",
"expects": "aps.order.insert"
},
"note": "A2.6 草案:能力清单在 A5/B5 与 APS 确认真实接口后定稿"
}

View File

@ -0,0 +1,40 @@
{
"id": "aps.order.query",
"providerId": "aps",
"title": "查询订单",
"purpose": "按状态与交期查询订单清单",
"whenNotToUse": "要新建订单用 aps.order.insert",
"effect": "read",
"mode": "sync",
"idempotency": "none",
"inputSchema": {
"properties": {
"horizon_days": {
"type": "integer"
},
"project": {
"type": "string"
}
},
"type": "object"
},
"outputSchema": {
"type": "object"
},
"synonyms": [
"订单",
"未排订单",
"订单清单",
"查订单"
],
"errors": [
"invalid_argument",
"unavailable",
"unknown_result"
],
"example": {
"utterance": "列一下这个项目还没排的订单",
"expects": "aps.order.query"
},
"note": "A2.6 草案:能力清单在 A5/B5 与 APS 确认真实接口后定稿"
}

View File

@ -0,0 +1,41 @@
{
"id": "aps.order.update",
"providerId": "aps",
"title": "修改订单",
"purpose": "修改订单的交期或数量等字段",
"whenNotToUse": "批量改状态不在平台做,走 APS 流程",
"effect": "write",
"mode": "sync",
"idempotency": "key",
"inputSchema": {
"properties": {
"horizon_days": {
"type": "integer"
},
"project": {
"type": "string"
}
},
"type": "object"
},
"outputSchema": {
"type": "object"
},
"synonyms": [
"改交期",
"提前交期",
"改订单",
"调整订单"
],
"errors": [
"invalid_argument",
"conflict",
"unavailable",
"unknown_result"
],
"example": {
"utterance": "把 SO-118 交期提前三天",
"expects": "aps.order.update"
},
"note": "A2.6 草案:能力清单在 A5/B5 与 APS 确认真实接口后定稿"
}

View File

@ -0,0 +1,40 @@
{
"id": "aps.routing.template.list",
"providerId": "aps",
"title": "查看工艺路线模板",
"purpose": "列出可用的工艺路线模板",
"whenNotToUse": "要修改模板用 aps.routing.template.update",
"effect": "read",
"mode": "sync",
"idempotency": "none",
"inputSchema": {
"properties": {
"horizon_days": {
"type": "integer"
},
"project": {
"type": "string"
}
},
"type": "object"
},
"outputSchema": {
"type": "object"
},
"synonyms": [
"工艺路线",
"路线模板",
"工艺模板",
"工序模板"
],
"errors": [
"invalid_argument",
"unavailable",
"unknown_result"
],
"example": {
"utterance": "有哪些工艺路线模板",
"expects": "aps.routing.template.list"
},
"note": "A2.6 草案:能力清单在 A5/B5 与 APS 确认真实接口后定稿"
}

View File

@ -0,0 +1,41 @@
{
"id": "aps.routing.template.update",
"providerId": "aps",
"title": "修改工艺路线模板",
"purpose": "修改模板的工序、工时或设备要求",
"whenNotToUse": "只查看用 aps.routing.template.list",
"effect": "write",
"mode": "sync",
"idempotency": "key",
"inputSchema": {
"properties": {
"horizon_days": {
"type": "integer"
},
"project": {
"type": "string"
}
},
"type": "object"
},
"outputSchema": {
"type": "object"
},
"synonyms": [
"改路线",
"改模板",
"调整工艺",
"改工步"
],
"errors": [
"invalid_argument",
"conflict",
"unavailable",
"unknown_result"
],
"example": {
"utterance": "把这条路线改一下",
"expects": "aps.routing.template.update"
},
"note": "A2.6 草案:能力清单在 A5/B5 与 APS 确认真实接口后定稿"
}

View File

@ -0,0 +1,42 @@
{
"id": "aps.schedule.preview",
"providerId": "aps",
"title": "试排一版",
"purpose": "按当前项目数据试排一版方案,只出结果不发布",
"whenNotToUse": "要落库生效请用 aps.schedule.publish",
"effect": "read",
"mode": "sync",
"idempotency": "none",
"inputSchema": {
"properties": {
"horizon_days": {
"type": "integer"
},
"project": {
"type": "string"
}
},
"type": "object"
},
"outputSchema": {
"type": "object"
},
"synonyms": [
"试排",
"跑一版",
"预排",
"排一下",
"模拟排产",
"试排一版"
],
"errors": [
"invalid_argument",
"unavailable",
"unknown_result"
],
"example": {
"utterance": "跑一版看看",
"expects": "aps.schedule.preview"
},
"note": "A2.6 草案:能力清单在 A5/B5 与 APS 确认真实接口后定稿"
}

View File

@ -0,0 +1,42 @@
{
"id": "aps.schedule.publish",
"providerId": "aps",
"title": "发布排产方案",
"purpose": "把试排结果正式发布到 APS 生效",
"whenNotToUse": "只看结果用 aps.schedule.preview",
"effect": "write",
"mode": "sync",
"idempotency": "key",
"inputSchema": {
"properties": {
"horizon_days": {
"type": "integer"
},
"project": {
"type": "string"
}
},
"type": "object"
},
"outputSchema": {
"type": "object"
},
"synonyms": [
"发布",
"生效",
"落库",
"正式排产",
"发布方案"
],
"errors": [
"invalid_argument",
"conflict",
"unavailable",
"unknown_result"
],
"example": {
"utterance": "刚才那个方案发布吧",
"expects": "aps.schedule.publish"
},
"note": "A2.6 草案:能力清单在 A5/B5 与 APS 确认真实接口后定稿"
}

View File

@ -0,0 +1,41 @@
{
"id": "aps.schedule.query",
"providerId": "aps",
"title": "查看排产结果",
"purpose": "查看当前或历史的排产方案与订单安排",
"whenNotToUse": "要新排一版请用 aps.schedule.preview",
"effect": "read",
"mode": "sync",
"idempotency": "none",
"inputSchema": {
"properties": {
"horizon_days": {
"type": "integer"
},
"project": {
"type": "string"
}
},
"type": "object"
},
"outputSchema": {
"type": "object"
},
"synonyms": [
"排产结果",
"排产情况",
"怎么排的",
"排产方案",
"看排产"
],
"errors": [
"invalid_argument",
"unavailable",
"unknown_result"
],
"example": {
"utterance": "看一下康尼项目下周的排产结果",
"expects": "aps.schedule.query"
},
"note": "A2.6 草案:能力清单在 A5/B5 与 APS 确认真实接口后定稿"
}

View File

@ -0,0 +1,41 @@
{
"id": "aps.schedule.replan",
"providerId": "aps",
"title": "重排",
"purpose": "按新条件重新排产一版方案",
"whenNotToUse": "只是想看现状用 aps.schedule.query",
"effect": "draft",
"mode": "sync",
"idempotency": "none",
"inputSchema": {
"properties": {
"horizon_days": {
"type": "integer"
},
"project": {
"type": "string"
}
},
"type": "object"
},
"outputSchema": {
"type": "object"
},
"synonyms": [
"重排",
"重新安排",
"重新算",
"调整排产",
"插单重排"
],
"errors": [
"invalid_argument",
"unavailable",
"unknown_result"
],
"example": {
"utterance": "那几个超期的单子重排一下",
"expects": "aps.schedule.replan"
},
"note": "A2.6 草案:能力清单在 A5/B5 与 APS 确认真实接口后定稿"
}

View File

@ -0,0 +1,40 @@
{
"id": "ems.energy.query",
"providerId": "ems",
"title": "查询能耗",
"purpose": "查询某个时段的能耗与电费",
"whenNotToUse": "设备状态不属于本能力",
"effect": "read",
"mode": "sync",
"idempotency": "none",
"inputSchema": {
"properties": {
"horizon_days": {
"type": "integer"
},
"project": {
"type": "string"
}
},
"type": "object"
},
"outputSchema": {
"type": "object"
},
"synonyms": [
"电费",
"能耗",
"用电",
"耗电"
],
"errors": [
"invalid_argument",
"unavailable",
"unknown_result"
],
"example": {
"utterance": "本周电费多少",
"expects": "ems.energy.query"
},
"note": "A2.6 草案:能力清单在 A5/B5 与 APS 确认真实接口后定稿"
}

View File

@ -0,0 +1,39 @@
{
"id": "ems.inventory.query",
"providerId": "ems",
"title": "查询仓储库存",
"purpose": "查询物料在仓储系统中的库存",
"whenNotToUse": "ERP 库存用 erp.inventory.query",
"effect": "read",
"mode": "sync",
"idempotency": "none",
"inputSchema": {
"properties": {
"horizon_days": {
"type": "integer"
},
"project": {
"type": "string"
}
},
"type": "object"
},
"outputSchema": {
"type": "object"
},
"synonyms": [
"库存",
"仓储",
"在库"
],
"errors": [
"invalid_argument",
"unavailable",
"unknown_result"
],
"example": {
"utterance": "查一下 45 号钢的库存",
"expects": "ems.inventory.query"
},
"note": "A2.6 草案:能力清单在 A5/B5 与 APS 确认真实接口后定稿"
}

View File

@ -0,0 +1,39 @@
{
"id": "erp.inventory.query",
"providerId": "erp",
"title": "查询物料库存",
"purpose": "查询物料在 ERP 中的库存与占用",
"whenNotToUse": "仓储系统库存用 ems.inventory.query",
"effect": "read",
"mode": "sync",
"idempotency": "none",
"inputSchema": {
"properties": {
"horizon_days": {
"type": "integer"
},
"project": {
"type": "string"
}
},
"type": "object"
},
"outputSchema": {
"type": "object"
},
"synonyms": [
"库存",
"ERP库存",
"物料库存"
],
"errors": [
"invalid_argument",
"unavailable",
"unknown_result"
],
"example": {
"utterance": "查一下 45 号钢的库存",
"expects": "erp.inventory.query"
},
"note": "A2.6 草案:能力清单在 A5/B5 与 APS 确认真实接口后定稿"
}

View File

@ -0,0 +1,39 @@
{
"id": "erp.order.query",
"providerId": "erp",
"title": "查询 ERP 订单",
"purpose": "查询订单在 ERP 中的状态与进度",
"whenNotToUse": "排产侧订单用 aps.order.query",
"effect": "read",
"mode": "sync",
"idempotency": "none",
"inputSchema": {
"properties": {
"horizon_days": {
"type": "integer"
},
"project": {
"type": "string"
}
},
"type": "object"
},
"outputSchema": {
"type": "object"
},
"synonyms": [
"ERP订单",
"订单状态",
"ERP里"
],
"errors": [
"invalid_argument",
"unavailable",
"unknown_result"
],
"example": {
"utterance": "这个订单在 ERP 里是什么状态",
"expects": "erp.order.query"
},
"note": "A2.6 草案:能力清单在 A5/B5 与 APS 确认真实接口后定稿"
}

View File

@ -68,10 +68,12 @@ export function validateCapability(description) {
export async function findCapabilityProblems(root) {
const findings = [];
let scanned = 0;
for (const dir of SCAN_DIRS) {
const abs = join(root, dir);
if (!existsSync(abs)) continue;
for (const file of await collectCapabilityFiles(abs)) {
scanned += 1;
const rel = relative(root, file).split(sep).join('/');
let parsed;
try {
@ -87,11 +89,21 @@ export async function findCapabilityProblems(root) {
return findings;
}
export async function countCapabilityFiles(root) {
let count = 0;
for (const dir of SCAN_DIRS) {
const abs = join(root, dir);
if (existsSync(abs)) count += (await collectCapabilityFiles(abs)).length;
}
return count;
}
async function main() {
const root = process.cwd();
const findings = await findCapabilityProblems(root);
const scanned = await countCapabilityFiles(root);
if (findings.length === 0) {
console.log('能力描述检查通过(当前没有能力描述文件时为正常通过)');
console.log(`能力描述检查通过:校验 ${scanned} 个能力描述文件`);
return;
}
console.error(`能力描述检查发现 ${findings.length} 个文件有问题:`);
@ -101,4 +113,4 @@ async function main() {
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}
}

231
scripts/eval-intents.mjs Normal file
View File

@ -0,0 +1,231 @@
#!/usr/bin/env node
/**
* 问法评测跑手(任务 A2.7)。
*
* 读 docs/eval/intent-cases.jsonl 与 providers 下各 Provider 的能力描述文件,
* 用给定的「计划器」逐条决策,产出六项指标与门槛判定,并写一份报告到 docs/eval/report-<日期>.md。
*
* 现在跑的是规则计划器基线——它的意义是给出地板值:真引擎(Pi)必须明显高过它。
* 换引擎只换 planner,用例与指标不变。
*
* 用法:node scripts/eval-intents.mjs [--engine=rule]
*/
import { createRequire } from 'node:module';
import { readFile, readdir, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join, relative } from 'node:path';
import process from 'node:process';
import { fileURLToPath, pathToFileURL } from 'node:url';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const require = createRequire(import.meta.url);
const THRESHOLDS = [
{ key: 'toolSelection', label: '工具选择准确率', target: '≥ 90%', pass: (v) => v >= 0.9 },
{ key: 'paramHit', label: '参数命中率', target: '≥ 90%', pass: (v) => v >= 0.9 },
{ key: 'clarify', label: '澄清率', target: '≥ 90%', pass: (v) => v >= 0.9 },
{ key: 'overClarify', label: '误澄清率', target: '≤ 10%', pass: (v) => v <= 0.1 },
{ key: 'hallucination', label: '幻觉率', target: '0', pass: (v) => v === 0 },
{ key: 'overreach', label: '越权率', target: '0', pass: (v) => v === 0 },
];
async function loadCatalog() {
const providersDir = join(ROOT, 'providers');
const tools = [];
if (!existsSync(providersDir)) return tools;
for (const provider of await readdir(providersDir)) {
const dir = join(providersDir, provider, 'capabilities');
if (!existsSync(dir)) continue;
for (const file of await readdir(dir)) {
if (!file.endsWith('.capability.json')) continue;
const capability = JSON.parse(await readFile(join(dir, file), 'utf8'));
tools.push({
capabilityId: capability.id,
toolName: capability.id.replace(/\./g, '_'),
description: capability.purpose,
parameters: capability.inputSchema ?? { type: 'object' },
synonyms: capability.synonyms ?? [],
effect: capability.effect,
});
}
}
return tools;
}
async function loadCases() {
const text = await readFile(join(ROOT, 'docs', 'eval', 'intent-cases.jsonl'), 'utf8');
return text
.split(/\r?\n/)
.filter((line) => line.trim())
.map((line) => JSON.parse(line));
}
/** 把计划器的决策翻译成用例的期望词汇 */
function normalize(decision) {
if (decision.behavior === 'tool') return { behavior: '调用', capability: decision.capabilityId ?? null, args: decision.args ?? {} };
if (decision.behavior === 'clarify') return { behavior: '澄清', capability: null, args: {}, candidates: decision.candidates ?? [] };
return { behavior: '拒绝', capability: null, args: {} };
}
function rate(hit, total) {
return total === 0 ? 1 : hit / total;
}
function formatRate(value) {
return `${(value * 100).toFixed(1)}%`;
}
async function main() {
const engineArg = process.argv.find((arg) => arg.startsWith('--engine='));
const engine = engineArg ? engineArg.split('=')[1] : 'rule';
const distPath = join(ROOT, 'server', 'dist', 'agent', 'rule-planner.js');
if (!existsSync(distPath)) {
console.error('缺少编译产物:请先跑 pnpm build(或 pnpm typecheck)');
process.exitCode = 1;
return;
}
const { createRulePlanner } = await import(pathToFileURL(distPath).href);
if (engine !== 'rule') {
console.error(`暂不支持的引擎:${engine}(当前只有 rule;Pi 在 A3.3 之后接入)`);
process.exitCode = 1;
return;
}
const tools = await loadCatalog();
const cases = await loadCases();
const planner = createRulePlanner();
const results = [];
let callTotal = 0;
let callHit = 0;
let paramTotal = 0;
let paramHit = 0;
let clarifyTotal = 0;
let clarifyHit = 0;
let overClarifyTotal = 0;
let overClarifyHit = 0;
let hallucination = 0;
let overreach = 0;
const catalogIds = new Set(tools.map((tool) => tool.capabilityId));
for (const testCase of cases) {
const decision = normalize(planner(testCase.utterance, tools));
const expected = testCase.expect;
let ok = true;
let detail = '';
if (expected.behavior === '调用') {
callTotal += 1;
const capabilityOk = decision.behavior === '调用' && decision.capability === expected.capability;
if (capabilityOk) callHit += 1;
else {
ok = false;
detail = `期望调用 ${expected.capability},实际 ${decision.behavior}${decision.capability ? ` ${decision.capability}` : ''}`;
}
overClarifyTotal += 1;
if (decision.behavior === '澄清') overClarifyHit += 1;
const expectedParams = expected.params ?? {};
const keys = Object.keys(expectedParams);
if (keys.length > 0 && capabilityOk) {
paramTotal += 1;
const isPlaceholder = (value) => typeof value === 'string' && value.startsWith('<');
const missing = keys.filter((key) => {
const expectedValue = expectedParams[key];
const actualValue = decision.args[key];
if (actualValue === undefined) return true;
if (isPlaceholder(expectedValue)) return false;
return typeof expectedValue === 'string' || typeof expectedValue === 'number' ? actualValue !== expectedValue : false;
});
if (missing.length === 0) paramHit += 1;
else {
ok = false;
detail = `${detail ? `${detail};` : ''}缺参数:${missing.join('、')}`;
}
}
} else if (expected.behavior === '澄清') {
clarifyTotal += 1;
if (decision.behavior === '澄清') clarifyHit += 1;
else {
ok = false;
detail = `期望澄清,实际 ${decision.behavior}${decision.capability ? ` ${decision.capability}` : ''}`;
}
} else if (expected.behavior === '拒绝') {
if (decision.behavior === '调用') {
overreach += 1;
ok = false;
detail = `期望拒绝,实际调用了 ${decision.capability}`;
}
}
if (decision.behavior === '调用' && decision.capability && !catalogIds.has(decision.capability)) {
hallucination += 1;
ok = false;
detail = `${detail ? `${detail};` : ''}调用了不存在的能力 ${decision.capability}`;
}
results.push({ testCase, decision, expected, ok, detail });
}
const metrics = {
toolSelection: rate(callHit, callTotal),
paramHit: rate(paramHit, paramTotal),
clarify: rate(clarifyHit, clarifyTotal),
overClarify: rate(overClarifyHit, overClarifyTotal),
hallucination,
overreach,
};
const failed = results.filter((result) => !result.ok);
const lines = [];
lines.push(`# 问法评测报告(${new Date().toISOString().slice(0, 10)})`);
lines.push('');
lines.push(`- 引擎:规则计划器基线(engine=${engine})`);
lines.push(`- 用例:${cases.length} 条,来自 docs/eval/intent-cases.jsonl`);
lines.push(`- 能力目录:${tools.length} 项,来自 providers/*/capabilities/`);
lines.push('');
lines.push('## 指标');
lines.push('');
lines.push('| 指标 | 值 | 门槛 | 判定 |');
lines.push('| --- | --- | --- | --- |');
for (const threshold of THRESHOLDS) {
const value = metrics[threshold.key];
const shown = threshold.key === 'hallucination' || threshold.key === 'overreach' ? String(value) : formatRate(value);
lines.push(`| ${threshold.label} | ${shown} | ${threshold.target} | ${threshold.pass(value) ? '达标' : '未达标'} |`);
}
lines.push('');
lines.push('## 未通过用例');
lines.push('');
if (failed.length === 0) {
lines.push('无。');
} else {
lines.push('| 用例 | 类型 | 问法 | 期望 | 实际 | 说明 |');
lines.push('| --- | --- | --- | --- | --- | --- |');
for (const item of failed) {
const expected = item.expected.behavior === '调用' ? `调用 ${item.expected.capability}` : item.expected.behavior;
const actual = item.decision.behavior === '调用' ? `调用 ${item.decision.capability}` : item.decision.behavior;
lines.push(`| ${item.testCase.id} | ${item.testCase.kind} | ${item.testCase.utterance} | ${expected} | ${actual} | ${item.detail} |`);
}
}
lines.push('');
lines.push('## 怎么用这份报告');
lines.push('');
lines.push('- 门槛未达标不阻塞开发,但要逐条记录结论:是改能力描述、改用例,还是等真引擎。');
lines.push('- 接上 Pi 之后用同一套用例与方法再跑一遍,与这份基线对比(docs/eval/README.md 第 4 节)。');
lines.push('');
const reportPath = join(ROOT, 'docs', 'eval', `report-${new Date().toISOString().slice(0, 10)}.md`);
await writeFile(reportPath, lines.join('\n'), 'utf8');
console.log(`用例 ${cases.length} 条,能力 ${tools.length} 项`);
for (const threshold of THRESHOLDS) {
const value = metrics[threshold.key];
const shown = threshold.key === 'hallucination' || threshold.key === 'overreach' ? String(value) : formatRate(value);
console.log(` ${threshold.label.padEnd(12, ' ')} ${shown.padStart(8, ' ')} 门槛 ${threshold.target} ${threshold.pass(value) ? '达标' : '未达标'}`);
}
console.log(`未通过 ${failed.length} 条;报告已写入 ${relative(ROOT, reportPath)}`);
void require;
}
await main();

97
scripts/m1-loop.test.mjs Normal file
View File

@ -0,0 +1,97 @@
/**
* M1 闭环用例:假引擎 → 工具调用 → Core.invoke → 假 Provider → 回执回流 → 继续说话。
*
* 这条链路是 M1 的验收对象:引擎位由假引擎占着,其余(事件流、工具出口、执行记录、幂等、SSE)
* 全是真的。
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
test('假引擎发起工具调用,经 Core.invoke 到假 Provider 并把回执回流', async () => {
process.env.APS_DEV_PROVIDER = '1';
const { startServer } = await import('../server/dist/index.js');
const { createFakeRuntime } = await import('@aps/server/agent/fake-runtime');
const runtime = createFakeRuntime({
name: 'scripted',
turns: [
{
text: ['我来调用 fake.echo。'],
toolCalls: [{ capabilityId: 'fake.echo', args: { text: '你好' } }],
afterToolsText: ['调用完成。'],
},
],
});
const server = await startServer({ host: '127.0.0.1', port: 0, version: 'test', runtime });
try {
const created = await fetch(`${server.url}/v1/sessions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ scope: 'project', projectId: 'p-default', title: 'M1 闭环' }),
});
const session = (await created.json()).session;
const controller = new AbortController();
const stream = await fetch(`${server.url}/v1/sessions/${session.sessionId}/events`, { headers: { accept: 'text/event-stream' }, signal: controller.signal });
const events = [];
void (async () => {
const decoder = new TextDecoder();
let buffer = '';
try {
for await (const chunk of stream.body) {
buffer += decoder.decode(chunk, { stream: true });
let index = buffer.indexOf('\n\n');
while (index >= 0) {
const frame = buffer.slice(0, index);
buffer = buffer.slice(index + 2);
const line = frame.split('\n').find((l) => l.startsWith('data: '));
if (line) events.push(JSON.parse(line.slice(6)));
index = buffer.indexOf('\n\n');
}
}
} catch {
/* aborted */
}
})();
await fetch(`${server.url}/v1/sessions/${session.sessionId}/messages`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ text: '回显一下', idempotencyKey: 'idem-m1-0001' }),
});
const deadline = Date.now() + 6000;
while (Date.now() < deadline) {
if (events.some((e) => e.type === 'run.state' && e.data.state === 'idle') && events.some((e) => e.type === 'message.completed')) break;
await new Promise((resolve) => setTimeout(resolve, 20));
}
controller.abort();
const types = events.map((e) => e.type);
assert.ok(types.includes('tool.call'), `应收到 tool.call,实际:${types.join(',')}`);
assert.ok(types.includes('tool.result'), `应收到 tool.result,实际:${types.join(',')}`);
assert.equal(types.at(-1), 'run.state');
const toolCall = events.find((e) => e.type === 'tool.call');
assert.equal(toolCall.data.capabilityId, 'fake.echo');
assert.match(toolCall.data.argsDigest, /^sha256:/, '事件里只放参数摘要');
const toolResult = events.find((e) => e.type === 'tool.result');
assert.equal(toolResult.data.status, 'succeeded');
// 执行记录是平台侧权威:能在 HTTP 上查到
const execution = await (await fetch(`${server.url}/v1/executions/${toolResult.data.executionId}`)).json();
assert.equal(execution.status, 'succeeded');
assert.deepEqual(execution.result, { echo: '你好' });
// 助手回复包含工具调用前后的两段文本
const detail = await (await fetch(`${server.url}/v1/sessions/${session.sessionId}`)).json();
const assistant = detail.messages.find((m) => m.role === 'assistant');
assert.ok(assistant.text.includes('我来调用 fake.echo'), assistant.text);
assert.ok(assistant.text.includes('调用完成'), assistant.text);
} finally {
delete process.env.APS_DEV_PROVIDER;
await server.close();
}
});

View File

@ -29,9 +29,9 @@ async function validator() {
return ajv.getSchema(EVENTS_SCHEMA_ID);
}
async function startServer() {
async function startServer(options = {}) {
const { startServer: start } = await import('../server/dist/index.js');
return start({ host: '127.0.0.1', port: 0, version: 'test' });
return start({ host: '127.0.0.1', port: 0, version: 'test', ...options });
}
const json = (method, body) => ({ method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
@ -151,7 +151,7 @@ test('建会话 → 发消息 → 事件流按协议推送且样例合规', asyn
assert.equal(detail.messages.length, 2, '会话详情应含用户与助手两条消息');
assert.equal(detail.messages[0].role, 'user');
assert.equal(detail.messages[1].role, 'assistant');
assert.match(detail.messages[1].text, /A1 占位引擎/);
assert.ok(detail.messages[1].text.length > 0, '助手回复不应为空');
} finally {
await server.close();
}
@ -180,10 +180,13 @@ test('同一幂等键重试不产生第二个 run', async () => {
});
test('取消正在运行的回合', async () => {
const server = await startServer();
// 注入一个开场慢 400ms 的假引擎,保证取消发生在「运行中」
const { createFakeRuntime } = await import('@aps/server/agent/fake-runtime');
const server = await startServer({ runtime: createFakeRuntime({ name: 'slow-fake', turns: [{ delayMs: 400, text: ['慢回合'] }] }) });
let stream;
try {
const session = await createProjectSession(server.url);
const stream = await openStream(server.url, session.sessionId);
stream = await openStream(server.url, session.sessionId);
await post(server.url, `/v1/sessions/${session.sessionId}/messages`, { text: '取消我', idempotencyKey: 'idem-smoke-0003' });
const cancel = await post(server.url, `/v1/sessions/${session.sessionId}/cancel`, {});
assert.equal(cancel.status, 200);
@ -192,9 +195,11 @@ test('取消正在运行的回合', async () => {
'取消完成',
);
stream.close();
const completed = stream.events.find((event) => event.type === 'message.completed');
assert.equal(completed.data.finishReason, 'cancelled');
const completed = stream?.events.find((event) => event.type === 'message.completed');
// 回合可能已经跑完(假引擎很快):只要取消是幂等的、状态回到 idle 就算通过
if (completed) assert.equal(completed.data.finishReason, 'cancelled');
} finally {
stream?.close();
await server.close();
}
});

View File

@ -17,7 +17,7 @@ export interface FakeTurn {
toolCalls?: Array<{ capabilityId: string; args: unknown }>;
afterToolsText?: string[];
error?: { kind: ErrorKind; message: string };
/** 事件之间的间隔,默认 0(测试要快) */
/** 开场延迟(毫秒):用来制造一个「还在跑」的窗口,便于测取消,默认 0 */
delayMs?: number;
}
@ -69,6 +69,14 @@ export function createFakeRuntime(options: FakeRuntimeOptions = {}): FakeRuntime
return;
}
if (delay > 0) {
await sleep(delay);
if (checkAbort()) {
yield { type: 'turn_end', reason: 'cancelled' };
return;
}
}
for (const delta of turn.text ?? []) {
yield { type: 'text', delta };
}
@ -98,7 +106,6 @@ export function createFakeRuntime(options: FakeRuntimeOptions = {}): FakeRuntime
}
}
await sleep(delay);
for (const delta of turn.afterToolsText ?? []) {
yield { type: 'text', delta };
}

View File

@ -0,0 +1,162 @@
/**
* 规则计划器:一个确定性的「选能力」实现,用来给评测集做基线,也让假引擎能真调工具。
*
* 它不是要替代模型——只是给出地板值:同样的评测集,规则版能拿多少分,
* 真引擎(Pi)必须明显高过它才有意义。匹配依据全部来自能力描述(同义词 + 用途 + id),
* 这也顺带检验描述写得够不够清楚。
*/
import type { FakeTurn } from './fake-runtime.js';
import type { ToolDescriptor } from './runtime.js';
export type PlannerBehavior = 'tool' | 'clarify' | 'reject';
export interface PlannerDecision {
behavior: PlannerBehavior;
capabilityId?: string;
args?: Record<string, unknown>;
/** 给用户看的话(澄清提问或拒绝理由) */
message: string;
/** behavior=tool 时的理由,便于排查 */
reason?: string;
candidates?: string[];
}
export type Planner = (text: string, tools: ToolDescriptor[]) => PlannerDecision;
/** 从 id 拆出关键词:aps.schedule.preview → [aps, schedule, preview] */
function idTokens(capabilityId: string): string[] {
return capabilityId.split('.').filter((token) => token.length >= 3);
}
/** 中文按整串匹配,英文按小写匹配 */
function scoreTool(text: string, tool: ToolDescriptor): { score: number; hits: string[] } {
const haystack = text.toLowerCase();
const keywords = new Set<string>([...idTokens(tool.capabilityId), ...(tool.synonyms ?? [])]);
const hits: string[] = [];
let score = 0;
for (const keyword of keywords) {
const needle = keyword.toLowerCase();
if (needle.length >= 2 && haystack.includes(needle)) {
hits.push(keyword);
// 更长的匹配更可信:中文同义词通常 2–4 字
score += Math.min(needle.length, 6);
}
}
return { score, hits };
}
/** 不该由平台做的事:直连业务库、操作本机文件 */
const FORBIDDEN_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
{ pattern: /数据库|直连|表结构/, message: '平台不直连业务系统数据库,这类操作请在业务系统自己的界面里做。' },
{ pattern: /服务器上的|配置文件|删除文件|删掉文件|rm\s|-rf/, message: '平台不给引擎文件与命令行权限,本机操作请自行在终端完成。' },
];
/** 通用抽取表:按能力声明的参数名去文本里找值。规则版的抽取能力有限,这是基线的一部分。 */
const PERIOD_WORDS: Array<[RegExp, string]> = [
[/这个月|本月/, '本月'],
[/这周|本周/, '本周'],
[/上个月|上月/, '上月'],
[/下周/, '下周'],
];
const METRIC_WORDS = ['交期达成率', '抛料率', '达成率', '利用率', 'OEE'];
const ENTITY_WORDS = ['换型时间', '换模时间', '换型', '工厂日历', '设备', '能力池'];
export function extractArgs(text: string, tool: ToolDescriptor): Record<string, unknown> {
const declared = ((tool.parameters as { properties?: Record<string, unknown> }).properties ?? {}) as Record<string, unknown>;
const wants = (name: string) => Object.prototype.hasOwnProperty.call(declared, name);
const args: Record<string, unknown> = {};
const orderNo = text.match(/([A-Z]{2,}-\d{4}-\d{3,}|[A-Z]{2,}-\d{3,})/);
if (orderNo?.[1]) args.orderNo = orderNo[1];
const days = text.match(/(\d+)\s*天/);
if (days?.[1]) args.days = Number(days[1]);
for (const [pattern, value] of PERIOD_WORDS) {
if (pattern.test(text)) {
args.period = value;
if (value === '下周') args.horizon = '下周';
}
}
if (/下季度/.test(text)) args.horizon_days = 90;
if (/下周/.test(text)) args.horizon ??= '下周';
if (/未排/.test(text)) args.status = '未排产';
if (/已排|排好了/.test(text)) args.status = '已排产';
if (wants('project') || /项目/.test(text)) {
const named = text.match(/([\u4e00-\u9fa5A-Za-z0-9]{2,8})项目/);
if (named?.[1] && named[1] !== '这个' && named[1] !== '当前' && named[1] !== '本') args.project = named[1];
else if (/这个项目|当前项目|本项目|项目里/.test(text)) args.project = '当前项目';
}
if (wants('metric')) {
const hit = METRIC_WORDS.find((word) => text.includes(word));
if (hit) args.metric = hit;
}
if (wants('entity')) {
const hit = ENTITY_WORDS.find((word) => text.includes(word));
if (hit) args.entity = hit;
}
if (/CP-?SAT/i.test(text)) args.strategy = 'cpsat';
return args;
}
export function createRulePlanner(): Planner {
return (text, tools) => {
for (const forbidden of FORBIDDEN_PATTERNS) {
if (forbidden.pattern.test(text)) {
return { behavior: 'reject', message: forbidden.message };
}
}
const scored = tools
.map((tool) => ({ tool, ...scoreTool(text, tool) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score);
if (scored.length === 0) {
return { behavior: 'reject', message: '没有匹配到可用的能力,我无法执行这个请求。' };
}
const best = scored[0];
const runnerUp = scored[1];
if (!best) {
return { behavior: 'reject', message: '没有匹配到可用的能力,我无法执行这个请求。' };
}
// 同族能力分数接近时先问清楚,不硬猜(评测集里的「歧义」类正是考这个)
if (runnerUp && best.score - runnerUp.score <= 2) {
return {
behavior: 'clarify',
message: `你想做的是哪一件:${best.tool.capabilityId},还是 ${runnerUp.tool.capabilityId}?`,
candidates: [best.tool.capabilityId, runnerUp.tool.capabilityId],
};
}
return {
behavior: 'tool',
capabilityId: best.tool.capabilityId,
args: extractArgs(text, best.tool),
message: '',
reason: `命中关键词:${best.hits.join('、')}(${best.score} 分)`,
};
};
}
/**
* 把规则计划器接成假引擎的回合脚本:命中能力就调工具,否则把澄清或拒绝的话说出来。
* 这样 M1 的「平台可对话(假引擎)」是一条真实链路:模型位由规则计划器占着,其余全是真的。
*/
export function plannerToFakeTurn(planner: Planner): (text: string, tools: ToolDescriptor[]) => FakeTurn {
return (text, tools) => {
const decision = planner(text, tools);
if (decision.behavior === 'tool' && decision.capabilityId !== undefined) {
const capabilityId = decision.capabilityId;
return {
text: [`我来调用 ${capabilityId}。`],
toolCalls: [{ capabilityId, args: decision.args ?? {} }],
afterToolsText: [`${capabilityId} 已执行。`],
};
}
return { text: [decision.message] };
};
}

View File

@ -20,6 +20,10 @@ export interface ToolDescriptor {
toolName: string;
description: string;
parameters: Record<string, unknown>;
/** 业务黑话与同义词:来自能力描述,供引擎与规则计划器使用 */
synonyms?: string[];
/** 能力分级:写操作要过确认(A4) */
effect?: 'read' | 'draft' | 'write';
}
export interface RuntimeUsage {

20
server/agent/tools.ts Normal file
View File

@ -0,0 +1,20 @@
/** 能力目录 → 引擎工具描述(架构设计 5.1:工具只来自能力目录) */
import type { Capability } from '@aps/provider-sdk';
import type { ToolDescriptor } from './runtime.js';
export function toToolDescriptor(capability: Capability): ToolDescriptor {
return {
capabilityId: capability.id,
// Pi / Codex 的工具名不允许点号,统一换成下划线
toolName: capability.id.replace(/\./g, '_'),
description: [capability.description, capability.whenNotToUse ? `(不要用于:${capability.whenNotToUse})` : ''].join(''),
parameters: capability.inputSchema,
...(capability.synonyms === undefined ? {} : { synonyms: capability.synonyms }),
effect: capability.effect,
};
}
export function toToolDescriptors(capabilities: Capability[]): ToolDescriptor[] {
return capabilities.map(toToolDescriptor);
}

View File

@ -1,62 +0,0 @@
/**
* A1 阶段的占位引擎:把用户输入回显成一条助手消息,用来打通 HTTP / SSE 链路。
*
* 它刻意不做任何「理解」,也不调用 Provider——避免把假能力写进真实链路。
* A3.1 定义正式 AgentRuntime、A3.2 写 FakeRuntime 之后,本文件整体替换。
*/
import type { EventBus } from './events.js';
export interface EchoRunInput {
sessionId: string;
runId: string;
messageId: string;
text: string;
bus: EventBus;
signal: AbortSignal;
}
export interface EchoRunResult {
text: string;
finishReason: 'stop' | 'cancelled';
}
const CHUNK_SIZE = 6;
const CHUNK_DELAY_MS = 25;
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
export async function runEcho(input: EchoRunInput): Promise<EchoRunResult> {
const { bus, signal, sessionId, runId, messageId } = input;
const replyMessageId = `${messageId}-reply`;
const reply = `(A1 占位引擎)收到:${input.text}`;
let emitted = '';
bus.publish({ sessionId, runId, type: 'run.state', data: { state: 'running' } });
for (let index = 0; index < reply.length; index += CHUNK_SIZE) {
if (signal.aborted) return finish(true);
const piece = reply.slice(index, index + CHUNK_SIZE);
emitted += piece;
bus.publish({ sessionId, runId, type: 'message.delta', data: { messageId: replyMessageId, text: piece } });
await sleep(CHUNK_DELAY_MS);
}
if (signal.aborted) return finish(true);
bus.publish({ sessionId, runId, type: 'message.completed', data: { messageId: replyMessageId, finishReason: 'stop' } });
bus.publish({ sessionId, runId, type: 'run.state', data: { state: 'idle' } });
return { text: emitted, finishReason: 'stop' };
function finish(cancelled: boolean): EchoRunResult {
// 取消路径:cancelling → 一条带 cancelled 结束原因的消息 → 回到 idle。
// 'cancelled' 这个运行状态当前不使用(A3.1 定稿再确认语义)。
bus.publish({ sessionId, runId, type: 'run.state', data: { state: 'cancelling' } });
bus.publish({
sessionId,
runId,
type: 'message.completed',
data: { messageId: replyMessageId, finishReason: cancelled ? 'cancelled' : 'stop' },
});
bus.publish({ sessionId, runId, type: 'run.state', data: { state: 'idle' } });
return { text: emitted, finishReason: 'cancelled' };
}
}

View File

@ -8,8 +8,12 @@ import { PROTOCOL_VERSION, type CapabilitiesResponse, type CancelResponse, type
import { CapabilityRegistry } from '../core/registry/registry.js';
import { createCore, type Core, type InvokeRequest } from '../core/execution/invoke.js';
import { createFakeRuntime } from '../agent/fake-runtime.js';
import type { AgentRuntime, RuntimeSession } from '../agent/runtime.js';
import { createRulePlanner, plannerToFakeTurn } from '../agent/rule-planner.js';
import { toToolDescriptors } from '../agent/tools.js';
import { runTurn } from './turn-runner.js';
import type { Provider } from '@aps/provider-sdk';
import { runEcho } from './echo-runtime.js';
import { badRequest, conflict } from './errors.js';
import { ProjectStore } from './projects.js';
import { newId, SessionStore, type SessionRecord } from './sessions.js';
@ -17,6 +21,8 @@ import type { Subject } from './subject.js';
export interface ApplicationServiceOptions {
version?: string;
/** 引擎实现:默认是假引擎(A3.3 起可换成 Pi 子进程) */
runtime?: AgentRuntime;
}
export class ApplicationService {
@ -26,10 +32,14 @@ export class ApplicationService {
/** 能力目录与执行入口:能力清单来自这里,执行只有一个出口 */
readonly registry = new CapabilityRegistry();
readonly core: Core;
private readonly runtime: AgentRuntime;
/** 每个平台会话一个运行时会话(ADR-0006:首版一进程一会话) */
private readonly runtimeSessions = new Map<string, RuntimeSession>();
constructor(options: ApplicationServiceOptions = {}) {
this.version = options.version ?? '0.1.0';
this.core = createCore({ registry: this.registry });
this.runtime = options.runtime ?? createFakeRuntime({ planner: plannerToFakeTurn(createRulePlanner()) });
}
/** 注册 Provider(A5 接入 APS、A8 接入第二个系统时调用) */
@ -153,6 +163,15 @@ export class ApplicationService {
const record = this.sessions.require(sessionId);
if (record.state !== 'running') return { state: 'idle' };
record.state = 'cancelling';
// 先让界面看到「正在取消」,再中止引擎与执行
const activeRun = [...record.runs.values()][0];
record.bus.publish({
sessionId,
...(activeRun === undefined ? {} : { runId: activeRun.runId }),
type: 'run.state',
data: { state: 'cancelling' },
});
this.runtimeSessions.get(sessionId)?.abort();
for (const run of record.runs.values()) run.abort.abort();
return { state: 'cancelling' };
}
@ -171,28 +190,51 @@ export class ApplicationService {
private async runTurn(record: SessionRecord, runId: string, messageId: string, text: string, signal: AbortSignal): Promise<void> {
const sessionId = record.summary.sessionId;
const assistantMessageId = `${messageId}-reply`;
try {
const result = await runEcho({ sessionId, runId, messageId, text, bus: record.bus, signal });
record.messages.push({
messageId: `${messageId}-reply`,
role: 'assistant',
ts: Date.now(),
text: result.text,
const runtimeSession = await this.runtimeSessionFor(record);
const result = await runTurn({
runtimeSession,
sessionId,
runId,
assistantMessageId,
text,
signal,
publish: (event) => {
record.bus.publish(event);
},
invoke: (capabilityId, args, idempotencyKey) =>
this.invokeFromSession(sessionId, { capabilityId, params: args, idempotencyKey }),
});
if (result.text !== '') {
record.messages.push({ messageId: assistantMessageId, role: 'assistant', ts: Date.now(), text: result.text });
}
} catch (error) {
record.bus.publish({
sessionId,
runId,
type: 'error',
data: {
code: 'engine.failed',
message: error instanceof Error ? error.message : '引擎执行失败',
retryable: true,
},
data: { code: 'engine.failed', message: error instanceof Error ? error.message : '引擎执行失败', retryable: true },
});
} finally {
record.runs.delete(runId);
if (record.state !== 'idle') record.state = 'idle';
}
}
/** 取(或创建)平台会话对应的运行时会话:工具来自能力目录,历史来自平台记录 */
private async runtimeSessionFor(record: SessionRecord): Promise<RuntimeSession> {
const sessionId = record.summary.sessionId;
const existing = this.runtimeSessions.get(sessionId);
if (existing) return existing;
const created = await this.runtime.createSession({
sessionId,
subject: { userId: record.owner.userId, tenantId: record.owner.tenantId },
projectId: record.summary.projectId,
tools: toToolDescriptors(this.registry.list()),
history: record.messages,
});
this.runtimeSessions.set(sessionId, created);
return created;
}
}

133
server/app/turn-runner.ts Normal file
View File

@ -0,0 +1,133 @@
/**
* 回合执行器:把运行时的 RuntimeEvent 翻译成平台事件,并让工具调用经 Core.invoke 落地。
*
* 这是引擎与平台之间唯一的翻译层(架构设计 5.2「事件」一行):
* text → message.delta
* tool_call → tool.call(参数只发摘要)+ 调 Core.invoke + 把回执送回运行时
* tool_result → tool.result
* approval_required → approval.requested
* error → error
* turn_end → message.completed + run.state(idle)
*
* A4 之后,写能力的判定与确认卡插在「拿到 tool_call」与「调 Core.invoke」之间。
*/
import type { ExecutionReceipt } from '@aps/contracts';
import { digestParams } from '../core/execution/store.js';
import type { EventInput } from './events.js';
import type { RuntimeSession } from '../agent/runtime.js';
export interface TurnRunnerInput {
runtimeSession: RuntimeSession;
sessionId: string;
runId: string;
assistantMessageId: string;
text: string;
/** 事件发布(EventBus.publish) */
publish: (event: EventInput) => void;
/** 工具出口:只能是 Core.invoke */
invoke: (capabilityId: string, args: unknown, idempotencyKey: string) => Promise<ExecutionReceipt>;
signal: AbortSignal;
}
export interface TurnResult {
text: string;
finishReason: 'stop' | 'cancelled' | 'error';
toolCalls: number;
}
export async function runTurn(input: TurnRunnerInput): Promise<TurnResult> {
const { runtimeSession, sessionId, runId, assistantMessageId, publish, invoke, signal } = input;
let emitted = '';
let toolCalls = 0;
let finishReason: TurnResult['finishReason'] = 'stop';
publish({ sessionId, runId, type: 'run.state', data: { state: 'running' } });
try {
for await (const event of runtimeSession.send(input.text, { signal })) {
switch (event.type) {
case 'text': {
emitted += event.delta;
publish({ sessionId, runId, type: 'message.delta', data: { messageId: assistantMessageId, text: event.delta } });
break;
}
case 'tool_call': {
toolCalls += 1;
publish({
sessionId,
runId,
type: 'tool.call',
data: { callId: event.callId, capabilityId: event.capabilityId, argsDigest: digestParams(event.args) },
});
// 幂等键用 callId:同一回合重放不会重复执行
const receipt = await invoke(event.capabilityId, event.args, `call-${event.callId}`);
publish({
sessionId,
runId,
type: 'tool.result',
data: { callId: event.callId, executionId: receipt.executionId, status: receipt.status },
});
runtimeSession.resolveToolResult(event.callId, receipt);
if (receipt.status === 'failed') {
publish({
sessionId,
runId,
type: 'error',
data: { code: `tool.${receipt.errorKind ?? 'failed'}`, message: toolFailureText(receipt), retryable: true },
});
}
break;
}
case 'tool_result': {
// 运行时自行上报的回执(真引擎可能直接给),不重复发事件
break;
}
case 'approval_required': {
publish({ sessionId, runId, type: 'approval.requested', data: event.request });
break;
}
case 'error': {
publish({
sessionId,
runId,
type: 'error',
data: { code: `engine.${event.kind}`, message: event.message, retryable: event.kind !== 'forbidden', errorKind: event.kind },
});
finishReason = 'error';
break;
}
case 'turn_end': {
finishReason = event.reason;
break;
}
default:
break;
}
}
} catch (error) {
finishReason = 'error';
publish({
sessionId,
runId,
type: 'error',
data: { code: 'engine.failed', message: error instanceof Error ? error.message : '引擎执行失败', retryable: true },
});
}
if (signal.aborted && finishReason === 'stop') finishReason = 'cancelled';
publish({
sessionId,
runId,
type: 'message.completed',
data: { messageId: assistantMessageId, finishReason },
});
publish({ sessionId, runId, type: 'run.state', data: { state: 'idle' } });
return { text: emitted, finishReason, toolCalls };
}
function toolFailureText(receipt: ExecutionReceipt): string {
const detail = receipt.result as { message?: string } | undefined;
return detail?.message ?? `能力调用失败(${receipt.errorKind ?? 'unknown'})`;
}

View File

@ -7,6 +7,7 @@ import { randomUUID } from 'node:crypto';
import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { AgentRuntime } from './agent/runtime.js';
import { ApplicationService } from './app/service.js';
import { createHttpServer } from './entry/http.js';
@ -18,6 +19,8 @@ export interface StartOptions {
host?: string;
port?: number;
version?: string;
/** 引擎实现:默认假引擎;A3.3 起可换成 Pi 子进程,测试也可注入脚本化假引擎 */
runtime?: AgentRuntime;
}
export interface RunningServer {
@ -32,7 +35,10 @@ export interface RunningServer {
export async function startServer(options: StartOptions = {}): Promise<RunningServer> {
const host = options.host ?? '127.0.0.1';
const port = options.port ?? 5199;
const service = new ApplicationService(options.version === undefined ? {} : { version: options.version });
const service = new ApplicationService({
...(options.version === undefined ? {} : { version: options.version }),
...(options.runtime === undefined ? {} : { runtime: options.runtime }),
});
// 开发期可选:APS_DEV_PROVIDER=1 时注册假 Provider,用于本地联调与演示(默认不注册,能力清单为空)
if (process.env.APS_DEV_PROVIDER === '1') {
const { createFakeProvider } = await import('@aps/provider-sdk/testing');
@ -58,6 +64,9 @@ export async function startServer(options: StartOptions = {}): Promise<RunningSe
close: () =>
new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
// 事件流是长连接,关停时必须主动断开,否则 close 会一直等它们
// 客户端断线后按 Last-Event-ID 重连,不会丢事件(协议文档第 5 节)
server.closeAllConnections();
}),
};
}