# How to build your own agent harness???
**作者**: Mike Piccolo
**日期**: 2026-05-28T18:41:58.000Z
**来源**: [https://x.com/mfpiccolo/status/2060069083878408689](https://x.com/mfpiccolo/status/2060069083878408689)
---

Most agent teams don't build a harness. They adopt one. LangChain, LangGraph, OpenAI Agents SDK, Anthropic SDK, CrewAI, AutoGen, the loop, the tools, the memory, and the orchestration are picked off the shelf as a single decision. The harness is a framework you import. If something inside it doesn't fit, you fork it, fight it, or work around it.

I think that shape is wrong, and it's the reason every long-running agent team eventually ends up rewriting its harness from scratch. The harness isn't one thing. It's ten or twelve different things bundled together because the surrounding ecosystem doesn't give you a way to compose them. Pi agent packages are on the right track, but they are still in the paradigm of “Add another service and integrate it with all others.” The iii engine treats all workers the same and removes the integration logic completely. The provider router, the credential vault, the policy engine, the approval gate, the model catalog, the session storage, the budget tracker, the after-call hook fanout, and the durable turn loop are independent concerns. These are all interoperable with your queue, http/api server, streaming, even browser workers. A framework that ships them as one block is selling you a tradeoff you didn't have to make.
The bet underneath iii is that they shouldn't be one block. There should be a set of workers on a shared engine, each replaceable, each versioned independently, each connected by a single primitive: a trigger (iii.trigger()) that every other worker also uses. The harness becomes a stack of installable workers, and "build your own" stops meaning "fork a framework." It means "swap a few workers."
This post walks through what that actually looks like. The complete stack that drives an iii agent turn today, why each layer is its own worker, and how you replace any of them.
## The 15 jobs an agent harness has to do
If you strip a production agent harness back to its responsibilities, you get a list that looks roughly like this:
1. Accept a turn request from a client and persist it
2. Resolve credentials for whichever model provider gets called
3. Look up what the chosen model can actually do (vision, tools, streaming, context window)
4. Drive the per-turn state machine, provision, stream assistant, run tools, steer, tear down
5. Load and serve skill bodies that describe each function's request shape, error codes, and usage notes
6. Assemble the system prompt, mode paragraph, identity preamble, working directory, and default skills appendix
7. Stream tokens back to the client as the model produces them
8. Check every tool call (that’s just a function) against a policy before it runs
9. Pause tool calls that need a human decision and route the answer back to the right turn
10. Track LLM spend against per-workspace or per-agent budgets
11. Run hooks before and after tool calls (logging, redaction, custom side effects)
12. Persist the session as a branching tree so forks and resumes work
13. Compact session history when the context window fills up
14. Emit an event stream that the UI subscribes to
15. Missing piece from every agent's company building, I see. Carry one OpenTelemetry trace across every step so you can debug it
Every serious agent harnesses most of these. The expensive ones do all of them. The cheap ones cut corners and rebuild the corners later when they hit production. The frameworks bundle them into a monolith and ship one version of each. That last part is the part that costs you, because a year in, you find out that the policy engine you want is not the policy engine the framework ships, and replacing it means replacing the harness.
The iii harness ships every one of those thirteen jobs as a separate worker on the workers.iii.dev registry. Each speaks the same WebSocket protocol. Each registers functions and triggers on the same engine bus. Each is iii worker add-able, swappable, and writable in any language with an SDK.
## The stack, by worker
Here is the actual production stack from the iii-hq/workers monorepo, with each worker's job in one line. The whole bundle ships at github.com/iii-hq/workers/harness:

Eleven workers. One engine. Each is on a published version. Each is independently runnable as a standalone process (pnpm dev:<worker> in dev, iii worker add <specific-worker> as a release binary) or as part of the composite entry point that spins them up together.
The reason this matters: every box in that table is a place where someone can hand you a different worker, and you keep the rest. Don't like the static model catalogue? Plug in a worker that registers models::list and reads from a live API. Don't like file-backed credentials? Plug in a worker that registers auth::get_token and reads from a secrets manager. Want a different turn FSM for a workflow that branches differently? Replace turn-orchestrator, every dependent calls run::start and reads turn_state through the same bus, so the rest of the stack doesn't change.
## How the loop actually runs
The shape of one turn looks like this, walking through the workers in the order they fire.
A browser/cli/chat POSTs a turn through harness::trigger with {session_id, message_id, payload}. The harness meta-worker forwards payload to run::start. That hop exists so the OpenTelemetry span wrapper can seed the session and message IDs as baggage, which propagates to every nested iii.trigger call across every worker in the stack. The trace tree on the other side is one connected graph.
run::start lands on the turn-orchestrator. It persists the run request, seeds the initial TurnStateRecord in iii state at session/<sid>/turn_state, and returns immediately. The actual work happens inside the durable per-state machine, woken by publishes to the turn-step FIFO.
The two terminal states are stopped (clean exit via finishSession()) and failed (an unexpected handler throw routes here, acks the queue so it stops retrying, and surfaces message_complete{stop_reason:'error'} plus agent_end so the UI shows the reason). Teardown is an inline finishSession() port called from any turn-end path, not a separate enqueued step.
provisioning does three things. It boots a iii-sandbox microVM if the run needs isolated execution. It calls directory::skills::download for every namespace in system_default_skills (default ["iii://iii-directory/index"]) so iii-directory pre-caches the skill bodies the run starts with. And it assembles the system prompt in three layers: a mode paragraph picked from run_request.mode (plan, ask, or agent), the iii identity preamble that teaches the model the agent_trigger convention and the directory::skills::get on-demand discovery pattern, and an appended index of the default skills the agent boots with. The caller can override the whole prompt by passing system_prompt on run::start; otherwise the orchestrator builds it. Function schemas come from the live engine catalog.
assistant_streaming calls provider::<name>::stream on whichever provider worker matches the run's provider field. The provider worker pulls credentials via auth::get_token (auth-credentials), streams the model's SSE response into an iii channel, and the orchestrator drains that channel emitting message_update events on agent::events for the UI fanout. Channel creation and the read loop live behind a pull-based MessagePump in provider-stream.ts, so the streaming state stays focused on transitions.
When the assistant returns tool calls, the FSM enters function_execute. Every tool call passes through dispatchWithHook, the single chokepoint in the orchestrator. consultBefore calls policy::check_permissions directly with a 5-second timeout. The policy worker (the harness meta-worker, in the default stack) reads iii-permissions.yaml, matches the call's function_id against the rule set, and returns one of three outcomes:
- allow: dispatch proceeds; the orchestrator triggers the target function and writes the result
- deny: dispatch short-circuits with a DenialEnvelope, the result becomes a denial record
- needs_approval: the individual call parks into the turn's awaiting_approval list. The rest of the batch keeps dispatching. The turn transitions to function_awaiting_approval only when one or more entries are pending.
The approval wake is reactive and shared. The orchestrator registers exactly one turn::on_approval state trigger on scope approvals. When the console calls approval::resolve, the approval-gate worker writes approvals/<sid>/<cid> = {decision, reason} to iii state. That write fires turn::on_approval, which advances the affected session. function_awaiting_approval reads only the decisions that just landed, dispatches each one as it arrives (allow becomes a pre-approved dispatch, deny or aborted becomes a synthetic denial), and advances when awaiting_approval[] is empty. No per-call resume functions to register. No startup re-scan to recover pending approvals. One trigger covers every session.
Fail-closed by construction: if the policy worker is unreachable or the 5-second timeout fires, consultBefore denies the call with a gate_unavailable envelope. If iii::durable::publish itself errored, the hook fanout returns publish_failed: true and the orchestrator treats it as a deny.
A few latency wins fall out of this shape. The after-function-call hook short-circuits publish_collect via a subscriber-presence cache when no durable subscriber is registered for the topic, removing roughly 500ms per executed function call. tearing_down is inlined into finishSession(), removing one durable queue hop per turn. context-compaction subscribes to a dedicated agent::turn_end stream the orchestrator emits at turn boundaries, so compactor wakeups are per-turn instead of per-event. The session-create fanout state trigger gates by scope alone and matches in-process, so the previous per-write harness::session::is_create_event RPC is gone.
After the batch completes, steering_check decides whether to continue, stop, or hit max_turns. If continue, loop back to assistant_streaming. If stop or max, finishSession() runs inline: emit agent_end, free the sandbox, transition to stopped.
Throughout the whole run, every worker that participates emits OTel spans tagged with iii.session.id, iii.message.id, and iii.function.id. Those tags are what the engine's engine::traces::group_by reads to populate "Group by Session" / "Group by Message" / "Group by Function" in the traces UI. The instrumentation is automatic: src/runtime/worker.ts wraps every registerFunction in a Proxy so no per-worker code has to remember to add spans.
## Build your own
The interesting part is that none of the workers above are special. Each one is a process that opens a WebSocket to the engine, registers some functions and triggers, and runs. The contract is the same as the contract every application worker uses. The harness is built on the same primitive your business logic is built on.
Which means "build your own harness" decomposes into the same operation as "write any worker." You pick the layer you want to replace, you write a worker that registers the same functions on the bus, you iii worker add it, and the rest of the stack starts using your worker.
Two layers don't show up in the worker table above but matter for how the harness behaves. Skills are how each worker advertises what its functions do. Every worker can publish a skill at iii://<worker>/<function> that the agent fetches via directory::skills::get before calling that function for the first time. The system prompt is assembled per turn from a mode paragraph, the iii identity preamble, and the default skill bodies the run was configured with. Both are bus-driven: skills are served by the iii-directory worker, the system prompt is assembled by the turn-orchestrator. Both are replaceable.
Five concrete examples.
Replace the model catalogue with a live API. Write a worker that registers models::list, models::get, models::supports. Have it fetch from your provider's catalog endpoint every N minutes and cache. Publish it. iii worker add your-org/dynamic-models-catalog. Stop the static models-catalog worker. The turn-orchestrator never knows the difference. It calls iii.trigger('models::list') and the engine routes to whichever worker registered that function id most recently.
Add a new provider. The shape is provider-kimi and provider-lmstudio already prove out. Each is one worker that registers provider::<name>::stream and provider::<name>::complete, drains an SSE stream from the upstream API into an iii channel, and writes its model usage to llm-budget via budget::record. Adding a fifth provider is writing one folder with one iii.worker.yaml and one register.ts. Publish to the registry, or keep it local. The turn-orchestrator picks the provider by the run's provider field; new providers become available the instant the worker connects.
Serve skills from a private artifact store. Write a worker that registers directory::skills::get and directory::skills::list, backed by your internal docs system or a private S3 bucket. Disconnect or rename the default iii-directory worker. The orchestrator's bootstrap calls directory::skills::download per namespace; your worker answers. The agent's "fetch the per-function skill before calling a new function" pattern keeps working unchanged because the wire shape is the same.
Override the system prompt entirely. run::start accepts an optional system_prompt field. Pass it and the orchestrator uses your string verbatim, skipping the mode paragraph + identity preamble + skills appendix assembly. Useful when you have an existing prompt asset you want the harness to honour without modification. Skill download still runs in bootstrap, so the agent keeps directory::skills::get on-demand discovery even with a custom prompt.
Replace the approval gate UI surface. The default approval-gate worker registers approval::resolve. The wire schema is one function call:
```
iii.trigger('approval::resolve', {
session_id: '...',
function_call_id: '...',
decision: 'allow' | 'deny' | 'aborted',
reason: 'optional human text',
})
```
The handler persists approvals/<sid>/<cid> = {decision, reason} to iii state. The orchestrator's single turn::on_approval state trigger picks that write up and wakes the right session. If you want to drive approvals from Slack instead of the console, write a Slack worker that listens for /approve <id> and /deny <id> slash commands, then calls approval::resolve with the right payload. The orchestrator never knows the difference. The whole approval-gate worker stays untouched. You added a new worker; you didn't replace the existing one.
If you want a different policy engine (OPA, Cedar, your own DSL), write a worker that registers policy::check_permissions and returns { decision, rule_id?, matched_constraint? }. Disconnect the default policy worker (which is wrapped inside the harness meta-worker, so you'd disable that handler or run a stripped-down meta-worker). The turn-orchestrator's consultBefore doesn't know the difference. Same 5-second timeout, same fail-closed semantics, same wire shape.
The point of these examples isn't the specific replacements. It's the shape of the operation. Every harness layer in the iii stack is reachable through one or two function ids on the bus. Replacing a layer is writing a worker that registers those ids. The rest of the system stays.
## The harness is a slider, not a fork in the road
The classic harness debate frames itself as thin vs thick. Anthropic's thin loop versus LangGraph's explicit DAG. The framing assumes you pick one side and live with it.
When the harness is composed of workers on the same bus, thin vs thick is just a count of how many workers you install. A thin harness is turn-orchestrator plus provider-anthropic plus auth-credentials plus a minimal harness meta-worker. That's it. No approvals, no budgets, no policy engine, no hook fanout. Run anything. Trust the model. Useful for autonomous research agents, experimental loops, anything internal.
A thick harness is all thirteen workers plus context-compaction plus a custom policy worker plus a custom approval-gate plus a Slack-integrated approval surface plus the budget worker enforcing per-workspace caps. Useful for an agent running customer workflows where every tool call needs to be auditable and every model spend has to roll up to a finance dashboard.
The architectural distance between thin and thick isn't a rewrite. It's a config change. Same wire protocol, same trace shape, same observability story. The slider moves by adding and removing workers from your config.yaml. Everything else holds.
It applies inside a single worker too. The turn-orchestrator just shipped a refactor that collapsed its FSM from eleven states to seven, deleted the per-call turn::approval_resume::<sid>/<cid> mechanism in favour of one reactive turn::on_approval state trigger on scope approvals, and inlined tearing_down into a finishSession() port. Every other worker in the stack (approval-gate, session, llm-budget, providers, models-catalog, auth-credentials, hook-fanout, context-compaction) stayed unchanged. The approval::resolve wire shape didn't move. The contracts held. That's the property the composition gives you: a major internal rewrite of one worker is a self-contained change because every neighbour talks to it through bus-level function ids.
This is the part the framework model can't give you. A framework picks a position on the slider for you and locks you in. The worker model leaves the slider in your hand.
## What this means in practice
If you've been running an agent on top of a framework and feeling the same boundary problems most teams hit at scale, the answer is probably not "rewrite the harness in our own framework." The policy engine doesn't extend the way you need. The approval UI is wired into the framework's chat surface. The credential store can't talk to your secrets manager. The budget tracker is in a sidecar database the trace can't see. The answer is to switch to a substrate where the harness is decomposed in the first place.
The fastest way to feel the argument is to clone github.com/iii-hq/workers, pnpm install, pnpm build, and run the composite entry point. You'll get the full fourteen-worker harness pointed at an iii engine. You can disable any worker by removing its entry from the boot list. You can swap any worker by writing a replacement that registers the same function ids. You can extend any worker by adding a subscriber to its hook topics. hook-fanout::publish_collect is the generic every iii hook builds on.
The docs live at iii.dev/docs. The engine is at github.com/iii-hq/iii. The worker registry is at workers.iii.dev. The harness bundle is at github.com/iii-hq/workers/harness.
## The bet
A harness is not a thing you install. A harness is a set of jobs your system has to do for an agent to run durably, safely and observably. The framework era bundled those jobs together because nothing underneath gave you a way to compose them.
iii's bet is that one primitive: a worker that connects to the engine over WebSocket and registers functions and triggers is small enough to absorb every one of those jobs separately, and that the resulting stack is more useful than any framework because every layer is independently replaceable.
You don't adopt the iii harness. You install the workers you want, write the ones you need, and end up with a harness shaped exactly like your system. Same protocol on every layer. Same trace across every call. Same iii worker add for the parts you take from the registry as for the parts you publish yourself.
That's what "build your own agent harness" looks like when the substrate is the right shape. Pick the workers. Write the missing ones. Compose. The harness is the composition.
Join us in building the perfect agent harness that the modern world needs: discord.gg/iiidev
iii is open source. Get started at iii.dev/docs. The harness workers are at github.com/iii-hq/workers and the engine is at github.com/iii-hq/iii.
— Mike Piccolo, Founder & CEO @iiidevs
## 相关链接
- [Mike Piccolo](https://x.com/mfpiccolo)
- [@mfpiccolo](https://x.com/mfpiccolo)
- [57K](https://x.com/mfpiccolo/status/2060069083878408689/analytics)
- [Pi agent](https://github.com/earendil-works/pi/tree/main/packages/coding-agent)
- [iii](https://iii.dev/)
- [workers.iii.dev](https://workers.iii.dev/)
- [iii-hq/workers](https://github.com/iii-hq/workers/tree/main/harness)
- [github.com/iii-hq/workers/harness](https://github.com/iii-hq/workers/tree/main/harness)
- [iii-sandbox](https://github.com/iii-hq/iii/tree/main/crates/iii-worker/src/sandbox_daemon)
- [iii.session.id](https://iii.session.id/)
- [iii.message.id](https://iii.message.id/)
- [iii.function.id](https://iii.function.id/)
- [github.com/iii-hq/workers](https://github.com/iii-hq/workers)
- [iii.dev/docs](https://iii.dev/docs)
- [github.com/iii-hq/iii](https://github.com/iii-hq/iii)
- [workers.iii.dev](https://workers.iii.dev/)
- [github.com/iii-hq/workers/harness](https://github.com/iii-hq/workers/tree/main/harness)
- [discord.gg/iiidev](https://discord.gg/iiidev)
- [iii.dev/docs](https://iii.dev/docs)
- [github.com/iii-hq/workers](https://github.com/iii-hq/workers)
- [github.com/iii-hq/iii](https://github.com/iii-hq/iii)
- [@iiidevs](https://x.com/@iiidevs)
- [Upgrade to Premium](https://x.com/i/premium_sign_up)
- [2:41 AM · May 29, 2026](https://x.com/mfpiccolo/status/2060069083878408689)
- [57.5K Views](https://x.com/mfpiccolo/status/2060069083878408689/analytics)
- [View quotes](https://x.com/mfpiccolo/status/2060069083878408689/quotes)
---
*导出时间: 2026/5/29 09:47:46*
---
## 中文翻译
# 如何构建你自己的 Agent 套件???
**作者**: Mike Piccolo
**日期**: 2026-05-28T18:41:58.000Z
**来源**: [https://x.com/mfpiccolo/status/2060069083878408689](https://x.com/mfpiccolo/status/2060069083878408689)
---

大多数 Agent 团队并不构建自己的套件,而是采用现成的。LangChain、LangGraph、OpenAI Agents SDK、Anthropic SDK、CrewAI、AutoGen——循环、工具、记忆和编排都作为一个单一决策被拿来即用。套件是你引入的一个框架。如果其中的某些部分不合适,你就 Fork 它、对抗它,或者绕过它。

我认为这种形态是错误的,这也是为什么每一个长期运行的 Agent 团队最终都会从头重写自己的套件。套件不是单一的东西。它是十几个不同事物的捆绑,仅仅因为周围的生态系统没有提供一种将它们组合起来的方式。Pi agent 包走在了正确的轨道上,但它们仍处于“增加另一个服务并将其与所有其他服务集成”的范式之中。iii 引擎将所有 Worker 一视同仁,并完全移除了集成逻辑。提供商路由器、凭证保管库、策略引擎、审批网关、模型目录、会话存储、预算追踪器、通话后 Hook 扇出以及持久的轮转循环,这些都是独立的关注点。它们都可以与你的队列、HTTP/API 服务器、流媒体甚至浏览器 Worker 互操作。一个将它们打包成整块的框架是在向你兜售一种你本不必做出的权衡。
iii 背后的赌注是,它们不应该是一整块。应该是在一个共享引擎上的一组 Worker,每一个都可替换、每一个都独立版本化、每一个都通过单一原语连接:一个 `iii.trigger()`,其他所有 Worker 也使用它。套件变成了一堆可安装的 Worker,“构建你自己的”不再意味着“Fork 一个框架”,而是意味着“替换几个 Worker”。
这篇文章将具体演示这到底长什么样。驱动 iii Agent 轮转的完整技术栈,为什么每一层都是它自己的 Worker,以及你如何替换其中的任何一个。
## Agent 套件必须承担的 15 项工作
如果你将一个生产级 Agent 套件剥离回其职责,你会得到一个看起来大致如下的列表:
1. 接受来自客户端的轮转请求并将其持久化
2. 解析被调用的任意模型提供商的凭证
3. 查询所选模型实际上能做什么(视觉、工具、流式传输、上下文窗口)
4. 驱动每次轮转的状态机,配置、流式传输助手、运行工具、操控、拆除
5. 加载并提供技能主体,描述每个函数的请求形态、错误代码和使用说明
6. 组装系统提示词、模式段落、身份前文、工作目录和默认技能附录
7. 在模型生成 Token 时将其流式传回给客户端
8. 在每次工具调用(这只是一个函数)运行前根据策略进行检查
9. 暂停需要人工决策的工具调用,并将答案路由回正确的轮转
10. 针对每个工作区或每个 Agent 的预算跟踪 LLM 支出
11. 在工具调用前后运行 Hook(日志记录、编辑、自定义副作用)
12. 将会话持久化为分支树,以便分支和恢复能够工作
13. 当上下文窗口填满时压缩会话历史
14. 发出 UI 订阅的事件流
15. 这是每一个正在构建 Agent 的公司都缺失的部分。在每一步都携带一个 OpenTelemetry 追踪,以便你可以对其进行调试
每一个严肃的 Agent 套件都涵盖了其中的大部分。昂贵的那些涵盖了所有。廉价的那些会偷工减料,并在投入生产后再去修补这些角落。现有的框架将它们捆绑成单体,并为每一部分发布一个版本。最后一部分才是真正耗费成本的地方,因为一年后,你会发现你想要的策略引擎并不是框架自带的那个,而替换它意味着替换整个套件。
iii 套件将这十三项工作中的每一项都作为 workers.iii.dev 注册表上的一个独立 Worker 发布。每一个都讲同样的 WebSocket 协议。每一个都在同一个引擎总线上注册函数和触发器。每一个都是 iii worker 可添加、可交换的,并且可以用任何带有 SDK 的语言编写。
## 技术栈,按 Worker 划分
这是来自 iii-hq/workers monorepo 的实际生产级技术栈,每个 Worker 的职责用一行字概括。整个包发布于 github.com/iii-hq/workers/harness:

十一个 Worker。一个引擎。每一个都处于一个已发布的版本。每一个都可以独立运行,既可以作为独立进程(开发中通过 `pnpm dev:<worker>`,发布版通过 `iii worker add <specific-worker>`),也可以作为将它们一起启动的复合入口点的一部分。
这很重要的原因:该表格中的每一个框都是一个别人可以给你提供不同 Worker 的地方,而你保留其余部分。不喜欢静态的模型目录?插入一个注册 `models::list` 并从实时 API 读取的 Worker。不喜欢基于文件的凭证?插入一个注册 `auth::get_token` 并从密钥管理器读取的 Worker。想要一个用于不同分支工作流的不同轮转 FSM?替换 `turn-orchestrator`,所有依赖项都通过同一个总线调用 `run::start` 并读取 `turn_state`,所以堆栈的其余部分保持不变。
## 循环实际上是如何运行的
一次轮转的形态如下所示,按照 Worker 触发的顺序遍历。
浏览器/CLI/聊天通过 `harness::trigger` 使用 `{session_id, message_id, payload}` POST 一个轮转。`harness` 元 Worker 将 payload 转发给 `run::start`。这个跳转的存在是为了让 OpenTelemetry span 包装器将会话和消息 ID 作为 baggage 播种,这会传播到堆栈中每个 Worker 内部的每一次嵌套 `iii.trigger` 调用。另一端的追踪树是一张连通的图。
`run::start` 落在 `turn-orchestrator` 上。它持久化运行请求,在 `iii state` 的 `session/<sid>/turn_state` 中播种初始的 `TurnStateRecord`,并立即返回。实际工作发生在持久的每状态机内部,由对 `turn-step` FIFO 的发布唤醒。
两个终端状态是 `stopped`(通过 `finishSession()` 干净退出)和 `failed`(意外的处理程序抛出会路由到这里,确认队列以便停止重试,并抛出 `message_complete{stop_reason:'error'}` 加上 `agent_end`,以便 UI 显示原因)。拆除是一个内联的 `finishSession()` 端口,从任何轮转结束路径调用,而不是一个单独的入队步骤。
`provisioning` 做三件事。如果运行需要隔离执行,它会启动一个 `iii-sandbox` 微虚拟机。它为 `system_default_skills` 中的每个命名空间(默认为 `["iii://iii-directory/index"]`)调用 `directory::skills::download`,以便 `iii-directory` 预缓存运行开始时所需的技能主体。它分三层组装系统提示词:一个从 `run_request.mode`(plan、ask 或 agent)中选取的模式段落,教导模型 `agent_trigger` 约定和 `directory::skills::get` 按需发现模式的 iii 身份前文,以及 Agent 启动时默认技能的附加索引。调用者可以通过在 `run::start` 时传递 `system_prompt` 来覆盖整个提示词;否则编排器会构建它。函数模式来自实时的引擎目录。
`assistant_streaming` 调用匹配运行的 `provider` 字段的任意提供商 Worker 上的 `provider::<name>::stream`。提供商 Worker 通过 `auth::get_token`(`auth-credentials`)拉取凭证,将模型的 SSE 响应流式传输到一个 iii 频道,编排器排出该频道,并在 `agent::events` 上发出 `message_update` 事件以供 UI 扇出。频道创建和读取循环位于 `provider-stream.ts` 中基于拉取的 `MessagePump` 后面,因此流式传输状态专注于转换。
当助手返回工具调用时,FSM 进入 `function_execute`。每个工具调用都经过 `dispatchWithHook`,这是编排器中的单一瓶颈。`consultBefore` 以 5 秒超时直接调用 `policy::check_permissions`。策略 Worker(在默认堆栈中是 `harness` 元 Worker)读取 `iii-permissions.yaml`,将调用的 `function_id` 与规则集匹配,并返回三种结果之一:
- `allow`:调度继续进行;编排器触发目标函数并写入结果
- `deny`:调度以 `DenialEnvelope` 短路,结果变成拒绝记录
- `needs_approval`:单个调用暂停进入轮转的 `awaiting_approval` 列表。批次中的其余部分继续调度。仅当一个或多个条目待处理时,轮转才会转换到 `function_awaiting_approval`。
审批唤醒是响应式且共享的。编排器在 `approvals` 范围上只注册一个 `turn::on_approval` 状态触发器。当控制台调用 `approval::resolve` 时,`approval-gate` Worker 将 `approvals/<sid>/<cid> = {decision, reason}` 写入 iii state。该写入触发 `turn::on_approval`,从而推进受影响的会话。`function_awaiting_approval` 只读取刚刚落地的决策,在到达时分发每一个(allow 变成预批准的调度,deny 或 aborted 变成合成的拒绝),并在 `awaiting_approval[]` 为空时推进。无需注册每个调用的恢复函数。无需启动时重新扫描以恢复待处理的审批。一个触发器覆盖每个会话。
通过设计实现“故障安全”:如果策略 Worker 不可达或 5 秒超时触发,`consultBefore` 会返回一个 `gate_unavailable` 信封并拒绝调用。如果 `iii::durable::publish` 本身出错,Hook 扇出会返回 `publish_failed: true`,编排器将其视为拒绝。
这种形态带来了一些延迟上的优势。当没有为主题注册持久订阅者时,后函数调用 Hook 通过订阅者存在缓存短接 `publish_collect`,从而每次执行的函数调用减少约 500ms。`tearing_down` 内联到 `finishSession()` 中,每次轮转减少一次持久队列跳转。`context-compaction` 订阅编排器在轮转边界发出的专用 `agent::turn_end` 流,因此压缩器唤醒是按轮转而不是按事件。会话创建扇出状态触发器仅按范围匹配并在进程中匹配,因此先前的每次写入 `harness::session::is_create_event` RPC 已消失。
批次完成后,`steering_check` 决定是继续、停止还是达到 `max_turns`。如果继续,循环回 `assistant_streaming`。如果停止或达到最大值,`finishSession()` 内联运行:发出 `agent_end`,释放沙盒,转换到 `stopped`。
在整个运行过程中,参与的每个 Worker 都会发出带有 `iii.session.id`、`iii.message.id` 和 `iii.function.id` 标签的 OTel spans。这些标签是引擎的 `engine::traces::group_by` 读取的标签,用于在追踪 UI 中填充“按会话分组”/“按消息分组”/“按函数分组”。检测是自动的:`src/runtime/worker.ts` 将每个 `registerFunction` 包装在 Proxy 中,因此无需每个 Worker 的代码记住添加 spans。
## 构建你自己的
有趣的是,上述 Worker 都没有特别的。每个都是一个打开 WebSocket 连接到引擎、注册一些函数和触发器并运行的进程。该契约与每个应用 Worker 使用的契约相同。套件构建在原语之上,你的业务逻辑也构建在同一原语之上。
这意味着“构建你自己的套件”分解为与“编写任何 Worker”相同的操作。你选择要替换的层,编写一个 Worker 在总线上注册相同的函数,`iii worker add` 它,堆栈的其余部分就开始使用你的 Worker。
两层没有出现在上面的 Worker 表中,但对套件的行为很重要。技能是每个 Worker 宣传其功能的方式。每个 Worker 都可以在 `iii://<worker>/<function>` 发布一个技能,Agent 在第一次调用该函数之前通过 `directory::skills::get` 获取它。系统提示词从模式段落、iii 身份前文和运行配置的默认技能主体按轮转组装。两者都是总线驱动的:技能由 `iii-directory` Worker 提供服务,系统提示词由 `turn-orchestrator` 组装。两者都是可替换的。
五个具体的例子。
用实时 API 替换模型目录。编写一个注册 `models::list`、`models::get`、`models::supports` 的 Worker。让它每 N 分钟从你的提供商目录端点获取并缓存。发布它。`iii worker add your-org/dynamic-models-catalog`。停止静态 `models-catalog` Worker。`turn-orchestrator` 从未察觉差异。它调用 `iii.trigger('models::list')`,引擎路由到最近注册该函数 ID 的任意 Worker。
添加一个新的提供商。`provider-kimi` 和 `provider-lmstudio` 的形状已经证明了这一点。每一个都是一个注册 `provider::<name>::stream` 和 `provider::<name>::complete` 的 Worker,将来自上游 API 的 SSE 流排入 iii 频道,并通过 `budget::record` 将其模型使用情况写入 `llm-budget`。添加第五个提供商就是编写一个包含一个 `iii.worker.yaml` 和一个 `register.ts` 的文件夹。发布到注册表,或保留在本地。`turn-orchestrator` 通过运行的 `provider` 字段选择提供商;新提供商在 Worker 连接的瞬间立即可用。
从私有工件存储提供技能。编写一个注册 `directory::skills::get` 和 `directory::skills::list` 的 Worker,由你的内部文档系统或私有 S3 存储桶支持。断开连接或重命名默认的 `iii-directory` Worker。编排器的引导过程按命名空间调用 `directory::skills::download`;你的 Worker 回答。Agent 的“在调用新函数之前获取每个函数的技能”模式继续不变工作,因为线路形状是相同的。
完全覆盖系统提示词。`run::start` 接受一个可选的 `system_prompt` 字段。传递它,编排器将逐字使用你的字符串,跳过模式段落 + 身份前文 + 技能附录的组装。当你有一个希望套件不加修改地尊重的现有提示词资产时,这很有用。技能下载仍在引导中运行,因此 Agent 即使使用自定义提示词也能保持 `directory::skills::get` 按需发现。
替换审批网关 UI 界面。默认的 `approval-gate` Worker 注册 `approval::resolve`。线路模式是一个函数调用:
```
iii.trigger('approval::resolve', {
session_id: '...',
function_call_id: '...',
decision: 'allow' | 'deny' | 'aborted',
reason: 'optional human text',
})
```
处理程序将 `approvals/<sid>/<cid> = {decision, reason}` 持久化到 iii state。编排器的单一 `turn::on_approval` 状态触发器接收到该写入并唤醒正确的会话。如果你想从 Slack 而不是控制台驱动审批,编写一个 Slack Worker,监听 `/approve <id>` 和 `/deny <id>` 斜杠命令,然后使用正确的 payload 调用 `approval::resolve`。编排器从未察觉差异。整个 `approval-gate` Worker 保持不变。你添加了一个新 Worker;你没有替换现有的那个。
如果你想要一个不同的策略引擎(OPA、Cedar、你自己的 DSL),编写一个注册 `policy::check_permissions` 并返回 `{ decision, rule_id?, matched_constraint? }` 的 Worker。断开默认策略 Worker(它包裹在 `harness` 元 Worker 内部,所以你会禁用该处理程序或运行一个精简版的元 Worker)。`turn-orchestrator` 的 `consultBefore` 从未察觉差异。同样的 5 秒超时,同样的故障安全语义,同样的线路形状。
这些例子的重点不在于具体的替换。而在于操作的形态。iii 堆栈中的每个套件层都可以通过总线上的一个或两个函数 ID 访问。替换一层就是编写一个注册这些 ID 的 Worker。系统的其余部分保持不变。
## 套件是一个滑块,而不是道路的分岔
经典的套件辩论将其定位于“薄”与“厚”的框架之争。