Glean 拾遗
日刊 /2026-06-02 / 用可替换 Worker 构建你自己的 Agent 控制框架——iii 架构详解

用可替换 Worker 构建你自己的 Agent 控制框架——iii 架构详解

原文 x.com 收录 2026-06-02 06:00 阅读 20 min
AI 解读

iii 提出了一种不同于 LangChain 等 Agent 框架的架构:将 Agent 运行所需的 15 项职责拆分为独立的 Worker(如 turn-orchestrator、auth-credentials、policy、approval-gate 等),每个 Worker 通过 WebSocket 连接到同一引擎,注册函数与触发器,通过 iii.trigger() 共享总线通信。这种设计使得每一层都可独立替换——想换模型目录就写一个注册 models::list 的 Worker,想加新提供商就写一个注册 provider::<name>::stream 的 Worker,无需修改其余堆栈。文章详细展示了从 turn 请求到 function execute、approval、streaming 的完整循环,以及每个 Worker 的职责和替换示例。整个堆栈开源(github.com/iii-hq/workers),支持任何语言编写 Worker。适合正在搭建或受困于现有框架的 Agent 团队阅读。

原文 20 分钟
原文 x.com ↗
§ 1

How to build your own agent harness???

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.

How to build your own agent harness???

大多数 agent 团队并不构建 harness。他们直接采用现成的。LangChain、LangGraph、OpenAI Agents SDK、Anthropic SDK、CrewAI、AutoGen,其中的循环、工具、记忆和编排都是作为一个整体决策从货架上取用的。Harness 即你导入的框架。如果里面有什么不合用,你就 fork 它、与之抗争,或者绕开它。

我认为这种形态是错误的,这也是每个长期运行的 agent 团队最终都从零重写其 harness 的原因。Harness 并非单一事物。它是十到十二种不同的事物捆绑在一起,因为周边生态没有给你组合它们的方式。Pi agent 包的方向对了,但仍处于“再加一个服务并让它与所有其他服务集成”的范式之中。iii 引擎将所有 worker 一视同仁,并完全去除了集成逻辑。提供方路由器、凭证库、策略引擎、审批门、模型目录、会话存储、预算追踪器、调用后钩子扇出以及持久化 turn 循环都是独立的关注点。它们都可以与你的队列、HTTP/API 服务器、流式传输、甚至浏览器 worker 互操作。一个将它们作为一个整体交付的框架是在向你兜售你本不必做的取舍。

iii 的赌注是它们不应是一个整体。应该有一组运行在共享引擎上的 worker,每个都可替换,每个都独立版本化,每个都通过一个单一的原始机制相连:即每个其他 worker 也使用的触发器(iii.trigger())。Harness 变成了一组可安装的 worker,而“自己构建”不再意味着“fork 一个框架”,而是“替换几个 worker”。

本文逐步展示那实际是什么样子。今天驱动 iii agent turn 的完整栈,为什么每一层都是它自己的 worker,以及你如何替换其中任何一个。

§ 2

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.

agent harness 必须做的十五件事

如果你将一个生产级 agent harness 剥离到只剩其职责,你会得到大致像这样的清单:

  1. 接受来自客户端的 turn 请求并将其持久化
  2. 无论调用哪个模型提供商,解析其凭证
  3. 查询所选模型实际能做什么(视觉、工具、流式传输、上下文窗口)
  4. 驱动每个 turn 的状态机:准备工作、流式传输助手、运行工具、引导、拆除
  5. 加载并提供技能体,描述每个函数的请求格式、错误码和使用说明
  6. 组装系统提示:模式段落、身份序言、工作目录和默认技能附录
  7. 在模型生成 token 时将它们流式传回客户端
  8. 在每个工具调用(那只是一个函数)运行前根据策略对其进行检查
  9. 暂停需要人工决策的工具调用,并将答复路由回正确的 turn
  10. 按工作空间或 agent 追踪 LLM 花费
  11. 在工具调用前后运行钩子(日志记录、脱敏、自定义副作用)
  12. 将会话作为分支树持久化,以便分支和恢复工作
  13. 当上下文窗口填满时压缩会话历史
  14. 发出一个 UI 订阅的事件流
  15. 我看到每个 agent 公司构建中缺失的一块:跨每一步携带一个 OpenTelemetry 跟踪,以便你能调试它 每个严肃的 agent harness 都会承担其中大部分职责。昂贵的那些会做全部。便宜的会偷工减料,等到了生产环境再重建那些角落。这些框架将它们捆绑成一个单体,并为每一项交付一个版本。这最后一点正是让你付出代价的部分,因为一年后,你发现你想要的策略引擎不是框架交付的那个,而替换它就意味着替换整个 harness。
§ 3

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.

iii harness 将这十三项工作分别作为一个单独的 worker 交付在 workers.iii.dev 注册表中。每个都讲相同的 WebSocket 协议。每个都在相同的引擎总线上注册函数和触发器。每个都可以通过 iii worker add 添加、替换,并可用任何有 SDK 的语言编写。

§ 4

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.

栈:按 worker 列出

这是来自 iii-hq/workers 单体仓库的实际生产栈,每个 worker 的职责用一行概括。整个包发布在 github.com/iii-hq/workers/harness:

十一个 worker。一个引擎。每个都有已发布的版本。每个都可以作为独立进程运行(开发中 pnpm dev:<worker>,发布二进制时 iii worker add <特定worker>),或者作为将它们一起启动的组合入口的一部分。

这很重要的原因是:表中的每一个方框都是别人可以给你一个不同 worker 的地方,而你保留其余部分。不喜欢静态模型目录?接入一个注册了 models::list 并从实时 API 读取的 worker。不喜欢基于文件的凭证?接入一个注册了 auth::get_token 并从秘密管理器读取的 worker。想要一个针对分支不同工作流的不同 turn FSM?替换 turn-orchestrator,每个依赖者都通过同一总线调用 run::start 并读取 turn_state,因此栈的其余部分不变。

§ 5

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.

循环实际如何运行

一次 turn 的形态如下,按 worker 触发的顺序逐步走过。

浏览器/CLI/聊天工具通过 harness::trigger POST 一个 turn,带有 {session_id, message_id, payload}。Harness 元 worker 将 payload 转发给 run::start。这个跳转的存在是为了让 OpenTelemetry span 包装器能将 session 和 message ID 作为 baggage 植入,并传播到整个栈中每个 worker 的每个嵌套 iii.trigger 调用。另一端的跟踪树是一张相连的图。

§ 6

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.

run::start 落在 turn-orchestrator 上。它持久化运行请求,在 iii 状态的 session/<sid>/turn_state 中植入初始的 TurnStateRecord,并立即返回。实际工作在内部的持久化按状态机中进行,由发布到 turn-step FIFO 的消息唤醒。

两个终态是 stopped(通过 finishSession() 干净退出)和 failed(意外的 handler 抛出路由到这里,确认队列使其停止重试,并呈现 message_complete{stop_reason:'error'} 加上 agent_end,以便 UI 显示原因)。拆卸是一个内联的 finishSession() 端口,从任何 turn 结束路径调用,而不是一个单独的入队步骤。

§ 7

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.

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 来覆盖整个提示;否则由编排器构建。函数 schema 来自实时引擎目录。

§ 8

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.

assistant_streaming 在匹配运行中 provider 字段的任一 provider worker 上调用 provider::<name>::stream。Provider worker 通过 auth::get_token(auth-credentials)拉取凭证,将模型的 SSE 响应流式传输到一个 iii 通道中,编排器再消耗该通道,在 agent::events 上发出 message_update 事件用于 UI 扇出。通道创建和读取循环位于 provider-stream.ts 中基于拉取的 MessagePump 之后,以便流式状态专注于状态转换。

§ 9

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.

当助手返回工具调用时,FSM 进入 function_execute。每个工具调用都通过 dispatchWithHook,即编排器中唯一的卡口。consultBefore 直接调用 policy::check_permissions,带 5 秒超时。策略 worker(默认栈中为 harness 元 worker)读取 iii-permissions.yaml,将调用的 function_id 与规则集匹配,并返回三种结果之一:

  • allow:调度继续;编排器触发目标函数并写入结果
  • deny:调度用 DenialEnvelope 短路,结果变成拒绝记录
  • needs_approval:单个调用停入该 turn 的 awaiting_approval 列表。批次中的其余调用继续调度。仅当至少一项待处理时,turn 才转换到 function_awaiting_approval。 审批唤醒是响应式且共享的。编排器在 scope approvals 上注册恰好一个 turn::on_approval 状态触发器。当控制台调用 approval::resolve 时,approval-gate worker 将 approvals/<sid>/<cid> = {decision, reason} 写入 iii 状态。该写入触发 turn::on_approval,进而推进受影响的会话。function_awaiting_approval 仅读取刚刚到达的决策,收到一个就分发一个(allow 变为预先批准的分发,deny 或 aborted 变为合成的拒绝),当 awaiting_approval[] 为空时前进。无需注册每个调用的恢复函数,也无需启动时重新扫描来恢复待处理的审批。一个触发器覆盖所有会话。

构造上即为故障关闭:如果策略 worker 不可达或 5 秒超时触发,consultBefore 将以 gate_unavailable 信封拒绝调用。如果 iii::durable::publish 自身出错,钩子扇出返回 publish_failed: true,编排器将其视为拒绝。

§ 10

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.

这种形态带来了一些延迟优势。函数调用后钩子通过订阅者存在缓存短路 publish_collect,当没有为话题注册持久订阅者时,每个执行的函数调用可去除约 500 毫秒。tearing_down 被内联到 finishSession() 中,每个 turn 减少一次持久队列跳跃。context-compaction 订阅编排器在 turn 边界发出的专用 agent::turn_end 流,所以压缩器唤醒是按 turn 而不是按事件的。session-create 扇出状态触发器仅按 scope 闸控并在进程内匹配,因此之前的每次写入 harness::session::is_create_event RPC 被消除了。

批次完成后,steering_check 决定是继续、停止还是达到最大 turn 数。如果继续,循环回 assistant_streaming。如果停止或达到最大,finishSession() 内联运行:发出 agent_end,释放沙箱,转换到 stopped。

§ 11

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.

在整个运行过程中,每个参与的 worker 都会发出带有 iii.session.idiii.message.idiii.function.id 标签的 OTel span。这些标签就是引擎的 engine::traces::group_by 读取以填充跟踪 UI 中“Group by Session”/“Group by Message”/“Group by Function”的内容。埋点是自动的:src/runtime/worker.ts 将每个 registerFunction 包装在一个 Proxy 中,因此每个 worker 的代码无需记得添加 span。

§ 12

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.

构建你自己的

有趣的是,以上这些 worker 没有一个特殊。每一个都是一个进程,开启一个到引擎的 WebSocket,注册一些函数和触发器,然后运行。这个契约与每个应用 worker 使用的契约相同。Harness 构建在与你的业务逻辑相同的原语之上。

这意味着“构建你自己的 harness”分解为与“编写任意 worker”相同的操作。你选择要替换的层,编写一个在总线上注册相同函数的 worker,通过 iii worker add 添加它,栈的其余部分就开始使用你的 worker。

有两个层未出现在上文的 worker 表中,但对 harness 的行为很重要。技能是每个 worker 宣传其功能作用的方式。每个 worker 都可以在 iii://<worker>/<function> 发布一项技能,agent 在首次调用该函数之前通过 directory::skills::get 获取它。系统提示是按 turn 由模式段落、iii 身份序言和运行所配置的默认技能体组装而成。二者都是总线驱动的:技能由 iii-directory worker 提供,系统提示由 turn-orchestrator 组装。二者都是可替换的。

§ 13

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.

用实时 API 替换模型目录。编写一个 worker,注册 models::list, models::get, models::supports。让它每隔 N 分钟从你的提供商目录端点获取并缓存。发布它。iii worker add your-org/dynamic-models-catalog。停止静态的 models-catalog worker。turn-orchestrator 完全不知道其中的区别。它调用 iii.trigger('models::list'),引擎会路由到最近注册该函数 ID 的 worker。

§ 14

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.

新增一个提供商。provider-kimi 和 provider-lmstudio 的形式已经证实可行。每一个都是一个 worker,注册 provider::<name>::stream 和 provider::<name>::complete,将来自上游 API 的 SSE 流导入 iii 通道,并通过 budget::record 将模型使用量写入 llm-budget。添加第五个提供商只需编写一个文件夹,内含一个 iii.worker.yaml 和一个 register.ts。发布到注册表,或保留在本地。turn-orchestrator 根据运行时的 provider 字段选择提供商;新提供商在 worker 连接上的瞬间即可用。

§ 15

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.

从私有工件仓库提供技能。编写一个 worker,注册 directory::skills::get 和 directory::skills::list,由你的内部文档系统或私有 S3 存储桶支持。断开或重命名默认的 iii-directory worker。编排器的引导程序会按命名空间调用 directory::skills::download;你的 worker 响应。agent“在调用新函数之前获取每个函数的技能”的模式保持不变,因为线缆格式相同。

§ 16

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.

完全覆盖系统提示。run::start 接受一个可选的 system_prompt 字段。传入它,编排器就会逐字使用你的字符串,跳过模式段落+身份序言+技能附录的组装。当你已有一个希望 harness 不作修改就尊重的提示资产时很有用。技能下载仍在引导程序中运行,因此 agent 即使在自定义提示下也保留 directory::skills::get 按需发现。

§ 17

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.

替换审批门 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 状态。编排器唯一的 turn::on_approval 状态触发器会捕捉该写入并唤醒正确的会话。如果你希望从 Slack 而不是控制台驱动审批,编写一个 Slack worker,监听 /approve <id> 和 /deny <id> 斜杠命令,然后以正确的负载调用 approval::resolve。编排器完全不知道其中的区别。整个 approval-gate worker 保持不变。你添加了一个新 worker;你没有替换已有的那个。

§ 18

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.

如果你想要一个不同的策略引擎(OPA、Cedar、你自己的 DSL),编写一个注册 policy::check_permissions 并返回 { decision, rule_id?, matched_constraint? } 的 worker。断开默认的策略 worker(它包装在 harness 元 worker 内部,因此你需要禁用该处理器或运行一个精简的元 worker)。turn-orchestrator 的 consultBefore 不知道其中的区别。同样的 5 秒超时,同样的故障关闭语义,同样的线缆格式。

§ 19

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.

这些示例的重点不在于具体的替换操作,而在于操作的形式。iii 栈中的每个 harness 层都可通过总线上的一个或两个函数 ID 访问。替换一个层就是编写一个注册这些 ID 的 worker。系统的其余部分保持不变。

§ 20

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.

harness 是一个滑块,不是岔路口

经典的 harness 讨论将其表述为轻量与重量之争。Anthropic 的轻量循环对比 LangGraph 的显式 DAG。这种表述假定你选择一边并与之共存。

当 harness 是由同一总线上的 worker 组成时,轻量与重量仅仅是你安装了多少个 worker 的计数。一个轻量 harness 就是 turn-orchestrator 加上 provider-anthropic 再加上 auth-credentials 再加上一个最小的 harness 元 worker。仅此而已。没有审批、没有预算、没有策略引擎、没有钩子扇出。可以运行任何东西。信任模型。对于自主研究 agent、实验性循环、任何内部用途都很有用。

一个重量 harness 则包括全部十三个 worker,再加上 context-compaction、自定义策略 worker、自定义 approval-gate、集成 Slack 的审批界面,以及执行每工作空间上限的 budget worker。对于运行客户工作流的 agent 很有用,其中每个工具调用都需要可审计,每笔模型花费都必须汇总到财务仪表盘。

轻量与重量之间的架构距离不是一次重写,而是一次配置变更。相同的线缆协议,相同的跟踪形状,相同的可观测性故事。通过从 config.yaml 中添加和移除 worker 来移动滑块。其他一切不变。

这也适用于单个 worker 内部。turn-orchestrator 刚刚交付了一次重构,将其 FSM 从十一个状态压缩到七个,删除了每个调用的 turn::approval_resume::<sid>/<cid> 机制,代之以一个作用于 scope approvals 的响应式 turn::on_approval 状态触发器,并将 tearing_down 内联到 finishSession() 端口中。栈中的每个其他 worker(approval-gate、session、llm-budget、providers、models-catalog、auth-credentials、hook-fanout、context-compaction)保持不变。approval::resolve 的线缆格式没有移动。契约得到了遵守。这就是组合赋予你的特性:一个 worker 的重大内部重写是一个自包含的变更,因为每个邻居都通过总线级的函数 ID 与之通信。

这是框架模型无法给予你的部分。框架为你选择滑块上的一个位置并把你锁定。Worker 模型将滑块留在你手中。

§ 21

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.

这在实践中意味着什么

如果你一直在某个框架之上运行 agent,并感受到大多数团队在规模化时会遇到的相同边界问题,那么答案可能不是“在我们自己的框架中重写 harness”。策略引擎无法按你需要的方式扩展。审批 UI 被硬连接到框架的聊天界面。凭证存储无法与你的秘密管理器通信。预算追踪器在跟踪程序看不到的边车数据库中。答案是切换到一个从一开始就将 harness 分解的基板。

最快感受该论点的方法是克隆 github.com/iii-hq/workers,pnpm install,pnpm build,然后运行组合入口。你将得到指向 iii 引擎的完整十四 worker harness。你可以通过从启动列表中移除某个 worker 的条目来禁用它。你可以通过编写注册相同函数 ID 的替换 worker 来交换任何 worker。你可以通过为某个 worker 的钩子话题添加订阅者来扩展它。hook-fanout::publish_collect 是每个 iii 钩子构建的通用基础。

文档在 iii.dev/docs。引擎在 github.com/iii-hq/iii。Worker 注册表在 workers.iii.dev。Harness 包在 github.com/iii-hq/workers/harness。

§ 22

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

赌注

Harness 不是你安装的东西。Harness 是系统为了让 agent 持久、安全且可观测地运行而必须执行的一组任务。框架时代将这些任务捆绑在一起,因为底层没有东西给你组合它们的方式。

iii 的赌注是一个原语:一个通过 WebSocket 连接到引擎并注册函数和触发器的 worker 足够小,可以单独吸收每一项任务,而且由此产生的栈比任何框架都更有用,因为每一层都是独立可替换的。

你不采纳 iii harness。你安装你想要的 worker,编写你需要的 worker,最终得到一个完全匹配你系统的 harness。每一层相同的协议。每次调用相同的跟踪。从注册表取用的部分和你自己发布的部分都可以通过相同的 iii worker add 添加。

这就是当基板形状正确时,“构建你自己的 agent harness”的样子。挑选 worker。编写缺失的 worker。组合。Harness 就是这种组合。

加入我们,一起构建现代世界所需的完美 agent harness:discord.gg/iiidev

iii 是开源的。从 iii.dev/docs 开始。Harness worker 在 github.com/iii-hq/workers,引擎在 github.com/iii-hq/iii。

— Mike Piccolo,创始人兼 CEO @iiidevs

打开原文 ↗