@linnlabs/linnkit 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +84 -0
- package/LICENSE +21 -0
- package/README.md +178 -0
- package/README.zh-CN.md +182 -0
- package/dist/agent-invocation-BHcNfrBV.d.cts +30 -0
- package/dist/agent-invocation-BznDaXDs.d.ts +30 -0
- package/dist/agentEvents-DEB7Fy_J.d.cts +81 -0
- package/dist/agentEvents-DEB7Fy_J.d.ts +81 -0
- package/dist/agentSpec-EkmviZjy.d.cts +2621 -0
- package/dist/agentSpec-EkmviZjy.d.ts +2621 -0
- package/dist/ai-engine.types-BpeU_XQG.d.cts +158 -0
- package/dist/ai-engine.types-vZRnQcJa.d.ts +158 -0
- package/dist/audit-BaRUGaqv.d.cts +307 -0
- package/dist/audit-BaRUGaqv.d.ts +307 -0
- package/dist/audit-CtcfART1.d.ts +33 -0
- package/dist/audit-LeOrm2hX.d.cts +33 -0
- package/dist/checkpointMarker-DAI3wUQu.d.cts +8 -0
- package/dist/checkpointMarker-DAI3wUQu.d.ts +8 -0
- package/dist/cli.cjs +8028 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +4 -0
- package/dist/cli.d.ts +4 -0
- package/dist/cli.js +8025 -0
- package/dist/cli.js.map +1 -0
- package/dist/context-manager.cjs +8704 -0
- package/dist/context-manager.cjs.map +1 -0
- package/dist/context-manager.d.cts +2190 -0
- package/dist/context-manager.d.ts +2190 -0
- package/dist/context-manager.js +8650 -0
- package/dist/context-manager.js.map +1 -0
- package/dist/context-trace-DRi5M4lX.d.ts +239 -0
- package/dist/context-trace-HE2qY5Q-.d.cts +239 -0
- package/dist/contracts.cjs +1333 -0
- package/dist/contracts.cjs.map +1 -0
- package/dist/contracts.d.cts +8 -0
- package/dist/contracts.d.ts +8 -0
- package/dist/contracts.js +1214 -0
- package/dist/contracts.js.map +1 -0
- package/dist/defaultGraphExecutor-BBswR8wn.d.ts +624 -0
- package/dist/defaultGraphExecutor-BIjJj7WF.d.cts +624 -0
- package/dist/execution-CAIypb41.d.cts +129 -0
- package/dist/execution-CAIypb41.d.ts +129 -0
- package/dist/index-CHqwkvGp.d.ts +149 -0
- package/dist/index-CJeWHopy.d.ts +584 -0
- package/dist/index-Cm-JbzTH.d.cts +1450 -0
- package/dist/index-Cvr23YCl.d.cts +23 -0
- package/dist/index-DDzuSb0n.d.ts +23 -0
- package/dist/index-DO4dQgf2.d.cts +584 -0
- package/dist/index-DRBWi1fy.d.ts +1450 -0
- package/dist/index-Dl5PLgAv.d.cts +149 -0
- package/dist/index.cjs +9577 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +89 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +9563 -0
- package/dist/index.js.map +1 -0
- package/dist/messages-XthmnHZ3.d.cts +8007 -0
- package/dist/messages-XthmnHZ3.d.ts +8007 -0
- package/dist/ports-DaatKJXp.d.cts +90 -0
- package/dist/ports-DnLuKfpE.d.ts +90 -0
- package/dist/ports.cjs +4 -0
- package/dist/ports.cjs.map +1 -0
- package/dist/ports.d.cts +7 -0
- package/dist/ports.d.ts +7 -0
- package/dist/ports.js +3 -0
- package/dist/ports.js.map +1 -0
- package/dist/quickstart.cjs +7697 -0
- package/dist/quickstart.cjs.map +1 -0
- package/dist/quickstart.d.cts +24 -0
- package/dist/quickstart.d.ts +24 -0
- package/dist/quickstart.js +7691 -0
- package/dist/quickstart.js.map +1 -0
- package/dist/runAgent-CPj_9e58.d.ts +88 -0
- package/dist/runAgent-HYKlXbVr.d.cts +88 -0
- package/dist/runHandle-CyXvzgzk.d.ts +239 -0
- package/dist/runHandle-D3gPsD7B.d.cts +239 -0
- package/dist/runtime-kernel/events.cjs +1485 -0
- package/dist/runtime-kernel/events.cjs.map +1 -0
- package/dist/runtime-kernel/events.d.cts +8 -0
- package/dist/runtime-kernel/events.d.ts +8 -0
- package/dist/runtime-kernel/events.js +1475 -0
- package/dist/runtime-kernel/events.js.map +1 -0
- package/dist/runtime-kernel.cjs +8656 -0
- package/dist/runtime-kernel.cjs.map +1 -0
- package/dist/runtime-kernel.d.cts +19 -0
- package/dist/runtime-kernel.d.ts +19 -0
- package/dist/runtime-kernel.js +8568 -0
- package/dist/runtime-kernel.js.map +1 -0
- package/dist/sse-vPyrOPa0.d.cts +1687 -0
- package/dist/sse-vPyrOPa0.d.ts +1687 -0
- package/dist/testkit.cjs +10613 -0
- package/dist/testkit.cjs.map +1 -0
- package/dist/testkit.d.cts +284 -0
- package/dist/testkit.d.ts +284 -0
- package/dist/testkit.js +10593 -0
- package/dist/testkit.js.map +1 -0
- package/dist/todo-B1PmDlp3.d.cts +2253 -0
- package/dist/todo-B1PmDlp3.d.ts +2253 -0
- package/dist/tokenizer-DFL4I7-I.d.ts +28 -0
- package/dist/tokenizer-DH_JXv-H.d.cts +28 -0
- package/dist/toolContracts-Blll0241.d.ts +463 -0
- package/dist/toolContracts-CLkQmhTG.d.cts +463 -0
- package/docs/README.md +76 -0
- package/docs/integration/01-installation.md +94 -0
- package/docs/integration/02-quickstart.md +104 -0
- package/docs/integration/README.md +223 -0
- package/docs/integration/agent-registration-guide.md +330 -0
- package/docs/integration/audit.md +64 -0
- package/docs/integration/child-runs.md +87 -0
- package/docs/integration/constraints-and-pitfalls.md +87 -0
- package/docs/integration/context-engineering.md +650 -0
- package/docs/integration/context-fences.md +289 -0
- package/docs/integration/glossary.md +69 -0
- package/docs/integration/llm-provider.md +76 -0
- package/docs/integration/persistence.md +44 -0
- package/docs/integration/realtime.md +76 -0
- package/docs/integration/run-supervisor.md +69 -0
- package/docs/integration/telemetry.md +48 -0
- package/docs/integration/testing.md +95 -0
- package/docs/integration/tool-development-guide.md +362 -0
- package/docs/integration/tool-history.md +202 -0
- package/docs/integration/tools.md +188 -0
- package/package.json +115 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Telemetry · 接 TelemetryPort(可选)
|
|
2
|
+
|
|
3
|
+
> **What** · `TelemetryPort` 接入 —— LLM 调用耗时 / token usage / tool 时延上报(含 `runId` / `parentRunId` 用于成本聚合)。
|
|
4
|
+
> **When to read** · 接 Datadog / OpenTelemetry / Prometheus;做成本预警;监控 P99 时延;做多租户用量统计。
|
|
5
|
+
> **Prerequisites** · [`02-quickstart.md`](./02-quickstart.md)。
|
|
6
|
+
> **Key exports** · `TelemetryPort` / `telemetry` namespace + `withLLMTelemetryContext` from `@linnlabs/linnkit/runtime-kernel`。
|
|
7
|
+
> **Related** · [`audit.md`](./audit.md) · [`run-supervisor.md`](./run-supervisor.md) · [`testing.md`](./testing.md)(`createMockTelemetryPort`)
|
|
8
|
+
|
|
9
|
+
## 1. linnkit 给你的合同
|
|
10
|
+
|
|
11
|
+
- `TelemetryPort`(来自 `@linnlabs/linnkit/runtime-kernel`,在 `telemetry` namespace 下):`emit(event)` + 可选 `flush()`。
|
|
12
|
+
- `TelemetryEvent` / `TelemetryEventKind` / `TelemetryScope`(同上):4 类 kind(`llm_call` / `tool_call` / `graph_node` / `run_lifecycle`)的事实 schema。
|
|
13
|
+
- `withLLMTelemetryContext`(来自 `@linnlabs/linnkit` 根入口):把 run 作用域的 telemetry context 通过 AsyncLocalStorage 挂上去,避免跨异步边界丢 trace。
|
|
14
|
+
|
|
15
|
+
## 2. linnkit 自带的 mock primitive
|
|
16
|
+
|
|
17
|
+
- `noopTelemetry`(从 `runtimeKernel.telemetry` namespace 取):默认无副作用实现,写测试时直接当 placeholder。
|
|
18
|
+
- `createMockTelemetryPort()`(来自 `@linnlabs/linnkit/testkit`):按 `scope.runId ?? scope.turnId` 收集 telemetry,并提供可被 `RunHandle.cost()` 读取的 `RunCostCollector`。
|
|
19
|
+
|
|
20
|
+
## 3. 你必须做的
|
|
21
|
+
|
|
22
|
+
1. 决定 telemetry 落到哪:日志、指标、tracing 管道、host 自家 telemetry sink。
|
|
23
|
+
2. 把 `TelemetryPort` 作为可选能力接入 runtime-assembly。
|
|
24
|
+
3. 用 `withLLMTelemetryContext(scope, () => ...)` 把每次 run 包起来,让 LLM 调用、tool 调用都自动继承 scope。
|
|
25
|
+
|
|
26
|
+
## 4. 你不要做的
|
|
27
|
+
|
|
28
|
+
- 不要把 telemetry 直接和 UI 事件流绑死(UI 走实时通道,telemetry 走 sink)。
|
|
29
|
+
- 不要把 tracing id / run id 透传到模型供应商请求体里。
|
|
30
|
+
- 不要把"先埋点再说"的 ad-hoc 日志散在业务文件里——所有可观测点收敛进 telemetry port。
|
|
31
|
+
|
|
32
|
+
## 5. Scope 字段与父子 run
|
|
33
|
+
|
|
34
|
+
| 字段 | 何时填 |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `scope.runId` | 当前 run 自己的 id(同步 child-run 应填 child run id 自己) |
|
|
37
|
+
| `scope.parentRunId` | 父 run 的 id(同步 child-run 必填,detached run 视场景填) |
|
|
38
|
+
| `scope.turnId` | host 的 turn 概念 id(推荐与 `runId` 对齐) |
|
|
39
|
+
| `scope.conversationId` | host 的会话/对话 id |
|
|
40
|
+
| `scope.traceId` | 可选;跨服务追踪 |
|
|
41
|
+
|
|
42
|
+
正确填写后,`RunCostCollector.snapshot(parentRunId)` 可以返回 `childrenTotal`,把同步子 agent 的 LLM cost 聚合到父 run。
|
|
43
|
+
|
|
44
|
+
## 6. 最小验证
|
|
45
|
+
|
|
46
|
+
- 单测:注入一个 `Array.push`-style sink,断言一次 run 里 4 类 kind 各发了至少 1 次。
|
|
47
|
+
- 集成测:`withLLMTelemetryContext` 内嵌的 LLM 调用拿到的 `scope` 与外层一致;并发两个 run 时 scope 不串。
|
|
48
|
+
- 集成测:父 agent 工具内 `invokeChildRun` → 父 run 的 `cost().childrenTotal.llmCost > 0`,且子 run 自身 cost 不重复计入父 run 的直接 cost。
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Testing · 用 testkit 测接入
|
|
2
|
+
|
|
3
|
+
> **What** · `@linnlabs/linnkit/testkit` 测试底座 —— scripted AI engine / graph loop harness / tool fixtures / replay harness / 26 条 strict invariants(15 run + 11 contextPolicy + C12 tokenizer)。
|
|
4
|
+
> **When to read** · 写第一个 agent 单测;要校验 `contextPolicy` 决策;要 mock LLM / tokenizer / telemetry / audit;做接入回归。
|
|
5
|
+
> **Prerequisites** · [`02-quickstart.md`](./02-quickstart.md)。
|
|
6
|
+
> **Key exports** · `createGraphLoopHarness` / `createScriptedAiEngine` / `createContextPipelineHarness` / `createRunSupervisorHarness` / `createCollectingAuditPort` / `createMockTelemetryPort` / `createMockTokenizerPort` from `@linnlabs/linnkit/testkit`。
|
|
7
|
+
> **Related** · [`context-engineering.md` §9.4.6](./context-engineering.md) · [`audit.md`](./audit.md) · [`telemetry.md`](./telemetry.md) · [`constraints-and-pitfalls.md`](./constraints-and-pitfalls.md)(`AGENT-GUARD-10-no-testkit-in-production`)
|
|
8
|
+
|
|
9
|
+
`@linnlabs/linnkit/testkit` 是 **package-neutral** 的测试底座。它**只**给你"linnkit 自己的合同"测试用的 primitive;不替代你的 host-bound testkit。
|
|
10
|
+
|
|
11
|
+
## 1. 两层架构
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
第一层(linnkit 自带):装包就有,验证 linnkit 合同
|
|
15
|
+
│ create*Harness / fixture / assertions / invariants
|
|
16
|
+
▼
|
|
17
|
+
第二层(你自己写):放在 app-hosts/<your-app>/testkit/* 下
|
|
18
|
+
│ 依赖你的默认 LlmNode / ToolManager / persistence
|
|
19
|
+
▼
|
|
20
|
+
host application-layer test(产品级):跟 linnkit 没关系
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## 2. 第一层:linnkit 内置 primitive(直接装包就有)
|
|
24
|
+
|
|
25
|
+
| primitive | 用途 |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `createScriptedAiEngineHarness` | 脚本化 AI engine |
|
|
28
|
+
| `createGraphLoopHarness` | 把 graph loop / LlmNode / AgentEventBridge / observationPreview 装好的最小 harness |
|
|
29
|
+
| `createDefaultGraphExecutor` | 返回一个最小默认 `GraphExecutor`(仅测试用)|
|
|
30
|
+
| `createReplayHarness` | context replay harness |
|
|
31
|
+
| `createToolContextFixture` | 最小 `ToolExecutionContext` |
|
|
32
|
+
| `createRunSupervisorHarness` | 一行装配 `DefaultRunSupervisor + MemoryRunRegistryStore + EventBus + MemoryEventStore + mock cost collector` |
|
|
33
|
+
| `createCollectingAuditPort` | 把 `AuditEnvelope` 收进数组,支持 `assertEmitted()` / `assertEmittedInOrder()` |
|
|
34
|
+
| `createMockTelemetryPort` | 按 `scope.runId ?? scope.turnId` 收集 telemetry,并提供 `RunCostCollector` |
|
|
35
|
+
| `validateRunInvariants` / `assertRunInvariants` | 默认严格校验 15 条 run 不变量,覆盖 lifecycle / audit / telemetry / cost / EventStore / ToolCall 配对、wait-user 状态联动与 detached run 终态 |
|
|
36
|
+
| `assertions` namespace | 常用断言 |
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import {
|
|
40
|
+
createScriptedAiEngineHarness,
|
|
41
|
+
createGraphLoopHarness,
|
|
42
|
+
createRunSupervisorHarness,
|
|
43
|
+
validateRunInvariants,
|
|
44
|
+
createToolContextFixture,
|
|
45
|
+
assertions,
|
|
46
|
+
} from '@linnlabs/linnkit/testkit';
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## 3. 最小 run 协议测试
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const harness = createRunSupervisorHarness();
|
|
53
|
+
const handle = await harness.registerRun({ runId: 'turn_1' });
|
|
54
|
+
await handle.markRunning();
|
|
55
|
+
await handle.markCompleted();
|
|
56
|
+
|
|
57
|
+
const report = await validateRunInvariants({
|
|
58
|
+
rootRunId: handle.runId,
|
|
59
|
+
runRecords: await harness.getRegisteredRuns(),
|
|
60
|
+
telemetryEvents: harness.telemetry.getEvents(),
|
|
61
|
+
auditEnvelopes: harness.audit.getEnvelopes(),
|
|
62
|
+
getCost: (runId) => harness.telemetry.costCollector.snapshot(runId),
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`validateRunInvariants` 默认开启所有 15 条不变量;想跳过特定不变量需要显式传入 `{ allowed: [...] }`。生产 CI 推荐保持默认严格。
|
|
67
|
+
|
|
68
|
+
## 4. 第二层:你自己写的 host-bound testkit
|
|
69
|
+
|
|
70
|
+
放在 `app-hosts/<your-app>/testkit/*` 下。它依赖你的默认 adapter,把第一层 harness 包一层:
|
|
71
|
+
|
|
72
|
+
- 把你的默认 `LlmNode` / `AgentEventBridge` / `observationPreview` 喂给 `createGraphLoopHarness()`
|
|
73
|
+
- 用你的默认 `ToolManager` 创建 host-bound `ToolRuntimeHarness`
|
|
74
|
+
- 用 `createRunSupervisorHarness()` 承载 supervisor/audit/telemetry,再把你的真实 graph loop 包成 `runAgentScenario()` 这类一站式 driver
|
|
75
|
+
- 用 in-memory 持久化 mirror 你的 SQLite/Postgres 实现,做 contract parity
|
|
76
|
+
|
|
77
|
+
linnkit 不强制你的第二层 wrapper 长什么样,只要求一条铁规:**第二层 wrapper 不能回写 `@linnlabs/linnkit` 包内**——所有依赖你自己默认 adapter 的逻辑必须留在你自己仓库。
|
|
78
|
+
|
|
79
|
+
## 5. 选择规则
|
|
80
|
+
|
|
81
|
+
- 验证 linnkit 合同("我的 EventStore 是不是符合 port 契约?")→ 第一层 + 你的实现做参数化
|
|
82
|
+
- 验证"我的宿主装配是否通了"("我的 host 接进 graph 后能跑出 final_answer 吗?")→ 第二层
|
|
83
|
+
- 验证产品功能 → host application-layer test,跟 linnkit 没关系
|
|
84
|
+
|
|
85
|
+
## 6. 三类常见场景注入
|
|
86
|
+
|
|
87
|
+
第一层支持以下三种主动注入,方便覆盖错误路径:
|
|
88
|
+
|
|
89
|
+
| 场景 | 用法 |
|
|
90
|
+
|---|---|
|
|
91
|
+
| 工具抛错 | `harness.tools.injectThrowOnce({ tool: 'echo', error: new Error('boom') })` |
|
|
92
|
+
| LLM 抛错 | `harness.llm.injectThrow({ step: 1, error })` |
|
|
93
|
+
| LLM 调用中途取消 | `harness.driver.cancelMidLlm({ step: 2, reason: 'user_aborted' })` |
|
|
94
|
+
|
|
95
|
+
跑完 scenario 后用 `assertRunInvariants(report)` 验证所有 15 条不变量都没被破坏。
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# Tool Development Guide · 工具开发推荐规范
|
|
2
|
+
|
|
3
|
+
> **What** · 写自定义工具的设计推荐规范 —— `data` / `observation` 分层、错误处理、`getExecutionSummary`、长 observation 治理、7 条协议约束。
|
|
4
|
+
> **When to read** · 第一次写自定义工具;现有工具 token 偏多想优化;review 工具实现是否符合规范。
|
|
5
|
+
> **Prerequisites** · [`tools.md`](./tools.md)(工具接入面基础);[`02-quickstart.md`](./02-quickstart.md)。
|
|
6
|
+
> **Key exports** · `BaseTool` / `ToolExecutionContext` from `@linnlabs/linnkit/runtime-kernel`。
|
|
7
|
+
> **Related** · [`tools.md`](./tools.md) · [`tool-history.md`](./tool-history.md) · [`context-engineering.md` §6](./context-engineering.md)
|
|
8
|
+
|
|
9
|
+
> 这份文档回答一个问题:**怎么写一个高质量的工具,让它既能被 linnkit 协议层接住,又能给 AI / UI 双方都提供恰到好处的信息密度?**
|
|
10
|
+
|
|
11
|
+
linnkit 在协议层守住"工具调用的边界"——`ToolRuntimePort` + `BaseTool` 抽象类 + tool 配对不变量(C10)+ `AuditEnvelope`。但**工具内部怎么设计参数、怎么组织返回结构、怎么治理超长输出、怎么响应失败**,是 host 的工程实践。
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## 0. 设计哲学(一行话)
|
|
16
|
+
|
|
17
|
+
> **用最少的 token 传递最多的信息。**
|
|
18
|
+
|
|
19
|
+
linnkit 在协议层提供细粒度上下文工程能力(12 大分组 `contextPolicy` + `mustKeep` + fence + ContextTrace),但**这些只能管"已经进入上下文的消息怎么调度"**——**工具返回了什么、参数里塞了什么**,则是工具作者的工作。一个工具如果在 `parameters` 里塞 10 个可选字段、在返回值里嵌套四层 JSON,会让 LLM 上下文窗口被这一个工具吃掉 30%。这违背 linnkit 的产品特色:**对每一个发给 AI 的 token 进行精细化管理**。
|
|
20
|
+
|
|
21
|
+
派生原则:
|
|
22
|
+
|
|
23
|
+
| 原则 | 含义 |
|
|
24
|
+
|------|------|
|
|
25
|
+
| **工具是抽象的,复杂实现归上层** | 不在工具内部实现复杂业务函数;工具是"接受参数 → 调用 host 服务 → 返回结构化结果"的薄壳 |
|
|
26
|
+
| **数据合同优先选择最小且稳定的结构** | 中间推导物、展示噪音、上层可自由决定的字段,不进参数、不进返回值 |
|
|
27
|
+
| **不为"看起来更结构化"拆出多层嵌套** | 嵌套对象不是免费的,每一层 token 都要算账 |
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 1. 协议层强制约束
|
|
32
|
+
|
|
33
|
+
下表是 linnkit 协议层会**强制校验**的规则。违反会被运行时拒绝、被 testkit invariants 抓住、或导致 ContextTrace / AuditEnvelope 失真。
|
|
34
|
+
|
|
35
|
+
| 规则 | 校验机制 | 违反后果 |
|
|
36
|
+
|------|---------|---------|
|
|
37
|
+
| `name` / `description` / `parameters` 必填 | `BaseTool` 抽象类 | TS 编译失败 |
|
|
38
|
+
| `run(args, context): Promise<string>` 签名 | `BaseTool` 抽象类 | TS 编译失败 |
|
|
39
|
+
| `parameters.required[]` 声明的字段缺失 | `BaseTool.validateArguments()` 自动校验 | `ToolExecutionResult.errorKind = 'protocol'`,不进入 `run` |
|
|
40
|
+
| `run` 必须返回字符串(不是对象)| `BaseTool` 签名 | 下游解析失败 / 投影层崩溃 |
|
|
41
|
+
| 工具配对:`tool_call` ↔ `tool_output` 严格 1:1 | tool 配对不变量 C10 + testkit invariant | 26 条 strict invariants 报错 |
|
|
42
|
+
| 失败必须 `throw`,不能返回伪装成功的 JSON | runtime 接住 throw → `tool_output.status = 'error'` | UI 状态与实际不一致(最难排查的 bug 类) |
|
|
43
|
+
| `tool_calls` / `tool_outputs` 不可单独删(只能成对压缩)| `toolHistoryCompressor` + `ToolReplayProtocolGuard` | provider replay 协议违反 → LLM 调用失败 |
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## 2. `run` 的返回值结构(强烈推荐 `JSON.stringify({ data, observation })`)
|
|
48
|
+
|
|
49
|
+
linnkit 不强制 `data` / `observation` 这套分层(host 可以自己决定),但**强烈推荐**——这是已有项目实战验证的最佳实践,能让一个工具同时满足两类消费者:
|
|
50
|
+
|
|
51
|
+
| 字段 | 服务对象 | 设计原则 |
|
|
52
|
+
|------|---------|---------|
|
|
53
|
+
| `data` | **UI 渲染** | 结构化、字段名稳定、避免重复正文;UI 不应做"二次加工",前端只忠实渲染 |
|
|
54
|
+
| `observation` | **AI 上下文** | 纯文本、可读、信息密度高;**禁止**重复 `data` 中的字段、**禁止**拼大段 JSON、**禁止**加 emoji 等噪音 |
|
|
55
|
+
|
|
56
|
+
**为什么这套分层重要**:
|
|
57
|
+
|
|
58
|
+
- 没有分层时,工具作者要么"AI 友好 UI 不友好"(observation 给前端解析 → 解析失败),要么"UI 友好 AI 不友好"(结构化 JSON 进 LLM 上下文 → token 爆炸 + 模型不擅长读嵌套 JSON)。
|
|
59
|
+
- 有了分层后,`linnkit` 的 `enterAgentContext` 治理(`eventGovernance`)+ `observationGovernance`(治理超长 observation 落盘)能精准地只让 `observation` 进入 AI 上下文,`data` 留给前端。
|
|
60
|
+
|
|
61
|
+
### 2.1 最小返回值示例
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
async run(args: SumArgs, _context: ToolExecutionContext): Promise<string> {
|
|
65
|
+
const sum = args.numbers.reduce((acc, n) => acc + n, 0);
|
|
66
|
+
const result = {
|
|
67
|
+
data: { sum },
|
|
68
|
+
observation: `The sum of ${args.numbers.length} numbers is ${sum}.`,
|
|
69
|
+
};
|
|
70
|
+
return JSON.stringify(result);
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 2.2 真实工具示例(含 ToolExecutionContext 注入)
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import {
|
|
78
|
+
BaseTool,
|
|
79
|
+
type ToolArgs,
|
|
80
|
+
type ToolExecutionContext,
|
|
81
|
+
type ToolParameterSchema,
|
|
82
|
+
} from '@linnlabs/linnkit/runtime-kernel';
|
|
83
|
+
|
|
84
|
+
interface SearchDocsArgs extends ToolArgs {
|
|
85
|
+
query: string;
|
|
86
|
+
topK?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface DocHit {
|
|
90
|
+
documentId: string;
|
|
91
|
+
title: string;
|
|
92
|
+
snippet: string;
|
|
93
|
+
score: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class SearchDocsTool extends BaseTool<SearchDocsArgs> {
|
|
97
|
+
readonly name = 'search_docs';
|
|
98
|
+
|
|
99
|
+
readonly description = `Search documents in the user's knowledge base.
|
|
100
|
+
|
|
101
|
+
# When to Use
|
|
102
|
+
|
|
103
|
+
- When the user asks to find / search / look up content across documents.
|
|
104
|
+
- Prefer this over reading every document one by one.
|
|
105
|
+
|
|
106
|
+
# Output
|
|
107
|
+
|
|
108
|
+
Returns top-K documents ranked by relevance, each with id / title / snippet.`;
|
|
109
|
+
|
|
110
|
+
readonly parameters: ToolParameterSchema = {
|
|
111
|
+
type: 'object',
|
|
112
|
+
properties: {
|
|
113
|
+
query: { type: 'string', description: 'Search query (natural language).' },
|
|
114
|
+
topK: { type: 'integer', description: 'Max number of results.', default: 5 },
|
|
115
|
+
},
|
|
116
|
+
required: ['query'],
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
async run(args: SearchDocsArgs, context: ToolExecutionContext): Promise<string> {
|
|
120
|
+
const knowledgeBase = context.knowledgeBaseService;
|
|
121
|
+
if (!knowledgeBase) {
|
|
122
|
+
throw new Error('SearchDocsTool requires knowledgeBaseService in ToolExecutionContext');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const hits: DocHit[] = await knowledgeBase.search(args.query, args.topK ?? 5);
|
|
126
|
+
|
|
127
|
+
const result = {
|
|
128
|
+
data: { hits },
|
|
129
|
+
observation: hits.length === 0
|
|
130
|
+
? `No documents matched query: "${args.query}"`
|
|
131
|
+
: `Found ${hits.length} documents for "${args.query}":\n` +
|
|
132
|
+
hits.map((h, i) => `${i + 1}. [${h.documentId}] ${h.title} — ${h.snippet}`).join('\n'),
|
|
133
|
+
};
|
|
134
|
+
return JSON.stringify(result);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
注意几点:
|
|
140
|
+
|
|
141
|
+
1. **`description` 是写给 LLM 看的**:包含 "When to Use" / "Output" 等结构化指引,质量直接决定 LLM 的工具选择准确度。
|
|
142
|
+
2. **`context.knowledgeBaseService` 来自 host patch**:linnkit 协议层只定义 `ToolExecutionContext` 的保留字段(`__runtime` / `__capabilities`),host 通过 `ensureToolContextRuntimeCapability` 把自己的服务注入 context。
|
|
143
|
+
3. **`observation` 包含完整可读信息,但不重复 `data` 的字段名**:LLM 读完 `observation` 已经知道有哪些文档、引用 ID 是什么——不需要再 parse `data`。
|
|
144
|
+
4. **失败 throw**:`knowledgeBase` 缺失是配置错误,不是业务失败——`throw` 让 `AuditEnvelope` 与 `tool_output.status` 准确反映"协议级错误"。
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 3. 错误处理协议
|
|
149
|
+
|
|
150
|
+
| 场景 | 正确做法 | 错误做法 |
|
|
151
|
+
|------|---------|---------|
|
|
152
|
+
| 工具执行失败(外部服务挂了 / 业务规则违反)| `throw new Error('...')`,runtime 标记 `tool_output.status = 'error'` | 返回 `{"data":{"error":"..."}}` 伪装成功 |
|
|
153
|
+
| 必填参数缺失 | 通过 `parameters.required[]` 声明,让 `validateArguments` 拦截 | 在 `run` 里 `if (!args.x) throw` 或返回错误 JSON |
|
|
154
|
+
| Schema 不匹配(类型错 / 多余字段)| `parameters.additionalProperties = false` + `validateArguments` 拦截 | 在 `run` 里手写校验 |
|
|
155
|
+
| 批量场景部分失败 | 整体 `success`,但 `data` 中给出每条 `{ status, message }`,`observation` 简短摘要 | 抛错让整个批次失败 |
|
|
156
|
+
| 增强步骤降级(主操作成功,附加步骤失败)| 整体 `success`,在 `data.warnings` / `observation` 标注降级 | 抛错让主操作的产出丢失 |
|
|
157
|
+
| LLM 产出坏掉的 `tool_call.arguments`(流式 JSON 损坏)| 不属于工具错误——runtime / LLM 调用层处理为协议错误 | 在 `run` 里返回"成功但 data.error=..." |
|
|
158
|
+
|
|
159
|
+
### 3.1 不允许"伪装成功的失败"
|
|
160
|
+
|
|
161
|
+
`tool_output.status` 是协议层契约,**驱动**三个下游消费者:
|
|
162
|
+
|
|
163
|
+
1. **UI 渲染**:失败应该红色卡片 + 错误信息;伪装成功 → UI 显示绿色,但内容是错误,用户困惑
|
|
164
|
+
2. **`toolHistoryCompressor`**:失败的工具可以被压缩成 "tool X failed",伪装成功会被当成成功结果保留正文
|
|
165
|
+
3. **`AuditEnvelope`**:tool retry / tool deny 等审计决策依赖 `tool_output.status` 准确
|
|
166
|
+
|
|
167
|
+
**违反这一条的 bug 是最难排查的一类**。`throw` 一次,三处一致;伪装一次,三处全错。
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## 4. `parameters.required[]` 是强约束,不是提示
|
|
172
|
+
|
|
173
|
+
任何业务上"没有它就不能执行"的字段,**必须**放进 `required`。
|
|
174
|
+
|
|
175
|
+
linnkit 的 `BaseTool.validateArguments()` 会在 `run` 之前自动校验:
|
|
176
|
+
|
|
177
|
+
```ts
|
|
178
|
+
const required = this.parameters.required || [];
|
|
179
|
+
for (const field of required) {
|
|
180
|
+
if (!(field in args) || args[field] === undefined || args[field] === null) {
|
|
181
|
+
return {
|
|
182
|
+
success: false,
|
|
183
|
+
error: `Missing required parameter: ${field}`,
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
这意味着:
|
|
190
|
+
|
|
191
|
+
- ✅ 缺失 required 字段 → `ToolExecutionResult.errorKind = 'protocol'`,不进入 `run`
|
|
192
|
+
- ❌ 在 `run` 里写 `if (!args.x) throw` → 协议级错误被误标为执行级错误,audit / telemetry / replay 全都失真
|
|
193
|
+
|
|
194
|
+
**强约束的好处**:LLM 看到 `required` 列表后,会有更强的"必须传"心智;运行时拦截也能让 token 用尽更早暴露(避免 `run` 里走了 1/3 才发现缺字段)。
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## 5. `getExecutionSummary` —— 把工具产出压成历史摘要
|
|
199
|
+
|
|
200
|
+
`toolHistoryCompressor` 在 `strategy: 'per-run'` / `'per-pair'` 下,会用 `getExecutionSummary(output)` 把历史轮次的工具产出**压缩成一行摘要**——这是 linnkit 上下文工程的核心机制之一。
|
|
201
|
+
|
|
202
|
+
**默认实现**(`BaseTool.getExecutionSummary` 已提供):
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
getExecutionSummary?(output: string): string {
|
|
206
|
+
if (!output) return 'Tool returned no output.';
|
|
207
|
+
if (output.length <= 200) return output;
|
|
208
|
+
return `Tool returned ${output.length} characters of output.`;
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
默认实现对短输出原样保留、对长输出说"返回了 N 字符"——**信息量很弱**。每个工具都应当**自己实现** `getExecutionSummary`:
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
getExecutionSummary(output: string): string {
|
|
216
|
+
try {
|
|
217
|
+
const parsed = JSON.parse(output);
|
|
218
|
+
const hits = parsed?.data?.hits ?? [];
|
|
219
|
+
return `搜索到 ${hits.length} 篇文档:${hits.slice(0, 3).map((h: DocHit) => h.title).join('、')}${hits.length > 3 ? '…' : ''}`;
|
|
220
|
+
} catch {
|
|
221
|
+
return '搜索结果解析失败。';
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**对 token 的影响**:在 `strategy: 'per-run'` 下,一次 run 的 N-1 历史轮次工具产出都会被压成 `getExecutionSummary` 一行摘要——一个高质量的 summary 能把工具历史的 token 占用从几万压到几百。这是 linnkit "对每一个发给 AI 的 token 进行精细化管理" 的真实落地点。
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## 6. 超长 observation 治理(不要在工具内部截断)
|
|
231
|
+
|
|
232
|
+
**❌ 错误做法**:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
async run(args, context) {
|
|
236
|
+
const fullText = await fetchHugeContent(args);
|
|
237
|
+
if (fullText.length > 20_000) {
|
|
238
|
+
return JSON.stringify({
|
|
239
|
+
data: { preview: fullText.slice(0, 20_000), truncated: true, blobPath: writeToDisk(fullText) },
|
|
240
|
+
observation: fullText.slice(0, 20_000),
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return JSON.stringify({ data: { text: fullText }, observation: fullText });
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
每个工具都自己实现一遍截断 + 落盘 = 协议不一致、blob 路径不一致、replay 不一致。
|
|
248
|
+
|
|
249
|
+
**✅ 正确做法**:让 linnkit 协议层的 `toolOutput.observationGovernance` + host 的 `ObservationPreviewPort` 接管:
|
|
250
|
+
|
|
251
|
+
```ts
|
|
252
|
+
async run(args, context) {
|
|
253
|
+
const fullText = await fetchHugeContent(args);
|
|
254
|
+
return JSON.stringify({
|
|
255
|
+
data: { text: fullText },
|
|
256
|
+
observation: fullText,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
工具只负责"拿到完整内容、放进返回值"。当 `observation` 超过 `contextPolicy.toolOutput.observationGovernance.maxChars`(默认 20,000)或 `maxLines`(默认 1,200)时,`ToolNode` 会自动调用 host 的 `ObservationPreviewPort.truncateObservation()` 把全文落盘、生成 `blob_id`、把 observation 替换成"短预览 + 续读指引"。
|
|
262
|
+
|
|
263
|
+
详见 [`tools.md §6`](./tools.md#6-observationpreviewport配置超长-observation-存储路径)。
|
|
264
|
+
|
|
265
|
+
**为什么协议化**:
|
|
266
|
+
|
|
267
|
+
- 全仓一致:所有工具共用同一套截断 / 落盘 / 续读协议
|
|
268
|
+
- 可观测:截断决策进入 ContextTrace,可解释"为什么这次 observation 这么短"
|
|
269
|
+
- 可配置:`contextPolicy.toolOutput.observationGovernance` 让每个 agent 独立配置阈值
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## 7. 交互工具(`requireUser`)的单消息协议
|
|
274
|
+
|
|
275
|
+
如果你的工具需要用户输入(如确认操作、问卷、多选),**不能**自己写"先返回一段提示 → 等用户输入 → 再返回结果"——这会破坏 `tool_call` ↔ `tool_output` 1:1 配对,违反 C10 不变量。
|
|
276
|
+
|
|
277
|
+
**正确做法**:
|
|
278
|
+
|
|
279
|
+
1. **第 1 段**:工具返回完整 `StructuredToolResult`,并在 `result.control.requireUser = true` 声明"需要用户继续交互"。
|
|
280
|
+
2. **第 2 段**:`ToolNode` 不再持久化首条 `tool_output`,而是把 `pendingInteractionSpec` 写入 local state,然后 route 到 `wait_user` 节点。
|
|
281
|
+
3. **第 3 段**:`WaitUserNode` 发出 `requires_user_interaction` 事件,run 进入 `awaiting_user` 状态。
|
|
282
|
+
4. **第 4 段**:用户提交回复后,runtime 用**同一条** `tool_output` 事件继续——`metadata.interaction` 字段承载用户的 `approved / modified / submitted / skipped` 状态。
|
|
283
|
+
|
|
284
|
+
**reload / replay 的关键**:交互卡片的初始内容**必须**能从 `tool_call.arguments` 直接重建——不要把"首次工具输出快照"当成唯一事实来源。
|
|
285
|
+
|
|
286
|
+
---
|
|
287
|
+
|
|
288
|
+
## 8. 注册到 host 的 `ToolRuntimePort`
|
|
289
|
+
|
|
290
|
+
工具写完之后,host 通过 `ToolRuntimePort` 把它装配进 runtime。最简单的做法是用 quickstart 提供的 `QuickstartMemoryToolRuntime`:
|
|
291
|
+
|
|
292
|
+
```ts
|
|
293
|
+
import { QuickstartMemoryToolRuntime } from '@linnlabs/linnkit/quickstart';
|
|
294
|
+
|
|
295
|
+
const toolRuntime = new QuickstartMemoryToolRuntime([
|
|
296
|
+
new SearchDocsTool(),
|
|
297
|
+
new EchoTool(),
|
|
298
|
+
]);
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
生产 host 通常自己实现 `ToolRuntimePort`(实现 `ToolCatalogPort` + `ToolPresentationPort` + `ToolExecutionPort` 三个 sub-port),把工具与 host 的服务、权限、UI 渲染 registry 串起来——详见 [`tools.md`](./tools.md)。
|
|
302
|
+
|
|
303
|
+
---
|
|
304
|
+
|
|
305
|
+
## 9. 推荐遵守的 host 层约定
|
|
306
|
+
|
|
307
|
+
以下几条规范是 **host 业务层强烈推荐**遵守的——linnkit 协议层不会守门,但它们对工具开发质量影响很大:
|
|
308
|
+
|
|
309
|
+
| 约定 | 说明 |
|
|
310
|
+
|------|------|
|
|
311
|
+
| **`name` 用 `snake_case`** | 所有工具名小写下划线,避免与 LLM 自由生成的工具调用名混淆 |
|
|
312
|
+
| **`description` 包含 "When to Use"** | 明确告诉 LLM 何时调用、避免误用 |
|
|
313
|
+
| **`data` / `observation` 分层强制** | UI 字段稳定、observation 纯文本高密度(见 §2) |
|
|
314
|
+
| **`tag/badge` 慎用** | 只在创建态 / 审核态 / 风险态等强调操作时用 |
|
|
315
|
+
| **占位渲染白名单(早期 tool_call)** | 流式 tool_call delta 阶段,仅允许特定工具提前显示占位卡片 |
|
|
316
|
+
| **`requireUser` 工具的单消息交互协议** | 见 §7 |
|
|
317
|
+
|
|
318
|
+
---
|
|
319
|
+
|
|
320
|
+
## 10. 与 linnkit 协议的边界对齐表
|
|
321
|
+
|
|
322
|
+
| 工具内部决策 | 由谁负责 | 协议接入点 |
|
|
323
|
+
|------------|---------|----------|
|
|
324
|
+
| 工具名 / 描述 / 参数 schema | host(工具作者)| `BaseTool` |
|
|
325
|
+
| 返回值结构(`data` / `observation` 分层)| host(工具作者)| `BaseTool.run` 返回字符串 |
|
|
326
|
+
| 错误处理(throw vs 返回)| host(遵守 §3)| runtime 接住 throw → `tool_output.status = 'error'` |
|
|
327
|
+
| 必填参数校验 | linnkit 协议层 | `BaseTool.validateArguments()` |
|
|
328
|
+
| 超长 observation 治理 | linnkit 协议层 + host | `contextPolicy.toolOutput.observationGovernance` + `ObservationPreviewPort` |
|
|
329
|
+
| 工具历史压缩 | linnkit 协议层 | `contextPolicy.toolHistory.strategy` + `getExecutionSummary` |
|
|
330
|
+
| 工具配对一致性 | linnkit 协议层 | tool 配对不变量 C10 + `ToolReplayProtocolGuard` |
|
|
331
|
+
| 交互工具的 wait_user 路由 | linnkit 协议层 | `WaitUserNode` + `requires_user_interaction` 事件 |
|
|
332
|
+
| `data` 字段名约定 / 前端 registry 注册 | host(工具作者 + 前端工程师)| linnkit 不规定 |
|
|
333
|
+
| 工具的业务实现(数据库 / 外部服务调用)| host | linnkit 不规定 |
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
## 11. 自查清单
|
|
338
|
+
|
|
339
|
+
- [ ] `name` 用 `snake_case`,在 host 工具集中唯一
|
|
340
|
+
- [ ] `description` 包含 "When to Use",写给 LLM 看
|
|
341
|
+
- [ ] 必填参数全部放进 `parameters.required[]`
|
|
342
|
+
- [ ] `additionalProperties: false`(如果不希望 LLM 传额外字段)
|
|
343
|
+
- [ ] `run` 返回 `JSON.stringify({ data, observation })`
|
|
344
|
+
- [ ] `data` 给前端,`observation` 给 AI,**两者不重复字段**
|
|
345
|
+
- [ ] `observation` 是纯文本、无 emoji、信息密度高
|
|
346
|
+
- [ ] 失败用 `throw new Error(...)`,**不**返回伪装成功的 JSON
|
|
347
|
+
- [ ] 实现 `getExecutionSummary(output)`,给 ToolHistoryCompressor 用
|
|
348
|
+
- [ ] 不在工具内部做超长截断 / 落盘——交给 `ObservationPreviewPort`
|
|
349
|
+
- [ ] 如果是交互工具:第一段返回 `result.control.requireUser = true`,复活路径只从 `tool_call.arguments` 重建
|
|
350
|
+
- [ ] 单测用 `createToolContextFixture()` 直接测 `tool.run(args, fixtureContext)`
|
|
351
|
+
- [ ] 在 host 的 `ToolRuntimePort` 实例化里注册
|
|
352
|
+
|
|
353
|
+
---
|
|
354
|
+
|
|
355
|
+
## 12. 与其他文档的关系
|
|
356
|
+
|
|
357
|
+
- [`tools.md`](./tools.md) — `ToolRuntimePort` / `ObservationPreviewPort` 等**协议接入面**
|
|
358
|
+
- [`agent-registration-guide.md`](./agent-registration-guide.md) — 把工具集装进 `AgentSpec` / 注册到 host agent registry
|
|
359
|
+
- [`tool-history.md`](./tool-history.md) — `toolHistoryCompressor` 的 `per-pair` / `per-run` / `none` 三策略配置
|
|
360
|
+
- [`context-engineering.md`](./context-engineering.md) — 12 大分组 `contextPolicy` 总览,含 `toolOutput.observationGovernance`
|
|
361
|
+
- [`audit.md`](./audit.md) — tool retry / tool deny 等审计决策
|
|
362
|
+
- [`testing.md`](./testing.md) — testkit 提供的 26 条 strict invariants(含工具相关的 C10)
|