跳转至

19 · 实战二:用 profile 和 overlay 组成自己的 Agent

19.1 运行时工厂不等于另写应用入口

src/composition.ts 的 createNotesHarness() 负责整理宿主输入、建立临时 home、写 overlay,再调用公开 DeepSeekHarness SDK。SDK 实际启动同版本 dsh --profile sdk-minimal。它没有把一个自由 Cordis Context 当生产应用 launcher,也没有绕过官方 runtime 生命周期。

sequenceDiagram participant App as 你的宿主应用 participant Factory as composition.ts participant SDK as 官方 TS SDK participant DSH as rc.2 dsh CLI participant Tree as Cordis plugin tree App->>Factory: 明确 endpoint/key/workspace/provider Factory->>Factory: mkdtemp + notes.patch.json Factory->>SDK: profile sdk-minimal + patches SDK->>DSH: 启动子进程并 initialize DSH->>Tree: profile layers + 显式 overlay Tree->>Tree: service provider + tool consumer 激活 App->>SDK: run(prompt) SDK-->>App: root idle 后的 events / finalResponse App->>Factory: close() Factory->>SDK: shutdown + 进程收尾 Factory->>Factory: 删除本次临时 home

rc.2 的 sdk-minimal bundle 是完整 standalone tree,不是 base bundle 的少数隐藏 UI。它包含真正的模型路由、loop、Session、投影、JSONL 存储和 SDK server,但默认也有 persistent Shell 和 danger-full-access policy。因此名字“minimal”不等于低权限。

19.2 正确 patch 的 id 和配置覆盖

工厂生成的 overlay 首先禁用 persistent-bash、persistent-pwsh 两个 模型工具 plugin row,再用 llm-deepseek row 的完整 config 设置本次 endpoint、key 环境变量名、context window 与 5 秒 stream-idle timeout,最后 insert 自己的 provider 和 consumer。

[
  { "id": "persistent-bash", "disabled": true },
  { "id": "persistent-pwsh", "disabled": true },
  { "id": "llm-deepseek", "config": {
    "apiKeyEnv": "DEEPSEEK_API_KEY",
    "baseURL": "http://127.0.0.1:动态端口",
    "defaultContextWindow": 1000000,
    "streamIdleTimeoutMs": 5000
  } },
  { "insert": [
    { "id": "course-note-provider", "name": "绝对路径/json-notes.js", "config": { "path": "绝对路径/notes.json" } },
    { "id": "course-note-tools", "name": "绝对路径/notes-tools.js", "config": { "maxSteps": 8 } }
  ] }
]

这是解释用结构,动态端口和路径必须由 composition.ts 填入,不能复制占位值运行。包的 config 键是 baseURL,不是 baseUrl;CLI/Python 参数的拼写又不同,见 14/20 章。Patch 的 row config 是 整份替换,不是自动深合并,所以我们把需要的字段显式写全。

禁用 persistent tool rows 后,terminal service/subprocess provider 仍可能作为 runtime 基础依赖存在。我们的实际模型请求断言只有两个笔记工具,说明模型可调用表面被收窄;不宣称整个插件树绝对不能创建进程或成为操作系统 sandbox。

19.3 home、cwd、processCwd 的用途

home 由 mkdtemp 放在系统临时目录。每次运行自己的 profile 初始化、Session 和 patch,不加载用户已有 home 的插件/凭据记录。cwd 是 SDK 创建 Session 的工作目录,processCwd 是 dsh 子进程启动目录;本例都显式设为解析后的 workspace。两者名字相近,但不是同一个配置作用点。

工厂要求 notes 和 notesPath 恰好选一项,防止两个 provider 同时发布相同 service。笔记 JSON 文件路径由宿主选择,不由模型选择;workspace 可以传自己的业务目录,但这不能自动赋予按租户隔离文件的保证。

实际 integration test 往目标 workspace 写入 AGENTS.md、SYSTEM.md sentinel,确认 sdk-minimal 的这个组合没有将它们放入模型请求。测试不声称穷尽所有文件发现规则,也不证明任意 profile 或额外 include plugin 都会忽略项目文件。若自己加回 agent-instructions/skills/context producer,必须重新测试数据边界。

19.4 子进程环境:两个 SDK 有实质区别

TypeScript SDK 中 env 对象替代子进程环境,工厂只传 PATH、显式 API key、显式 BASE URL 和 telemetry 关闭值。没有把宿主完整 process.env 复制给 Agent。PATH 必须保留,让 Node 的 shebang launcher 能找到解释器;自己的部署也要指定正确命令解析环境。

Python SDK 的 env 会与 os.environ 合并,所以给它 {} 并不会去掉父环境。Python demo 先由 Node execFile 用 allowlist 启动 Python 进程,再让 Python SDK 按公开参数启动 dsh。两层含义在实现和教程一致。

这仍不是对子进程可见文件、网络、动态 loader 或插件代码的强隔离。你选入 tree 的 JS/Python 插件是宿主运行的代码,应按自己的软件供应链管理。真正有不可信执行需要 sandbox/container/平台策略和单独测试。

19.5 步骤预算放在 agent/pre-step

笔记工具 plugin 用 WeakMap<Agent, { turn, count }> 跟踪每个 live Agent 本轮已准入 step。agent/pre-step 是 waterfall:达到上限返回 {kind: 'reject'};未达上限 await next(),只在 downstream 的 decision 是 enter 时加一,并将 完整原 decision 返回。

这保留其他监听者的 messages rewrite 与 startsRequestSeries 等标记,不把 next() 返回值替换成自己发明的固定 enter。budget reset 以新的 turn 为准,第二次 run 会重新开始计数。相同 turn 内多次 retry 不再经过 pre-step,所以这里限制 admitted steps,不限制 HTTP attempt 总数。

flowchart LR A[pre-step 请求] --> B{新的 turn?} B -->|是| C[count = 0] B -->|否| D{count 已达 maxSteps?} C --> D D -->|是| E[reject / turn blocked] D -->|否| F[await next] F --> G{decision enter?} G -->|是| H[count + 1] G -->|否| I[不增加] H --> J[返回原 decision] I --> J

配置允许 1–100 的 safe integer,默认 8。测试连续工具调用、maxSteps 2,第三次请求没发出,最终 root turn reason 为 blocked。达到预算不会自动返回一段“成功”的文本,也不保证工具本轮已完成业务目标。

这个预算不能替代 token/cost/elapsed/request-attempt/concurrency 限额。maxTokens: 2048 限制单次请求输出,streamIdleTimeoutMs: 5000 限制流多久没新数据,requestTimeoutMs: 15000 限制部分 SDK 请求等待;特别是 SDK 的 run wait-for-idle 不等于有 15 秒硬总截止。要做生产任务,宿主要维护整体 deadline、进程终止与业务状态补偿。

19.6 关闭、flush 与业务结果

OwnedHarness.close() memoize 同一个 Promise,先 await harness.close(),再删除临时 home。demo 的 finally 同时关闭脚本 HTTP server。重复关闭应该等同一完成过程,而不是在前次进程还写日志时删除目录。

TS result 没有一个万能的 finishReason 字段。实验读取 durable turn/end.data.reason.kind,区别 completed、blocked 和 error。SDK 返回 idle 只说明当前 root 没有继续工作,HTTP 401 也会回到 idle;不能只检查 Promise resolve 或空 finalResponse 就写“任务成功”。

另一个实际测试发现:SDK 收到 turn/end 和 idle 时,文件 writer 可能仍未 flush。我们在检查 session.v4.jsonl 之前显式关闭 runtime,让正常收尾完成,再读取日志。这是正常关闭路径的验证,不是硬掉电/kill -9 耐久性测试。生产中的可靠交付还需要业务 commit/receipt 和明确 durable acknowledgement,见 16/17 章。

19.7 从实验改成自己的产品

先保留可重复本地 fixture,把 NoteStore 换成自己的 service;通过同样的 schema、拒绝、重试、副作用和 lifecycle 测试。然后用真实 API key 跑你自己的模型任务,观察实际 token、延迟与引用正确性。最后再加入 UI、审批、持久 home 和自动化,每加一个 producer 都复核模型可见数据与恢复行为。

临时 home 适合隔离实验,关闭后删除;需要跨宿主进程继续历史时必须换成应用拥有的持久 home,并设计并发 writer 与 retained data 清理。Session 是 durable evidence,业务数据库是交付状态,两者应建立明确关系,不能用“有一段回答”代替订单、文件或部署结果确认。