For the complete documentation index, see llms.txt. This page is also available as Markdown.

代理执行器

代理一次运行是如何进行的:工具调用循环、子代理分发、原型、分诊、钩子,以及围绕它们的成本和预算机制。

crates/openhuman-core/src/agent/ 是代理回合的主机端:工具调用循环及其方言、子代理分派与原型、会话与恢复、分诊、钩子、中断和成本核算,全部围绕 TinyAgents harness 封装,而不是重新实现它。

将 OpenHuman 嵌入为库

openhuman_embed 公开一个两步式 API。 Runtime::builder() 为每个进程启动一个进程内核心、后台服务、已注册的领域族、后端 URL 和 TinyHumans API 密钥,而 Runtime::agent(spec) 在其上实例化任意数量的代理。每个 AgentSpec 完整描述一个代理:提供商端点和模型、访问层级、 action_dir、MCP 服务器、自有进程内工具、技能包、系统提示词、工具作用域、沙箱模式、允许列表,以及缩小后的 DomainSet / ToolGroups。运行时标识为 HostKind::Library:推理不依赖 OpenHuman 应用登录,包括工作流代理节点的推理就绪检查。

嵌入器自有的工具

AgentSpec::tools 接收主机自身的 Box<dyn Tool> 对象;它们作为真正的工具到达模型,在传输中拥有自己的模式,并以自己的名称调用。在此之前,唯一的途径是 AgentSpec::mcp,而模型要为这种间接性付费:先进行发现调用以了解服务器提供什么,再使用一个 mcp_call_tool 封套,其内部的 arguments 对象没有任何提供商能够针对其验证或约束解码。

它接收的是工厂,而不是工具带。 Agent 是 Clone 而 Box<dyn Tool> 不是,并且规范背后的会话会在每个回合从 Config 重建,因此持有 dyn Tool 的任何内容都无法在回合之间存活。因此,该闭包每回合运行一次;这也意味着工具归属于生命周期短于代理的对象(一次事件、一个房间、一次任务)的主机,可以每次返回不同的工具带,而无需注册第二个代理。

实现 Tool 通过 openhuman_embed::Tool,而不是直接依赖 tinytools :通往该 crate 的第二条路径会产生不兼容的 Rust 类型,基于它构建的工具根本无法交给会话。

关于可变工具带的一个注意事项。提示词的工具目录由同一次构建中的同一条工具带渲染,因此在任何编写提示词的回合中两者保持一致;但恢复的会话会复用其持久化的系统消息,因此在长期存续线程下变动的工具带,会由该线程开启时的提示词来描述。仅在运行于独立会话的回合中变更工具带。

每代理隔离是一种上下文,而不是第二个核心。 Runtime::agent 克隆运行时的基础 Config,应用该规范,并派生出一个子 CoreContext (CoreContext::derive_with),携带该配置、代理的领域集、工具组和技能根策略。每个回合都在该上下文下分派(CoreRuntime::run_in → agent_chat_for 并带有显式的 AgentDefinition),因此配置加载器、领域门控、工具组过滤器和技能发现都会读取代理自身的设置。转录按代理 ID 键控,一个回合只恢复自己的线程。

运行时拥有的根目录下的布局: <root>/config.toml 以及凭据存储; <root>/workspace/ 包含会话数据库、 session_raw/ 转录,以及每个代理的 agents/<id>/skills/;以及 <root>/agents/<id>/action/ 作为每个代理的默认工作根目录(与 workspace 同级,绝不在其内部)。

Harness 是单代理简写:一个运行时加上一个名为 harness的代理。构建一个运行时,并发出并发的 run 或 turn(...).send() 调用到其代理;不要为每个代理构建一个核心。除非提供先前的会话 ID,否则每次调用都拥有不同的会话。

库模式下的认证是 TinyHumans API 密钥(RuntimeBuilder::api_key),它以 api-key 认证配置文件的形式存储在运行时的 config.toml旁边。托管推理将其作为 bearer 令牌发送到兼容 OpenAI 的端点;后端 REST 调用发送 x-api-key;没有会话 JWT,也没有任何会过期。自行指定 Provider (BYOK)的代理永远不会接触它。该密钥涵盖每项托管功能,受其后端作用域约束:推理、嵌入、语音、搜索、媒体、Composio 和其他集成、通道及套接字。只有会话绑定的 /auth/* 流程(OAuth 连接、通道链接令牌)仍会采用 HarnessBuilder::session.

Workspace::Inherit 连同 Provider::inherit() 以及没有 API 密钥,这刻意不是由库路由的推理。它借用已安装的 OpenHuman 配置,因此保留已安装应用的会话检查。无需应用登录进行嵌入时,请提供显式提供商或 API 密钥。

有些设置仍然对每个代理在运行时范围内生效(实时自主策略的 auto_approve*、审批门开关、子代理目录、配置子代理重新读取);crate README 在“仍为运行时范围”下列出它们。

每个代理回合都通过已发布的 tinyagents harness 运行。共有三个入口点: Agent::turn、通道和 CLI 总线路径,以及 run_subagent。它们全都经过位于 crates/openhuman-core/src/agent/tinyagents/ (TurnSpec::run)中的适配器接缝。上下文裁剪使用 MessageTrimMiddleware,取消使用 tinyagents 引导通道。策略停止钩子(预算、线程目标和迭代上限)通过 StopHookMiddleware (tinyagents/stop_hooks.rs)触发;它会在第一张停止票时暂停运行。通道路由会像聊天路由一样转发实时 AgentProgress 。共享的 TurnProgress 接缝位于 agent/session_host/tool_progress.rs.

多代理编排在 tinyagents 图层上运行,通过 tinyagents-orchestration的 parallel 模块、主机 spawn_parallel 适配器,以及从 graph::orchestration TaskStore 重新导出的共享生命周期原语 orchestration/mod.rs:

  • orchestration/delegation.rs 是一个 规划 → 执行 ⇄ 审核 → 完成 CompiledGraph。它具有条件路由、 RecursionPolicy、持久化的 FileCheckpointer、一个 CancellationToken 和一个 GraphTracingSink.

  • 工作流阶段引擎在图上扇出每个阶段的代理(with_max_concurrency)。持久化的 WorkflowRun 账本仍是恢复操作的事实来源。

  • spawn_parallel_agents 通过主机 spawn_parallel 之上的适配器执行其扇出 tinyagents_orchestration::parallel.

  • 代理团队成员运行时是一个条件路由图(执行 → 完成 | 失败 → 结束, agent_teams/runtime.rs).

  • 分离子代理注册表由一个类型化的 TaskStore 生命周期账本支持(Pending、Running,然后是 Completed、Failed 或 Cancelled)。

TinyAgents crate:功能与兼容性

OpenHuman 通过路径依赖,使用拆分的 TinyAgents 2.1 crate 家族,指向供应商化 git 子模块 vendor/tinyagents, tinyagents-harness (功能 sqlite, multimodal, builtin-tools), tinyagents-definition, tinyagents-graph (sqlite), tinyagents-registry, tinyagents-session, tinyagents-runtime, tinyagents-orchestration 以及可选的 tinyagents-live,再加上 tinyinference 来自 vendor/tinyagents/vendor/tinyinference,因此 SDK 变更可以在上游合并之前在树内测试(参见 crates/openhuman-core/Cargo.toml)。原因如下,以免未来升级导致退化:

  • 原生 TinyAgents 模型接口,OpenHuman 自有的产品策略。每条实时路由都是一个 Arc<dyn ChatModel<()>>:TinyAgents 的 OpenAI 兼容客户端覆盖在线等价的托管、本地和 BYOK 路由,而主机 ChatModel 实现覆盖 Claude SDK/Code 以及 Codex 专用传输。OpenHuman 仍拥有凭据解析、OAuth、访问门控、端点选择、出站披露、计费元数据和错误分类。

  • sqlite 启用具有一条原生 sqlite 链的功能。OpenHuman 的根目录和 Tauri Cargo 世界固定 rusqlite = "=0.40.2" (bundled);固定的 Rust 1.96 工具链提供其构建脚本所需的 cfg_select! 宏。两个世界都会解析为单一的 libsqlite3-sys 链。持久图检查点通过 TinyAgents 自己的 SqliteCheckpointer (参见 orchestration/delegation.rs).

  • WhatsApp Web 存储桥。 whatsapp-rust的 Diesel 支持的 sqlite-storage 功能将 sqlite 与 rusqlite 0.40 分别链接,因此可选的 whatsapp-web 功能(转发到 tinychannels/whatsapp-web)针对 wacore::store::InMemoryBackend 构建,并记录会话不是持久化的。在存在由 rusqlite 支持的持久化 WhatsApp 存储之前,Web 会话保持非持久化。

  • 没有脚本运行时。不存在 repl 或 Rhai 脚本功能,也没有 rhai_workflows 工具。Rhai 仍作为 tinyflows 自身 JSON 查询栈的依赖项出现,与代理 harness 无关。

  • 所有权映射:模型构建 → inference::provider::create_chat_model*;持久图检查点 → TinyAgents 的 SqliteCheckpointer;通用分离执行器状态 → DetachedTaskRegistry;面向控制器的持久化 → OpenHuman SQL/JSON 运行账本(running_subagents, workflow_runs, agent_teams, command_center)。通用 harness/图/中间件/事件原语按原样使用。

代理 harness 是将用户消息(或 webhook 触发、或 cron tick)转变为完整的、使用工具的 LLM 交互的运行时。它拥有工具调用循环、子代理分派、触发器分诊管道以及围绕它们的钩子表面。它不拥有提供商 HTTP 传输、工具实现、提示词分段组装或记忆存储。这些是 harness 所组合的独立领域。

本页介绍一个回合中发生的情况,然后深入查看每个活动部件。

一个回合的形态

每个回合都经历相同的生命周期,无论是用户刚输入消息、Telegram webhook 触发,还是上午 9 点 cron 触发:

本页的其余部分是同一张图的展开版。

会话和 Agent::turn

会话是一个 Agent 实例正在运行的实时对话。该 Agent 结构体拥有:

  • 对话历史(系统 + 用户 + 助手 + 工具消息)。

  • 要调用的提供商客户端(模型由 模型路由器).

  • 解析)。

  • 该 memory 工具(当记忆引擎开启时),模型通过它按需回忆、获取、学习和遗忘。

  • 每回合预算:最大工具迭代次数、最大负载大小、最大美元成本。

  • 本地操作预算:对具有副作用的工具操作设置滚动小时上限,读取自 config.autonomy.max_actions_per_hour.

Agent::turn(user_message) 是热路径。在一个回合中,它:

  1. 如果这是新进程,则恢复会话转录,从磁盘重新加载精确的提供商消息,使推理后端的 KV 缓存前缀仍然命中。

  2. 构建系统提示词(仅在第一个回合)。这会引入身份、灵魂、档案、记忆、已连接集成、可用工具和安全前言,由提示词分段构建器组装。

  3. 召回记忆包。TinyMemory 的代理生命周期会记录用户回合,并召回受令牌预算限制的包(学习内容和信念、脑文档、该代理的历史、其他代理的回合),封装在 <memory-context> 中,并暂时添加到该回合的模型请求;它永远不会持久化到转录中,因此不影响提示词缓存。模型仍可调用 memory 工具以获取带引用的回答(参见 记忆架构).

  4. 进入工具调用循环(下一节)。

  5. 在后台启动回合后钩子:在成本记录和对话记录完成前,用户便会获得答案。

系统提示词不会在后续回合重新构建。即便是外观上的字节变更也会使 KV 缓存前缀失效,并强制完整重新预填充,因此动态的每回合上下文(工具结果、记忆召回答案)会作为用户可见的消息内容追加,而不是拼接进系统提示词。

可缓存前缀比系统提示词更宽

冻结提示词只是该约定的三分之一,另外两项更容易破坏,因为它们看起来与缓存毫无关系:

  • 工具块也要计入,而且它排在 最前面。生产环境中的每个前缀缓存都会在对话之前渲染工具目录;聊天模板必须将其放在模型会在第一个用户回合之前读取的位置。OpenAI 的自动缓存、Anthropic 的 cache_control (工具 → 系统 → 消息)、DeepSeek 的上下文缓存以及任何 vLLM/SGLang 基数缓存都以这种方式工作。因此,回合之间发生变化的 tools 数组也会使冻结的系统提示词失效;请求体的 JSON 键顺序(它将 messages 放在 tools之前)对此毫无影响。会话中途的 Composio 协调仍然是正确的——工具表面对模型可调用内容说谎比冷预填充更糟——但它必须只在真正的能力变更时触发,否则保持字节稳定。 connected_set_hash 在哈希前进行排序,因此重新排序的后端响应永远不会导致重建,而 collect_orchestrator_tools 出于同样原因对已连接工具包枚举进行排序。

  • 历史记录必须只能追加。第 N 回合的序列化必须逐字保留,作为第 N+1 回合的开头。 pair_tool_cycles 在序列化时会丢弃未完成一半的工具循环,因此它对某个条目的判断必须只依赖该条目及其紧邻的前后项,绝不能依赖之后追加的任何内容,否则早先某条消息的存在会被追溯性地翻转,前缀也会在缓存下移动。上下文压缩是唯一有意的例外;它会重写中间部分,并为一次重新预填充付出代价。

已恢复会话的重放前缀会合并到 Agent::history 中,而不是拼接进一次请求里,因此请求、随后的轮次以及持久化的转录都会读到同一序列。把它拼进去会让对话付出双倍代价:下一轮会在没有它的情况下发出,而之后写出的转录(序列化自 history)只保留了新一轮,而 下一次 恢复时又会读回它,于是每次重启都会把线程再截短一点。

只有在线上协议明确这么说时,稳定前缀才会带来收益

保持字节稳定是必要条件,但不充分:提供方仍然必须被 告知 去缓存,而且还得明确告知两个提供方。

  • Anthropic 不会在没有 cache_control 标记 的情况下缓存任何内容,而且其 OpenAI 兼容端点无法携带这些标记;Anthropic 文档将该路径上的提示缓存描述为不受支持,并报告 prompt_tokens_details 始终为空。 一个 auth_style = "anthropic" 的 cloud_providers 条目因此会构建为 crate 原生的 Messages 适配器(tinyinference_llm::providers::anthropic),它会在最后一个工具、最后一个系统块以及最终消息上放置标记,因此不断增长的工具循环会复用上一轮的缓存,而不只是系统提示。唯一的例外是文本模式(native_tools = false,提示词引导的工具),它只由 Chat Completions 适配器实现,并且仍然使用兼容客户端,因此依旧要支付全价。

  • OpenRouter 会把标记转发给 Anthropic 和 Gemini,但自身不添加任何标记;托管的 OpenAI 会拒绝未知的内容块字段。OpenRouter 的 slug(以及 openrouter.ai 上的任何端点)会启用 openrouter.ai)会启用 OpenAiModel::with_explicit_cache_control,它会标记最后一个系统消息和最后一个用户消息;其余所有 Chat Completions 端点都不会被标记。

  • 路由提示与这些标记是分开的。测试框架会根据声明的稳定前缀推导出一个 prompt_cache_key ,并把它放入 provider_options,因此线程的每一轮,以及共享其系统提示和工具集的每个子代理,在具备分片能力的提供方上都会路由到同一个缓存分片(OpenAI 的 prompt_cache_key)。没有这种概念的适配器会将其丢弃。托管后端会把它作为顶级 body 字段接收,并与 thread_id.

并列。 RunPolicy::cache.protect_prompt_prefix之下的这三项都由宿主无条件设置,并且由 tinyagents 循环在每次请求中基于会话冻结的系统前缀所声明的可缓存前缀(系统提示分层,然后是工具目录)来决定;宿主本身并没有用于此事的中间件。直到 tinyagents 开始把该有效策略标记到发出的请求上之前,两个读取方只查看 request.cache_policy ,始终仅 None ,因此这个标志只具有诊断意义,任何标记或键都从未到达线上。现在每次模型调用中的 [cache] 调试行都会报告是否注入了键,以及声明了多少个可缓存分段; cache_read_tokens 在返回的用量中就是证明它生效的那个数字。

与其推理,不如测量。 CAPTURE_ALL=1 node scripts/debug/capture-first-inference.mjs 记录整个会话的请求, scripts/debug/run-multi-turn-capture.mjs 通过生产 RPC 驱动一个多轮线程,而 scripts/debug/audit-inference-prefix.mjs 报告第一次分歧并归因。单轮捕获看不到这些;问题从来不是第 1 轮成本多少,而是第 2 轮能否复用它。

AGENTS.md 项目说明

除了 identity/soul/profile/memory 之外,系统提示还会引入 AGENTS.md 指令文件,它相当于 OpenHuman 版的 Claude Code 的 CLAUDE.md / Codex 的 AGENTS.md。两层内容只在系统提示构建时加载一次(不会按轮次重新读取,因此冻结前缀 / KV 缓存契约成立):

  • 全局, <workspace_dir>/AGENTS.md,用户的 OpenHuman 工作区(其中 SOUL.md / USER.md 驻留)。适用于每次运行。

  • 项目, <action_dir>/AGENTS.md,代理正在操作的文件夹。对于带有 git worktree 覆盖的子代理运行(SubagentRunOptions.worktree_action_dir),该覆盖目录会作为项目层。

全局层先渲染,项目层后渲染(项目说明后置,在冲突时优先),并位于 ## 项目说明(AGENTS.md) 标题下。当两个目录解析到同一路径时,该文件只加载一次(去重)。缺失 / 无法读取 / 为空的文件会被静默跳过,而且每一层都有 BOOTSTRAP_MAX_CHARS (约 20 000 字符)的上限,并带有一个 [... truncated] 标记,以免大文件挤占提示词。

加载器是 agent::prompts::agents_md (返回预加载字符串的纯函数,读取时即受限,因此异常的多 MB 文件在渲染时上限之前不会耗尽内存);这些字符串会被传递到 PromptContext (agents_md_global / agents_md_local),并由 AgentsInstructionsSection渲染,它位于默认和子代理构建器中的用户文件部分之后、工具目录之前。主代理 / 编排器代理以及其他内建动态代理(PromptSource::Dynamic)会通过 render_* 帮助函数组装自己的 body,因此相同内容会被集中地注入到 AgentsInstructionsSection 中,由 SystemPromptBuilder::from_dynamic在代理自身 body 之后、中央 grounding 契约之前追加,而不是由每个 agents/<id>/prompt.rs 构建器分别处理。该特性由 agent.agents_md_enabled (默认开启)控制;关闭时,不会加载或注入任何 AGENTS.md 内容。

工具调用循环

在 Agent::turn中,工具调用循环是内部引擎。它是公开的 tinyagents crate 的 AgentHarness 循环,由 TurnSpec::run (crates/openhuman-core/src/agent/tinyagents/mod.rs按轮次组装。它最多运行到该轮的迭代上限,该上限在 session_host/builder/iteration_cap.rs中解析:显式的 [agent] max_tool_iterations_override (或 OPENHUMAN_AGENT_MAX_TOOL_ITERATIONS)优先,然后是代理定义中的上限(编排器的是 200),再然后才是没有定义的轮次所使用的 max_tool_iterations (默认 10)。模型会在预算的 50% 和 80% 时被告知还剩多少次调用,而 inference_agent_chat 会把一次被截顶的轮次报告为 hit_cap: true ,以及它的 checkpoint 文本:

每次迭代都会发出一个实时 AgentProgress 事件,因此 UI 可以按 token 渲染流式输出、“正在调用工具 X”状态以及每次迭代的成本更新。

一个引擎,三个入口。该循环位于 TinyAgents 中。OpenHuman 的聊天宿主、频道/CLI 路由以及子代理宿主都会在适配器接缝处提供产品策略;被创建的子代理通过 agent/subagent_host进入,由其驱动中立的 tinyagents_orchestration::subagent::SubagentDriver ,而不是 OpenHuman runner。宿主负责提供方/模型选择、工具与安全收窄、工作区/工件处理、进度以及持久化产品投影。中立驱动负责生命周期排序、任务键合并,以及独占性的暂停或终态持久化决策。不存在 harness/subagent_runner 路径,而且它绝不能作为 facade 或兼容性导出返回。

工具分派与工具调用方言

agent.tool_dispatcher (可通过一次启动用 OPENHUMAN_TOOL_DISPATCHER覆盖)决定如何向模型表达工具。 auto (默认)在提供方配置支持时使用原生工具调用、通过 ChatModel 适配器传递的结构化工具规格以及结构化回调;在本地 Ollama 这类提示词引导型提供方上则退回为标签内 JSON。 python 而 typescript 是可选启用的:它们将目录渲染为代码签名并读取代码风格的回调,但在某些模型上会误解析。会话会为所选方言组装提示词,并在轮次 harness 上固定同一方言,因此文本方言会让其 schema 不出现在线上,而 harness 会用匹配的语法恢复调用。

规范的 tinytools_agent::dialect::ToolDialect 实现会直接提供与转录兼容的解析和渲染;OpenHuman 只在这些 I/O 边界转换持久化/提供方记录:

  • 原生(native),结构化工具调用字段。

  • XML(xml), <tool_call>{...}</tool_call> 标签出现在助手文本中,并在提示词中包含完整 JSON schema。

  • P-Format(pformat),紧凑的位置式 <tool_call>name[0|a|1|b]</tool_call> 带有 name[0|<a>|1|<b>] 签名出现在提示词中;可选启用。

  • 代码(python / typescript),目录是函数签名列表(def read_file(path: str, limit: int = None) -> str 或 function read_file(path: string, limit?: number): string;),模型会在标签内写出函数调用: read_file(path="src/main.rs", limit=20) 或 read_file({path: "src/main.rs", limit: 20})。它和 P-Format 一样紧凑,但语法更像代码训练模型已经会写的形式; python 是默认值。

每种文本方言共享同一个解析器:一个 <tool_call> body 先按 P-Format 尝试,再按代码调用尝试,最后按 JSON 尝试,因此混合形式的模型仍能被理解。持久化的会话历史可以包含这些任意形状的后缀,因此会话外壳会保留分派器,以便在转录恢复时忠实地解析并重放它们。

工具规则:允许、拒绝、隐藏、批准

你可以使用模式规则来限制代理能看到和调用哪些工具。该规则语言是 tinytools::ToolRules,tinyagents 循环通过 RunPolicy::tool_rules强制执行。一个规则集决定所有三个界面:请求上的工具 schema、 tool_search 以及延迟加载的目录,以及每一次调用,包括嵌套调用在内。被规则移除的工具因此无法通过搜索找到,也无法通过猜测名称调用。

规则来自三个地方,并作为层叠加。每一层都必须允许一个工具:

  1. [tool_rules] 在 config.toml. 操作者层,适用于每个代理,包括子代理。

  2. 代理定义。

    • disallowed_tools 而 tool_rules 在定义 TOML 中。

    • tool_rules 在代理注册项上, agent.registry 补丁可以设置。

    • AgentDefinitionSpec::tool_rules 用于嵌入器。

  3. 子代理。 子项会继承操作者的层,但不会继承其父代理自身的限制,因为编排器通常会禁用它自己委派出去的正是那些工具。然后它再加入自己的定义层。

规则如何生效:

  • 优先级。 deny 在层内胜出。 hide 让工具仍可调用,但不列出。 require_approval 会像声明了 approval_required; auto_approve 的工具一样延迟一次调用,但免除该声明。

  • 模式。 通配符支持 * 而 ? 并忽略 ASCII 大小写,因此 gmail_* 会匹配 GMAIL_SEND_EMAIL。定义中的名称列表(disallowed_tools)通过 tools::rules::glob_list_matches使用相同语法。该函数现在是匹配器唯一的实现。

  • 拒绝。 被拒绝的调用会连同规则的 id、层和原因一起返回。

代码: crates/openhuman-core/src/tools/rules/。每个会话只会组装一次其层(session_rule_set);每一轮都在自己的上下文中评估它们(OpenHumanRunContext::tool_rules).

循环中的上下文管理

冗长的工具调用链可能会超出上下文窗口。由两层来处理这一点:

  • 工具结果预算:每个工具结果都会按单次调用字节预算进行检查,并作为 tinyagents 工具中间件强制执行。超出的内容会被硬截断,并附上说明性标记,以便模型知道它没有看到完整输出。

  • 微压缩 / 自动压缩:当总历史开始逼近上下文窗口时,tinyagents 中间件(消息裁剪 + 安装在 tinyagents/middleware.rs 而 tinyagents/context_wiring.rs中的压缩钩子,其阶梯顺序和大小由 tinyagents-harness ContextLadder负责)会在下一次提供方调用之前,把较旧的轮次压缩成摘要。压缩后的历史会保留系统提示和最近几轮不变(KV 缓存稳定),并重写中间部分。

过大的工具结果:摘要分流

某些工具调用会返回巨大的载荷:一个 Composio 操作倾泻出 200 KB JSON,一个网页抓取返回 50 KB markdown,一个 file_read 遍历数千行日志。硬截断载荷中途会丢掉切点之后碰巧落下的任何内容。

当工具结果超过摘要器阈值(summarizer_payload_threshold_tokens,默认 4 000)时,编译后的 TinyJuice 模块会决定在其进入父历史之前是否对它进行摘要。该模块负责抽取提示词、摘要缓存以及按线程的失败断路器,并把原始内容存入其 CCR 存储中,以便 juice_retrieve 能够恢复它。宿主负责模型调用,因为它知道模型和轮次上下文。 ToolOutputMiddleware 通过 PayloadSummarizer::prepare绑定当前轮次的一个一元子项,在一次性 harness(tinyagents/payload_summarizer.rs)上注册被限额的模型,而模块则通过类型化的 tinyjuice-bus MlHost.Generate 契约回调。只有编排器携带摘要模型,而且无论压缩路由是否关闭,它都会始终看到 juice_retrieve,因为摘要的页脚会写明它。一个本应被摘要却没有得到摘要的结果会被标记为未摘要,无论是模块还是宿主失败,这样模型就不会再为它重新运行工具。摘要复用和断路器的作用域是线程级,若没有线程则是运行级。若摘要失败,或者载荷大到对其进行一次 LLM 调用在经济上毫无意义时,硬截断仍然是下游最后一道防线。

压缩、CCR 存储与恢复、内容查询、HTML 提取,以及静态工具声明和线上 DTO 都属于 TinyJuice 和 tinyjuice-bus。OpenHuman 的工具是适配器:它们把类型化请求传给模块,并把文件系统授权留在宿主侧。句柄查询针对模块的 CCR 存储运行;工件查询把已授权内容而不是路径发送给同一个模块操作。宿主没有并行的 CCR 存储或本地算法回退。如果模块不可用,必需查询和抽取会报告该操作不可用;可选的工具输出压缩则保持其文档化的透传行为。已恢复的线程即使当前设置不再创建新句柄,也会保留其记录下来的 Juice 声明和 schema;当前策略和模块可用性仍然决定执行。

调用者关注点。工具可以选择加入一个可选的 summary_focus 参数,通过在其 schema 中添加 tokenjuice::focus::summary_focus_property() ; web_fetch 而 web_search_tool 都会这么做。模型用它来说明自己从结果中需要什么,例如“速率限制”。 before_tool 会在校验之前把该参数从调用中移除,因此工具永远看不到它。只有 schema 中带有该精确属性的工具才会失去它;任何其他带有同名参数的工具都会保留它。它会连同结果一起送入 TinyJuice,在那里它会引导摘要、为缓存设键,并为确定性压缩器对文本排序。一个自己截断输出的工具,例如 web_fetch,通常会交给 cap-and-spill 处理。若调用者提供了 focus,它也会被摘要,因为直接翻页原始页面无法回答问题。

文件系统卸载: outputs/ 而 workspace/

仅靠压缩无法支撑长时程运行。摘要仍会一步接一步累积,而且再多的压缩也无法恢复已丢弃的保真度。因此,对于分钟到小时级任务,测试框架会把大型结果移出上下文并写到磁盘上,然后把路径交给下一步。

运行时代理现有的两个目录(实现位于 action_dir )下, crates/openhuman-core/src/agent/harness/artifact_offload/):

目录
内容

action_dir/outputs/

交付物。旨在存活到产生它们的那一步之后,通过路径传递。

action_dir/workspace/

临时区。工作线程需要但不回传的中间文件。

注意 action_dir/workspace/ 是代理 action 根目录内的一个临时文件夹。它不是核心内部的 workspace_dir,代理所写入的内容可能永远到不了那里。

由两部分来执行这一约定:

  • 提示。一个真正持有 file_write 在其系统提示中获得一份长期工件卸载契约:超过大约 2,000 个 token 的结果会写入位于 outputs/,回复则是该相对路径加上一段简短摘要。这个门控是有意设计的:提示词只能列出代理真正能调用的工具,否则模型会发出失败的调用。经过技能过滤的专家不会收到契约文本,且有专门的守卫断言它们的提示词从不提及文件系统工具。它们仍受下面的 harness 半部分覆盖,这不需要模型配合。The 规划器 提示词说明了该约定对其自身工作的含义;规划器被告知在 DAG 节点之间引用工件路径,而不是向前粘贴负载。

  • Harness。 卸载超大结果 作用于每个子代理结果,因此即使 worker 已经把结果内联了,超大结果也会被卸载。它在定义的 max_result_chars 上限之前触发,因此完整正文会落盘而不是被截断。

父级收到的是一个指针,而不是负载:

SubagentRunOutcome.artifact_paths 在结构上携带相同的路径,这些路径是从 [artifact] 输出中的指针,因此无论 harness 卸载了结果还是 worker 自己写了文件,交接都会携带路径。父级接收到的任何路径都可以通过普通的 file_read 在子级上下文消失很久之后仍然如此。

摘要器是后备方案,而不是首选。卸载处理常见情况;摘要器绕路和 tool_result_budget_bytes 上面的截断仍会处理它未处理的所有内容,包括这里的每一种失败模式。被拒绝或失败的卸载是故意设计成软失败的:调用方保留其内联负载,并回退到这些后备机制。

路径加固采用失败即拒绝。 resolve_artifact_path 会拒绝绝对路径, .. 目录穿越,以及经过词法归一化后会逃出其约定根目录的任何内容。当一个 SecurityPolicy 可用时,它还会拒绝位于 workspace_dir下的任何内容,既通过整体包含规则,也通过 is_workspace_internal_path。卸载目标解析到 action_dir下,绝不会 workspace_dir,包括有人配置 action_dir 位于工作区根目录内,此时所有卸载都会被拒绝,而不是悄悄写入内部状态。

写入日志 [artifact] wrote worker artifact under action_dir;交接携带的每条路径都会记录日志 [artifact] handoff carried an artifact path to the parent,在生成端和消费端都会记录,因此运行日志会展示每个指针的两端。

TokenJuice:内容感知的工具输出压缩

在新的工具结果进入历史记录之前(以及在字节预算后备机制之前),它会先通过编译后的 TinyJuice 模块中的 TokenJuice 内容路由器,OpenHuman crates/openhuman-core/src/inference/tokenjuice/。受 Headroom 启发,该路由器 检测内容类型 (JSON、代码、日志、搜索、diff、HTML、纯文本)根据字节和/或从工具名称与参数推导出的提示识别内容种类,然后分派到专用压缩器:

  • JSON → SmartCrusher:对象数组 → 表格(每个键只出现一次),保留包含错误或数值离群值的行。

  • 代码 → tree-sitter(Rust/TS/JS/Python)签名保留器,折叠函数体;括号深度启发式作为后备。

  • 日志 → 用于 命令 输出(git/cargo/npm/…),否则基于信号保留失败。

  • 搜索 → 每个文件按相关性排名的 top-K 匹配,并带有 另有 +N 项 计数。

  • Diff → 保留变更块,折叠未变更上下文,总结锁文件块。

  • HTML → 去除标记,提取为可读文本。

  • 纯文本 → 确定性的 TextCrusher。

每次有损压缩都会把原始内容卸载到 CCR(Compress-Cache-Retrieve)存储中,并由一个 ⟦tj:<hash>⟧ 标记,因此压缩在效果上是无损的:代理会调用 juice_retrieve (token + 可选的字节/行范围)按需取回完整原文。同一引擎还作为通用的 compress_content(content, hint, opts) 适用于任何大型负载(文件读取、网页抓取),并作为只读的 tokenjuice.* 调试 RPC。压缩默认开启(context.compaction_enabled; OPENHUMAN_COMPACTION=0 可选择关闭)。大型结果会被存储并替换为一行统计信息、500 个字符的开头,以及一个供只读的 juice_find / juice_extract / juice_summarize 工具查询(tokenjuice.repl_handle_enabled),因此对于获得 handle 的结果会跳过 LLM 摘要阶段。通过 [tokenjuice] 块 / OPENHUMAN_TOKENJUICE_* 环境变量进行配置。代理定义可以通过以下方式覆盖工具结果压缩: tokenjuice_compression = "auto" | "full" | "light" | "off"; auto 将编码模型代理([model] hint = "coding")解析为 light,这会禁用基于 CCR 的有损压缩,因此编码代理会保留原始的构建/测试/diff/搜索文本,除非某种缩减确实是无损的。其他代理默认使用 full。纯文本压缩使用确定性的 TextCrusher。

缺失的能力

没有自愈原型。缺失的宿主命令会报告给用户,而不是通过 polyfill 补齐。

子代理:编排器模式

OpenHuman 是多代理系统。用户正在聊天的代理是主代理(稳定内部 id: orchestrator),它是一个能力强大的默认代理,能够直接回答并完成普通工作,包括 inspect → edit → verify 编码循环。当并行性、更深层推理或专门能力能带来实质帮助时,它会生成专门的子代理。

为什么采用多代理

一个什么都懂的单一代理,其系统提示词也会大得像一本小书。将工作拆分给专家意味着:

  • 每个子代理只获得一份狭窄的系统提示,其中仅包含它需要的部分(身份 / 记忆 / 安全前言都可以去掉)。

  • 每个子代理都获得一个过滤后的工具注册表:图像代理不需要文件系统工具,任务管理器不需要 Composio 目录。

  • 子代理的历史从不会泄露回父级:父级只看到一条紧凑的工具结果,而不是内部对话。

  • 更便宜的模型可以完成叶子工作。编排器运行在强推理模型上;研究子代理可能运行在更快、更便宜的模型上。

内置原型

每个原型都位于 agents/<name>/ ,并带有一个 agent.toml (元数据、工具范围、模型提示)和一个提示词。编排器的聊天委派是其 [subagents] 白名单中的那些:

原型
当编排器选择它时

orchestrator

主代理:顶层、可直接执行的默认代理。不会被其他编排器生成。

task_manager_agent

添加、预览、获取、更新或移除任务源、工作流包、工件。

vision_agent

任何依赖图像内容的事情(描述、OCR、UI 元素)。

image_agent

生成或编辑图像。

video_agent

生成视频或让图像动起来。

presentation_agent

构建带有依据和引用的幻灯片(功能 documents).

skill_setup

从注册表中查找、安装和管理技能(setup_skills).

workflow_builder

编写或编辑已保存的工作流(build_workflow);返回提案,绝不保存。

flow_discovery

建议值得设置的工作流(discover_workflows).

其他内置项从不会作为聊天委派:

原型
谁来运行它

规划器, critic

仅 parallel_research_cross_check 工作流运行模板(decompose and research = 规划器,cross_check = critic).

summarizer

harness,用于压缩过大的工具结果;也用于该 workflow-run 模板的 synthesize 阶段。

trigger_triage / trigger_reactor

对传入的外部事件进行分类,以及对其作出轻量级反应。

morning_briefing

Cron:每日摘要,运行在一个命名的只读工具带上。

编排器本身通过内联技能处理编码、加密、设置、调度、产品文档和 MCP 服务器: use_skill 加载一个工具包的指南和模式(coding, web3, system, scheduling, docs, mcp),并且相同的工具也被 延后 因此 tool_search 找到任意一个即可。运行已安装的技能是编排器自己的 run_workflow。参见 tools/toolpacks/README.md.

自定义原型以 TOML 文件形式随附在 $OPENHUMAN_WORKSPACE/agents/*.toml (或 ~/.openhuman/agents/*.toml (供用户全局专家使用)。在 id 冲突时,自定义定义会覆盖内置项。

运行可复用的子代理

当编排器调用 spawn_subagent,默认契约是持久且异步的。该工具会从父会话/线程、代理 id、模型覆盖、sandbox 模式、action 根目录以及规范化后的任务键/标题构建一个确定性的兼容性选择器。然后它检查 orchestration::subagent_sessions ,然后才生成:

  • 如果一个兼容的 worker 已在运行,指令会通过其 RunQueue 注入,父级会快速获得一个 subagent_session_id / task_id 参考。

  • 如果一个兼容的 worker 处于空闲或暂停状态且具有可复用历史,harness 会为同一个持久的 subagent_session_id 并将保存的子历史通过 SubagentRunOptions.initial_history传入,新的指令会作为用户可见的后续内容附加。

  • 如果形态不兼容、worker 已关闭, fresh: true 被传入,或者不存在会话,harness 就会创建一个新的持久会话和 worker 线程。

子运行由 OpenHuman 宿主通过中立的 TinyAgents 生命周期进行准备和执行:

  1. 从其类型化运行上下文接收父级取消和工作区;剩余的旧版 provider、sandbox 和 transcript 输入仍保留在其作用域内,直到它们的显式迁移落地。

  2. 解析子代理的模型:内联 model 覆盖优先,然后是配置级固定项([orchestrator].model, [teams.*].lead_model, [teams.*].agent_model),然后是原型提示或继承的父模型。

  3. 按定义中的……过滤父级的工具注册表 tools, disallowed_tools,以及 skill_filter。在 fork 模式下,父级的完整注册表会原样继承。

  4. 构建一份狭窄的系统提示,省略定义要求剔除的部分。

  5. 将准备好的子代理交给 tinyagents-orchestration 以进行生命周期排序,并执行恰好一次暂停或终态持久化操作。

  6. 将子历史和 worker 线程指针持久化到该持久的 subagent_session_id 下,以便后续轮次可以恢复或检查它。暂停续接也会保留其完整的 TinyAgents 任务键。

wait_subagent 而 steer_subagent 接受持久的 subagent_session_id 或临时的 task_id;跨轮次更偏好持久 id。 list_subagents 显示当前父线程的可复用子项,以及 close_subagent 将 worker 标记为不可复用,并在其仍在运行时取消它。内联阻塞通过以下方式显式启用: blocking: true.

合成的原型委派(delegate_*, build_workflow,以及其他 delegate_name 工具)遵循同一契约:它们默认通过持久异步路径路由,返回一个 [async_subagent_ref] (附带 subagent_session_id + task_id)并立即返回,完成后的结果会通过……作为新的系统轮次插入到父聊天中 background_completions/background_delivery。其背后的队列是 harness tinyagents_tasks::CompletionRouter ,位于每工作区的 JsonlCompletionStore (.openhuman/background_completions.jsonl,按聊天线程作为键):它按任务 id 去重,为父级已内联收集的结果或被 Stop/delete 取消的结果保存墓碑记录,统计交付尝试次数,并返回那些放弃的记录。记录会一直处于租用状态,直到交付轮次落地(mark_delivered),因此一个已经完成但从未交付的结果会在重启后重新交付。宿主保留 <background_agent_*> 这样的措辞(一个 CompletionFormatter)、3 秒空闲防抖、系统轮次及其 web_chat 事件,以及将放弃的结果持久化到线程中的写入器。交付轮次会持久化它自己的结束消息, sender: "agent",id agent:<run_id>, extraMetadata.requestId = run_id,然后才发出 chat_done 作为 client_id: "system";前端会复用该 id,因此其常规的 chat_done append 会合并到同一行上(对这些确定性的 conversation store 是幂等的 agent:-前缀 id;其他每个 id 都是 UUID 新鲜生成的,并保持常数时间追加路径),而不是再次持久化已交付的结果。交互式轮次遵循同一契约: web_chat::presentation::deliver_response 将回复存储在 agent:<request_id> 之下,然后再发布 chat_done,因此核心生成的答案无论是否有客户端接收公告都会存在于磁盘上;一个断开的 socket 或重新加载的 webview 只会带来重绘成本,而不会丢失回复。例外是分段交付,此时客户端为每个分段拥有一行,而核心不存储任何内容;前端正是因此在那儿保留生成的 id。当没有父代理轮次或没有可交付的聊天线程(cron/CLI),或者当 blocking: true 被传入时,它们会自动回退到内联阻塞。跨轮次连续性来自三个部分:每轮次的 [active_subagents] 名册将内存中的实时注册表与持久的 subagent_sessions 存储(因此冷启动的编排器仍能看到更早的 worker); continue_subagent 从暂停检查点回退到持久存储,用其持久化历史恢复一个空闲 worker;以及一个 workflow_proposal 在已完成子历史中找到的负载会作为父线程消息持久化(extraMetadata.scope = "workflow_proposal"),前端在加载线程时会将其重新还原为提案卡片。

生成层级与分层

并非每个代理都被允许生成其他每个代理。harness 建模了一个三层层级,映射模型之间的成本 / 延迟 / 思考深度划分:

每个 AgentDefinition 都携带一个 agent_tier 字段(chat / reasoning / worker,默认 worker)。契约:

层级
可生成
不得生成
典型成员

chat

reasoning, worker

另一个 chat

orchestrator

reasoning

worker

另一个 reasoning,任意 chat

规划器 (今天的规范项)

worker

无

任何内容

critic, image_agent, task_manager_agent, …

为什么要有这些规则。

  • 聊天 → 聊天没有意义。 聊天层存在是为了快速的用户体验。一个聊天代理再生成另一个聊天代理只会让 TTFT 翻倍并消耗 token,却不会带来任何新能力。

  • 推理 → 推理会放大深度。 推理层很昂贵。推理代理链往往会重新分解同一个问题,并产生失控的层级结构。

  • Worker → 任何内容都会混合执行与编排。 Worker 是叶子节点,因此父级总是看到一条紧凑的结果,而不是嵌套委派的对话记录。

执法。两层:

  1. 加载时(静态)。 agents::loader::validate_tier_hierarchy 会遍历合并后的注册表(内置项 + 工作区 TOML),并拒绝启动任何列出了同层级或 worker-with-subagents 条目的注册表。内置原型在编译测试时检查;用户随包提供的 TOML 在工作区加载时检查。

  2. 运行时深度闸门(动态)。不论层级如何,子代理运行器将总生成链深度上限设为 MAX_SPAWN_DEPTH = 3 ,通过一个任务局部计数器递增, run_subagent,并显示为一个 SpawnDepthExceeded 代理错误。这样即使用户提供的 TOML 去掉了层级注解,也仍然无法递归超过三跳。

状态:加载时层级检查, agent_tier 字段,以及运行时深度计数器任务局部变量都已启用。深度同时受静态加载器契约和运行时 MAX_SPAWN_DEPTH = 3 保护。

工具包专用专家

对于拥有数百个动作的 Composio 工具包(仅 GitHub 就有 500+),将每个动作都加载到子代理的工具集中会使提示长度暴涨。该测试框架使用一个廉价的仅 CPU 过滤器(动词检测、token 重叠、动词对齐加成)将工具包的动作与父级细化后的任务提示进行排序,只把得分最高的子集加载到子代理中。无需模型调用,纯启发式,速度快且可解释。

分诊:处理外部触发

当 webhook 触发、cron 计时器走动,或 Composio 事件到达时,系统不能直接把它交给编排器。大多数触发只是噪声;有些值得通知;只有少数应该进入完整的代理轮次。触发分诊管线就是这道闸门。

评估器被刻意设计得很便宜:优先使用本地小模型,重试时回退到远程模型。决策会被缓存,因此相同触发不会重复分类。只有升级为“生成 orchestrator”的触发才会经过完整的 Agent::turn 机制。

钩子:可观测性与策略控制点

有两个钩子面分别包裹循环,处在两端:

停止钩子(回合中)

停止钩子在工具调用循环的迭代之间触发。它们是预算上限、速率限制和自定义杀开关的策略控制点。内置钩子:

  • 预算停止钩子:使用每轮迭代的成本累加器,将单回合累计费用上限设为美元计。

  • 最大迭代次数停止钩子:从代理持久配置之外限制迭代计数。

  • 动作预算策略: SecurityPolicy 约束 config.autonomy.max_actions_per_hour 带有副作用的工具操作。用户可在“设置 > 高级 > 代理自治”中调整,或者运维人员可用 OPENHUMAN_MAX_ACTIONS_PER_HOUR.

返回 Stop 会中止循环,并给出一个清晰原因,调用方可将其展示给用户。停止钩子不同于中断(下一节):它们由策略驱动,而非用户驱动。

回合后钩子

回合后钩子在回合完成后于后台触发。它们会收到一个 TurnContext 快照、用户消息、助手回复、每次工具调用及其参数和结果、总墙钟时间、迭代次数、会话 ID。内置消费者:

  • 成本日志:最终的每回合成本行。

  • 对话日志: memory 生命周期在回合后记录回复,并附上一行工具结果(绝不记录参数);没有批处理或空闲刷写。见 记忆架构.

长期事实会显式写入:代理调用 memory 工具的 learn 动作。没有自动档案管理员。

钩子通过 tokio::spawn运行,因此用户会在它们完成之前先拿到回复。

中断:优雅取消

取消是 tinyagents 的控制通道。当用户按下 Ctrl+C 或发送 /stop时,运行器会把请求转发到测试框架的控制/取消接缝,在安全点停止循环:在每次工具执行前、在每次子代理生成前,以及在每次提供方调用前:

  • 每个正在运行的子代理共享该取消作用域,并在下一个检查点退出。

  • 进行中的提供方流会被丢弃。

  • 已缓冲的对话回合仍会保存(空闲刷写,或退出时的退出刷写)。

中断由用户驱动;停止钩子由策略驱动。二者都进入同一套测试框架的暂停/停止管线,但来自不同侧。

成本核算

每个提供方响应都带有一个 UsageInfo 块、输入 token、输出 token、缓存输入 token,以及由 charged_amount_usd OpenHuman 后端填充的权威 TurnCost 会对单个回合内的每次提供方调用进行求和,以便测试框架可以:

  • 通过进度通道发出每次迭代的成本遥测。

  • 为预算停止钩子提供输入,从而让失控回合在循环中途自行切断。

  • 记录准确的回合结束成本行。

当后端没有暴露已收费金额时(旧版构建、不通过它计费的提供方),一个按层级划分的小费率表会提供 token 费率下限估算。只要后端可用,直接成本始终优先。

Web 通道事件和 RPC(assistant-ui 元素部分)

assistant-ui-elements 集成增加了一批增量式的 WebChannelEvent和 RPC,使前端无需轮询即可渲染工具参数/时序、计划/目标/队列状态以及回合生命周期。 EVENTS_VERSION (core/bus.rs) 是 1.4.0;每个新字段都是可选/有默认值的,因此较旧的订阅者仍能解析较新的发布者发出的内容。

新的/扩展的 socket 事件(通过 DomainEvent 桥接到 WebChannelEvent 由 web_chat::event_bus 而 openhuman_rpc::server::socketio):

  • ts (epoch ms) 由 publish_web_channel_event 在生产者未设置时加盖,因此前端无需猜测接收时间就能排序/测量延迟。

  • chat_done.timing: { first_token_ms, first_tool_ms, total_ms, tokens_per_second },由进度桥每回合的 TurnTiming 通过 ProgressBridgeHandle::timing_snapshot(). tokens_per_second 由 output_tokens / (total_ms / 1000) 推导得出,当两者都已知且 total_ms > 0.

  • chat_done.usage.context_tokens:根代理最终模型调用的输入加输出 token。它用于上下文窗口仪表,不包括更早的调用和子代理; input_tokens 而 output_tokens 保持为回合总支出求和。

  • chat_cancelled: { thread_id, client_id, request_id, cancel_reason: "user_stop" | "superseded", superseded_by }。与现有的 chat_error{error_type:"cancelled"}同时发出,而不是取代它;适用于无作用域/有作用域的停止路径、被更新请求取代的路径,以及并行回合的协作取消路径(后者之前根本没有发布任何终止事件)。

  • chat_error{error_type:"guardrail"}:携带一个 guardrail: { verdict, score, reasons: [{code,message}] } 载荷,当 start_chat 通过 StartChatError::Guardrail 拒绝一条消息时(提示注入/安全护栏)。其他所有拒绝仍保持 error_type:"inference"。RPC 接口(channel.web_chat的 Result<_, String>)将同样的结构化判定编码为一个 GUARDRAIL:<json> 哨兵字符串(web_chat::ops::start_chat::{GUARDRAIL_ERROR_PREFIX, is_guardrail_error_message},与现有的 BACKEND_UNAVAILABLE: 模式保持一致)。

  • turn_cost:来自 AgentProgress::TurnCostUpdated的实时单回合成本读数,每回合最多每 750ms 发出一次(第一次更新总会立即发出): { thread_id, client_id, request_id, round, usage: { input_tokens, output_tokens, cached_input_tokens, cost_usd, context_window, context_tokens, subagents } }. context_tokens 是根代理最终调用的输入加输出,用于其上下文窗口仪表;不对调用或子代理求和。 subagents 在这个实时事件中始终为空(它只是父级的累计汇总);按子代理的归因仍只会出现在终态 chat_done.usage.

  • approval_decided / plan_review_decided:通过 DomainEvent::ApprovalDecided / PlanReviewDecided桥接而来,后者新增了 thread_id, client_id, tool_call_id,以及 resolution ("expired" ,用于 TTL/清扫, "cancelled" ,用于被丢弃的决策通道, None ,用于普通用户决策,在链路上作为现有的 cancel_reason 字段传输)。仅当原始驻留同时具有 thread_id 而 client_id (chat-routed)。

  • run_mode_changed: { thread_id, client_id: "", message: "plan" | "build" },通过 DomainEvent::ThreadRunModeChanged桥接,由 agent::tinyagents::run_mode::set_mode.

  • thread_todos_changed, queue_item_queued / queue_item_delivered / queue_item_removed, queue_item 携带 { id, lane, text_preview }.

  • 子代理关联: subagent_spawned/subagent_completed/subagent_failed/subagent_awaiting_user 携带 subagent.parent_call_id (生成该代理的工具调用 id:某一次 spawn_parallel_agents 调用的每个 worker 都共享它); subagent_completed 还携带 subagent.output (截断后的最终文本)。 tool_call.args/tool_result.args+elapsed_ms 现在携带真实参数和时序,而不是 null (来自 tinyagents 的 AgentEvent::ToolStarted.input).

  • 工件事件(ArtifactPending/ArtifactReady/ArtifactFailed)携带 tool_call_id 而 turn_request_id,后者由原始回合的 ApprovalChatContext::request_id 填充,这样前端就能把工件卡片绑定到生成它的确切回合(None 适用于没有聊天上下文的 CLI/cron/子代理生产者)。

新的 RPC:

  • agent.set_run_mode { thread_id, mode } / agent.get_run_mode { thread_id },读取/切换线程的 Plan/Build RunModeHandle. channel.web_chat 还接受一个可选的 run_mode: "plan" | "build" 参数(镜像 socket 的 chat:start 载荷),因此回合可以在请求的模式下已就绪地开始,而不必与单独的 RPC 调用竞争;未识别的值会记录并忽略。

  • threads.token_usage { thread_id }:返回持久化的线程支出总计以及 last_turn_context_tokens,即用于上下文窗口仪表的根代理最终调用输入加输出。较早且没有逐调用用量的回合会回退到它们输入加输出的求和。

  • threads.goal_get / threads.todos_get:直接读取线程当前的目标/待办状态。

  • threads.token_usage { thread_id }:返回持久化的线程支出总计以及 last_turn_context_tokens,即用于上下文窗口仪表的根代理最终调用输入加输出。较早且没有逐调用用量的回合会回退到它们输入加输出的求和。

  • threads.edit_message { thread_id, message_id, content, client_id? } / threads.regenerate { thread_id, message_id?, client_id? },二者都返回 { request_id }。它们会取消该线程正在进行的回合,通过 TranscriptLocator::truncate_into_next_generation 在截断点处分叉会话转录(分叉来源于被封存的那一代永远不会被触碰,遵循同样的“压缩不会抹除”保证),截断对话存储消息日志以及所有被丢弃回合的回合状态快照,然后用编辑后的内容(edit_message)或原始提示(regenerate)重新启动回合。详见 threads::ops::edit的模块文档,其中有完整的 UI message id -> 转录截断点映射。

  • channel.web_queue_remove { client_id, thread_id, item_id },从线程的运行队列中移除一个特定的排队项。

  • agent.context_breakdown { agent_id?, thread_id? },一个面向 UI 的视图,展示代理渲染后的提示大小(系统/工具/历史拆分),用于组合器的上下文使用指示器。

  • commands.list:将内置命令与 skills.list 而 flows.list 合并为一个斜杠命令目录。

plan_exit (将线程从 Plan 切回 Build 的工具)只有在该线程上没有仍处于挂起状态的计划评审时才会切换模式(agent::plan_review::gate::PlanReviewGate::parked_review_for_thread),评审会在 request_plan_review 把控制权返回给代理之前完成,因此这只在一个时机不对/并发的 plan_exit 调用与仍在待决的评审竞争时才重要,而在用户实际批准任何内容之前,它绝不能解锁所有工具。

显式运行上下文和主机能力

OpenHumanRunContext 是共享聊天、通道和子代理回合接缝处的实时载体。根节点会快照其当前作用域内的来源、进度、停止钩子、分发状态、线程、路由槽和工作区授权,然后在把上下文传给运行器之前拥有或显式接收一个取消令牌;递归子代理会通过 child()对其进行分叉,在隔离路由观察和用量核算的同时,保留共享的取消/策略句柄。共享运行器创建 RunContext<OpenHumanRunContext> 通过 into_tinyagents,而 OpenHuman 组装与中间件注册表会直接消费该强类型上下文。路由和线程作用域仍仅围绕驱动存在于传统工具/模型 API 之外,位于类型化测试框架边界之外。递归分叉从其类型化父级接收取消和工作区 RunContext 通过 ToolDispatch;它从不读取环境中的取消状态。

OpenHumanHostBundleFactory 是 B1 主机组合点。它从相同的会话/运行时输入构造上下文、定义、安全、模型、内存、预算、进度、工具结果和体验适配器。OpenHuman 保留所有策略决策;TinyAgents 目前只接收由此产生的规范化运行上下文。将完整的主机能力捆绑包接入每次调用仍是后续切换步骤。

自我修复回顾

主循环之上放着几个小型自适应系统:

  • 工具输出摘要断路器:某线程连续三次摘要失败后,TinyJuice 就会停止要求该线程生成摘要,于是结果回退到压缩和截断。

  • 分诊本地/远程重试:先用本地 LLM;解析失败时回退到远程。

  • 未知工具和格式错误参数恢复:中间件会把无效的模型工具调用改写为可恢复结果,而不是中止运行。

这些都不会改变循环的形状,只是让常见故障模式在无需用户介入的情况下也能恢复。

在代码中查看的位置

测试框架外壳位于 crates/openhuman-core/src/agent/,tinyagents 适配器接缝位于 crates/openhuman-core/src/agent/tinyagents/ ,而原型定义位于 crates/openhuman-core/src/agent/registry/。 crates/openhuman-core/src/agent/ 中的 README 列出了公开表面;最关键的文件(相对于 crates/openhuman-core/src/agent/ 的路径,除非前面加了前缀)是:

文件 / 目录
其中包含的内容

session_host/runtime_session.rs

Agent::turn:上文所述生命周期;通过 tinyagents/turn_exec.rs.

tinyagents/mod.rs

TurnSpec::run:共享的 tinyagents 测试框架组装(实时循环)。

tinyagents/middleware.rs

命名的 OpenHuman 中间件栈(批准/安全、工具策略、恢复、预算、断路器)。

harness/graph.rs

通道/CLI 总线回合路由到 tinyagents 运行器。

subagent_host/

直接为 OpenHuman 规划器/执行器/持久化适配器提供 tinyagents-orchestration::subagent;产品提示/工具/模型/安全/工件/进度行为仍在此处。

orchestration/subagent_sessions/

持久可复用的子代理身份、兼容性匹配、持久化状态/历史。

harness/host_definition.rs

围绕 tinyagents_definition::AgentDefinition.

subagent_host/ops/runner.rs

集成工具排序和主机执行叶子;通用生命周期仍在 tinyagents-orchestration.

tinyagents/payload_summarizer.rs

TinyJuice 超大工具结果摘要背后的模型调用。

session_host/tool_progress.rs

幸存下来的 OpenHuman 接缝: TurnProgress.

message_convert.rs

围绕规范化工具调用方言 API 的具体持久/提供方转换。

triage/

外部触发分类 + 升级。

registry/agents/

内置原型:每个代理一个子目录。

hooks.rs / stop_hooks.rs

轮后和轮中钩子接口。

cost.rs

按轮的美元/令牌计费。

progress.rs

发送到 UI 的实时进度事件。

TinyAgents 上的代理引擎与编排

每个代理轮次都使用 TinyAgents。聊天和频道/CLI 调用方会调用会话/轮次宿主,而子代理调用则进入 agent/subagent_host 以及其直接 tinyagents-orchestration::subagent 生命周期。没有 harness/subagent_runner 兼容路径。周围的 OpenHuman 接口边界:

文件(crates/openhuman-core/src/agent/tinyagents/)

角色

mod.rs

运行器(TurnSpec::run):安装原生 ChatModel、宿主工具适配器和中间件到一个 AgentHarness;执行一个轮次;通过 MaxTokensModel (agent/tinyagents/model.rs);镜像进度;转发引导;并在模型调用上限处优雅暂停。

mod.rs / model.rs / tools.rs / convert.rs

RunPolicy / ChatModel / Tool /消息适配器(包括未知工具策略和带外推理转发)。

observability.rs

Harness AgentEvent → AgentProgress + 成本; GraphTracingSink 用于图事件。

orchestration.rs

重新导出 graph::orchestration 任务存储类型;map-reduce 扇出直接使用 TinyAgents SDK 接口。

delegation.rs

持久化的 规划 → 执行 ⇄ 审核 → 完成 委派图(生产工作器接入 orchestration::delegation),通过 TinyAgents 自身的 SqliteCheckpointer.

图上的编排(crates/openhuman-core/src/agent/orchestration/):

  • 工作流阶段 DAG(workflow_runs/engine.rs)运行在一个 dispatch ⇄ run_phase → done 条件路由图上;每个阶段通过 graph::parallel::map_reduce将其代理扇出。持久化的 workflow_runs 行仍是事实来源(控制器 + 恢复会读取它)。

  • 团队成员运行时(agent_teams/runtime.rs, run_member_graph)是一个条件路由图(execute → complete|fail → done).

  • 多阶段委派(orchestration::delegation + delegate 工具)运行 delegation.rs,并检查点到会话数据库。

  • 分离的子代理(running_subagents.rs)使用 TinyAgents DetachedTaskRegistry 来实现具备所有权感知的快照、等待/超时、引导查找、协作式取消、强制中止和终端清理。OpenHuman 保留持久化任务存储投影、产品/会话元数据、RPC 和交付语义,以及 RunQueue 兼容性回退。

有意不放到该 crate 原语中的内容(这些是设计决策,不是缺口):

  • 子代理宿主策略(subagent_host/)仍归 OpenHuman 所有:定义解析、原型工具过滤、提供方解析、窄提示构建、记忆上下文、工作线程镜像、交接配置以及产品检查点/会话投影。它直接实现 TinyAgents 的规划器/执行器/持久化特性。中性驱动程序,而不是本地宿主运行器,负责生命周期排序和任务键合并。

  • 持久化运行账本(workflow_runs, agent_teams, command_center, subagent_sessions)仍保留在 openhuman SQLite/JSON 中,直到其控制器投影和重启语义映射到 TinyAgents 任务/状态/日志记录。 agent_teams 带竞态安全的 SQL compare-and-swap 任务认领仍归 OpenHuman 所有。

TinyAgents 提供 harness store/cache/session 原语(harness::store 配合 JSONL 追加式存储, harness::cache, harness::subagent,带血统感知状态)、图任务存储、分离式运行时注册表和一致性契约。会话壳以及面向产品的子代理构建/交付流水线仍归 OpenHuman 所有。

可靠性:断路器、交还与分类失败

三个协作机制可防止运行偏离轨道或悄无声息地死亡:

无进度断路器(RepeatedToolFailureMiddleware, crates/openhuman-core/src/agent/tinyagents/middleware.rs)是该 crate 的 NoProgressTracker的一个轻量驱动。它会为每次工具调用的参数生成指纹,并将结果输入一个升级阶梯: 继续 → 提醒 (通过 SteeringCommand::InjectMessage注入的结构化“自步骤 X 以来无进展”的纠正信息,在交互轮次中安全可用)→ 停止 (将根因摘要记录到 HaltSummarySlot,并通过引导句柄暂停)。使用相同参数的重试会计入触发条件(阈值为连续 3 次相同失败); 可恢复 失败(超时、连接重置、速率限制、5xx)使用扩展的余量阶梯,而不是固定的 crate 阈值。

子代理交还(subagent_host/):子代理运行会解析为三种状态之一:

  • 已完成:干净的最终响应。

  • AwaitingUser { question, options }:子级调用了 ask_user_clarification;宿主会将其完整检查点(历史、问题、选项和覆盖项)投影到完整的 TinyAgents 任务键下 (root_run_id, parent_run_id, thread_id, task_id)。恢复时会取回该原始键,而不是从新的父轮次推导出一个新键。

  • 未完成 { reason }:子级被断路器停止,或触及其模型调用上限。委派方父级会转发阻塞原因,而不是把已停止的子级当作已完成答案,或重新发起一模一样的委派。

顶层的断路器停止同样绝不会被当作静默完成,而且断路器的根因摘要也不会原样展示给用户:它会被改写成适合模型阅读的形式(“把这个报告回去,而不是重试”)。 hit_cap / breaker_halt 会在轮次结果中显示,而聊天轮次关闭已停止的运行方式与关闭一个没有最终文本而结束的工具轮次相同(session_host/driver/grounded_close.rs):

  1. 一次禁用工具的收尾调用,其指令会重述本轮的工具记录,并包含每个失败自身的消息,同时将断路器摘要作为停止说明传入,以用于解释而非重复。

  2. 一次单独的检查调用,只看到请求、记录和候选回复。它会拒绝那种只叙述意图、与记录矛盾,或遗漏说明未完成请求的失败信息的回复。

  3. 对于空回复、工具调用回复、被拒绝回复或未验证回复(检查失败或未给出判定),提供确定性的回退处理。它会引用每个工具结果和停止说明。

只有在检查通过后才会流式输出接受的文本,因此被拒绝的回复绝不会渲染。

分类工具失败(tinytools::status,以 vendor 形式存放在 vendor/tinyagents/vendor/tinytools下):每个失败的工具调用都会被分类为一个与传输无关的 ClassifiedFailure { class, category, cause_plain, next_action, recoverable }。类别包括 MissingPermission, MissingApp, ServiceUnavailable, BadCredentials, BlockedByPolicy, ModelConnection, Timeout, Denied, ApprovalExpired;类别与 UI 状态一一映射: 可恢复 (安全自动重试), 被策略阻止 (更改设置), 需要用户确认 (登录 / 安装 / 授权), 用户拒绝 (绝不自动重试)。分类结果会随 AgentProgress::ToolCallCompleted.failure (包括子代理调用)进入聊天时间线。

日志、重放和迁移影子

每次运行都会追加到一个持久事件日志(tinyagents/journal.rs):一个 StoreEventJournal ,位于 {workspace}/tinyagents_store/journal 的 JSONL 追加式存储之上,组合为 {workspace}/tinyagents_store/journal,构成为 FanOutSink (实时桥接 + 日志)→ RedactingSink (持久化前做凭据掩码),并带有可重启稳定的事件 ID({run_id}-evt-{offset})。即使是未被观察到的后台轮次,事后也可以重建。三个只读 RPC 暴露它: agent_run_events (分页式、支持按 run_id/offset/limit), agent_run_status (最新 harness 状态),以及 agent_runs_active (活动运行,可按线程或根运行过滤)。

剩余的存储切换在影子支架上运行。产品行为保持不变,并记录差异:

  • 会话驻留在存储后端(DriverSessionStores);文件时代的双写和影子读取已经取消,而且 config.session_dual_write / session_shadow_reads 不再有任何效果。

目标和待办现在完全由 crate 支持,没有影子:线程目标存放在 crate graph.goals KV 存储(agent/goals/store.rs)中,会话待办列表则存放在进程内 crate graph.todos 存储(agent/todos/ops.rs);另见 目标和待办.

工作负载路由与突发层

tinyagents/routes.rs 是面向 OpenHuman 工作负载角色的声明式 TinyAgents ModelRouter ,适用于 OpenHuman 的工作负载角色 chat, reasoning, agentic, coding, burst, summarization,以及 vision,按其 hint:* 别名进行键控。它负责回退链和能力门控; inference::provider::factory 将所选每个角色解析为其配置的原生 ChatModel,在托管后端上解析为固定的默认模型(openrouter/deepseek/deepseek-v4-flash ,除非在“设置 → 路由 → 默认模型”中更改);没有按角色区分的层级端点。 burst 该角色用于低上下文、高扇出的工作器。

另请参见

最后更新于