跳到主要内容

上下文控制:instructions、按需 skills、subagents

30 秒导读: 一个 agent 跑得好不好,关键看「模型每一回合到底看到了什么」。eve 给你三档杠杆: instructions永远在场的系统提示;skills/ 默认不进提示,模型按需用 load_skill 拉进来; subagents 干脆把一整段活委派给一个有独立提示、工具、沙箱、状态的子 agent。本章讲这三档怎么实现。

本章只讲「eve 如何控制模型每回合看到什么」。沙箱的信任边界细节见 05-channels-connections-security; 默认 harness 的 agent 循环与 compaction 见 03-harness-tool-loop;文件系统如何被发现、编译成 manifest 见 01-filesystem-discovery


1. 这是什么(零基础也能懂)

一句话定义: 上下文控制 = 决定「模型这一回合的提示里塞了什么、没塞什么」的那套规则。

一个 agent 的「智力」很大程度上是被它看到的上下文决定的。塞太多——又贵、又慢、又容易被无关内容带偏; 塞太少——它不知道该怎么干。所以问题永远是:哪些东西每回合都得在,哪些只在需要时才出现,哪些根本不该进这个 agent 的提示。

eve 把答案分成三档,按「成本」由低到高排:

杠杆什么时候在提示里适合放什么
instructions(指令)每一回合都在(always-on)agent 的永久身份、稳定契约
skills/(技能)默认不在,模型 load_skill 后才注入可选的、长的操作手册 / playbook
subagents(子 agent)完全不在本 agent 的提示里,另起一个隔离上下文需要不同身份 / 不同工具面 / 想并行的整段活

一句话直觉/类比: 把它想成你桌上的三种东西——

  • instructions贴在显示器上的便签:抬头就看见,永远在。
  • skills书架上的手册:平时收着,要用时才抽出来翻开。
  • subagent把活外包给另一个同事:他有自己的桌子、自己的工具、自己的便签,干完把结果交回来。

一个最小的目录长这样(eve 是「文件系统即接口」,见 01-filesystem-discovery):

agent/
├── agent.ts # 定义 agent(模型、limits 等)
├── instructions.md # always-on 系统提示
├── skills/
│ ├── forecast.md # flat skill(单文件)
│ └── research/SKILL.md # packaged skill(目录 + 附属文件)
└── subagents/
└── researcher/agent.ts # 声明式子 agent(必须 export description)

本节不出现底层代码。记住一句话就够:instructions 永远在、skills 按需进、subagent 整段外包。


2. 顶层全景(三档杠杆怎么落到一次模型调用)

这节讲:一次模型调用的系统提示到底是怎么拼出来的,三档杠杆各自在哪一步进场。

怎么读这张图: 从上到下是「拼一次模型提示」的顺序;左边是 always-on 的、右边是按需的。

┌─────────────────────────────────────────────┐
│ 一次模型调用的 system 提示 │
└─────────────────────────────────────────────┘

┌──────────────────────────────┼──────────────────────────────┐
│ always-on(每回合都拼) │ 按需(只有触发了才进) │
▼ ▼ ▼
┌───────────────┐ ┌────────────────────┐ ┌────────────────────┐
│ instructions │ │ Available skills 清单│ │ load_skill 的结果 │
│ 的 markdown │ │ (只列名+描述,不含正文)│ │ (某个 SKILL.md 正文)│
│ ① 永久身份 │ │ ② 路由提示 │ │ ③ 模型主动拉来的 │
└───────────────┘ └────────────────────┘ └────────────────────┘
│ ▲
│ 另一条路:把整段活交出去,不进本提示 │ 模型调用 load_skill 工具
▼ │
┌──────────────────────────────────────────────┐ │
│ subagent → 另起一个隔离子 session │ ← 子 agent 有自己 │
│ (独立 instructions / tools / sandbox / state)│ 的这整张图 │
└──────────────────────────────────────────────┘

部件一句话职责:

部件干什么在哪个文件(packages/eve/src/)
提示拼装把 instructions / workspace / connections / skills 清单拼成 base 提示runtime/prompt/compose.ts:30 composeRuntimeBasePrompt
skills 清单格式化生成「Available skills」那一段(只列名+描述)execution/skills/instructions.ts:21 formatAvailableSkillsSection
load_skill 工具从沙箱读出某个 SKILL.md 正文,作为工具结果返回runtime/framework-tools/skill.ts:68 SKILL_TOOL_DEFINITION
每回合注入把动态指令 + 技能公告塞进本回合 system 消息harness/tool-loop.ts:564
subagent 注册把每个子 agent 降成一个模型可见工具runtime/subagents/registry.ts:49 createRuntimeSubagentRegistry
subagent 派发真正起一个子 session 跑这个委派execution/dispatch-runtime-actions-step.ts
深度上限算当前委派深度、决定还能不能再委派harness/subagent-depth.ts:21 resolveSubagentDelegationLimit

主线走一遍(高层): 一个回合开始 → harness 拼系统提示:instructions 正文 + workspace 提示 + connections + skills 清单(只有名字和描述) → 模型判断:这活要不要某个 skill?要 → 调 load_skill → eve 从沙箱读出那份 SKILL.md 正文当工具结果交回 → 模型拿到正文继续干。 若这活该整段外包 → 模型调某个 subagent 工具 → eve 起一个隔离子 session,跑完把结果当工具结果交回。


3. 核心机制一:instructions —— 永远在场的系统提示

它要解决的小问题

agent 总有一份「不管这回合干啥都该遵守」的契约:它是谁、说话风格、硬规矩。这部分必须每回合都在

思路:稳定 = 进 always-on,且尽量可被缓存

eve 的设计取舍是:把稳定的东西做成整个 session 不变的系统提示,这样上游的 prompt caching 能命中,省钱省延迟。 所以「永久身份」放 instructions,「会变的东西」(谁在调用、加载了哪个 skill)走别的通道,故意不去改 base 提示。

两种写法:markdown vs TypeScript

最简单的写法是 instructions.md,纯 markdown,就是一段稳定指令。

当你需要从「带类型的辅助函数 / 库代码 / 构建期环境值」拼出这段提示时,改写成模块 instructions.ts:

// 示意,非源码:agent/instructions.ts
import { defineInstructions } from "eve/instructions";
import { buildInstructionsPrompt } from "./lib/prompts.js";

export default defineInstructions({
markdown: buildInstructionsPrompt(), // 在构建期算出最终 markdown
});

关键细节:模块版只在构建期跑一次。 defineInstructions 的契约写得很明确:模块化的静态指令在构建期执行一次, 编译器把产出的 markdown 收进 compiled manifest,运行时每个 session 直接用同一份,不会再跑这个模块 (public/definitions/instructions.ts:19 InstructionsDefinition:34 defineInstructions)。

defineInstructions 还会给返回值打一个 brand 标记(INSTRUCTIONS_BRAND),让动态指令生命周期能校验「这个返回值确实是经过该 helper 的」 (public/definitions/instructions.ts:37)。

真实实现:instructions 怎么拼进 base 提示

base 提示由 composeRuntimeBasePrompt 拼:

// runtime/prompt/compose.ts:30 composeRuntimeBasePrompt(摘录)
return [
...createInstructionsPromptBlocks(input.instructions), // ① instructions 正文
...createWorkspacePromptBlocks(input.workspaceSpec), // ② workspace 浅提示
...(input.toolsAvailable ? [PARALLEL_ACTION_INSTRUCTION] : []),
...createConnectionsPromptBlocks(input.connections), // ③ connections
...createSkillsPromptBlocks(input.skills), // ④ skills 清单(只有名+描述)
];

注意函数名:composeRuntimeBasePrompt——「without flattening skills into always-on instructions」 (runtime/prompt/compose.ts:27 注释)。这一句是整章的题眼:skills 不会被压平进 always-on 指令,base 提示里只放 skills 的清单,不放正文。

instructions 那一块自己很朴素:trim 后非空才进,加一行标题 Instructions (<name>) (runtime/prompt/compose.ts:40 createInstructionsPromptBlocks)。

动态指令:按调用者变的那一档

当「该给什么上下文」取决于谁在调用(团队 / 租户 / 套餐 / feature flag),静态 instructions 就不够了。 这时在 agent/instructions/ 里用 defineDynamic 写一个 resolver,按 ctx.session.auth 或 channel 元数据返回这个 session 的系统提示

实现上,动态指令的产出不进 base 提示,而是按 session / turn 两个作用域存进 durable key,每回合由 tool-loop 重新拼进系统消息:

// context/dynamic-instruction-lifecycle.ts:42 buildDynamicInstructionMessages
const session = ctx.get(SessionDynamicInstructionsKey) ?? {};
const turn = ctx.get(TurnDynamicInstructionsKey) ?? {};
return [...Object.values(session).flat(), ...Object.values(turn).flat()]; // session 在前

每个 resolver 的输出替换它自己那一格(按 slug 键),分别落在 session.started / turn.started 对应的 durable key 上 (context/dynamic-instruction-lifecycle.ts:27 durableKeyForEvent)。返回值必须经 defineInstructions 打过 brand,否则被丢弃并报错 (:84)。指令只产 system 消息;要给 user 角色消息得走 channel context(见定义注释 public/definitions/instructions.ts:14)。


4. 核心机制二:skills —— 默认不入提示,按需注入

它要解决的小问题

很多「操作手册」很长很有用,但只在特定任务才需要(发布清单、写 changelog、调研流程)。 如果把它们全塞进 always-on 提示,每回合都在为没用上的内容付费,还冲淡了真正重要的指令。

思路:progressive disclosure(渐进式披露)

eve 的做法是业界 Agent Skills 标准的同一套:先只广播「有哪些 skill、各自该在什么场景用」,正文留着不进提示; 当模型判断这回合确实需要,它主动把正文拉进来。 一份按这个标准写的 skill 可以原样移植过来(docs/skills.mdx:6)。

回合开始
│ base 提示里只有这一段清单(便宜):
│ Available skills
│ - forecast: 回答天气/温度前先用天气工具 (path: /workspace/skills/forecast/SKILL.md)
│ - research: 遇到陌生/含糊问题先取证再答 ...

模型:这活匹配 research 吗?
├── 不匹配 → 照常干,正文从没进过提示(省了)
└── 匹配 / 用户点名 → 调 load_skill("research")

▼ eve 从沙箱读 /workspace/skills/research/SKILL.md
▼ 去掉 frontmatter,把正文当【工具结果】交回
模型拿到正文,本回合起按它干

skill 的两种形态:flat 和 packaged

形态长什么样描述(路由提示)从哪来
flat单个 skills/forecast.md可省 description frontmatter;省了就取正文第一行非空非围栏行(去掉 #/>/*/- 前缀)
packagedskills/research/ 目录 + SKILL.md + references//assets//scripts/必须description frontmatter(没有文件名 slug 可兜底)

packaged 的妙处:那些附属文件(参考、脚本、素材)不进提示,它们出现在运行时 workspace 根下,模型要看就用普通的 bash/read_file 去翻 (docs/skills.mdx:30docs/concepts/context-control.md:55)。这是 eve 一贯的取舍——把运行时文件放进 workspace,而不是灌进提示

真实实现一:清单怎么生成

清单段由 formatAvailableSkillsSection 生成。这个函数的文档注释把整章最核心的设计写死了:

// execution/skills/instructions.ts:8 注释(摘录)
// All skills are always listed regardless of activation state. Active skill
// instructions are never injected into the system prompt — the model already
// has them from the `load_skill` tool result. This keeps the system
// prompt identical across the entire session, preserving prompt caching.

翻成白话:不管哪个 skill 激活了,清单永远是全量、且只有名+描述;激活后的正文从不回填进系统提示——因为模型早已经从 load_skill 的工具结果里拿到了。这样系统提示整个 session 不变,prompt caching 才能一直命中。

每一行的格式带上了 workspace 路径,方便模型直接去 bash 看附属文件:

// execution/skills/instructions.ts:40 formatAvailableSkillLine
return `- ${skill.name}: ${skill.description} (path: ${WORKSPACE_ROOT}/skills/${skill.name}/SKILL.md)`;

(WORKSPACE_ROOT = /workspace,见 runtime/workspace/types.ts:9。)

真实实现二:load_skill 工具怎么读正文

load_skill 是 eve 自带的 framework 工具。它的执行体很直接:从当前沙箱读出那份 SKILL.md,去掉 YAML frontmatter,返回纯 markdown:

// runtime/skills/sandbox-access.ts:37 loadSkillFromSandbox(摘录)
assertSafeSkillId(id); // 先做路径段安全校验
const sandbox = await requireSandboxSession(access);
const path = skillFilePath(id, "SKILL.md"); // /workspace/skills/<id>/SKILL.md
const instructions = await sandbox.readTextFile({ path });
if (instructions === null) { /* 抛 not-found,并把可用 skill 名列进错误 */ }
return instructions.replace(FRONTMATTER_PATTERN, ""); // 去掉 frontmatter,只回正文

工具描述本身就在教模型怎么用:「按名加载一个可用 skill 的完整指令;这不是给 MCP connection 用的;加载会把指令加进当前回合」 (runtime/framework-tools/skill.ts:68 SKILL_TOOL_DEFINITION)。

两个值得记的坑/细节:

  • id 必须是安全路径段。 assertSafeSkillId 拒绝空串、含空白、. 前缀、/\..、盘符 (runtime/skills/sandbox-access.ts:12)——因为这个 id 会被直接拼进 /workspace/skills/<id> 路径,防的是路径穿越。
  • 把 connection 错认成 skill 会有专门提示。 如果模型拿一个 connection 名去 load_skill,eve 不只是报 not-found, 还会提示「那是个已安装的 connection,不是 skill,请用 connection_search」(runtime/framework-tools/skill.ts:42)。

真实实现三:激活后正文在哪一回合进场

清单里说「激活后正文不回填进 base 提示」,那它怎么持续生效?答案在 tool-loop 拼每回合系统消息那一步—— 动态指令 + 技能公告是每回合临时拼进 system 消息的,不动 base 提示:

// harness/tool-loop.ts:564 (摘录)
systemMessages.push(...buildDynamicInstructionMessages(ctx));
const skillAnnouncement = ctx.get(PendingSkillAnnouncementKey);
if (skillAnnouncement /* 非空 */) {
systemMessages.push({ role: "system", content: skillAnnouncement });
}

也就是说:base 提示(含 instructions + skills 清单)走缓存、保持不变;会变的那部分(动态指令、动态技能公告)每回合现拼,叠在前面。

动态 skills:按调用者发不同的技能集

和动态指令对称,agent/skills/ 里也能用 defineDynamic 写 resolver,按 ctx.session.auth 返回这个调用者能加载哪些 skill—— 比如计费团队才看得到计费 playbook(docs/concepts/context-control.md:73)。

实现上,resolver 命中事件后会:跑 handler → 把解析出的 skill 物化进沙箱(写出 SKILL.md 及附属文件)→ 清理被移除的 skill → 存一段「pending 公告」给 tool-loop 注入:

// context/dynamic-skill-lifecycle.ts:99 dispatchDynamicSkillEvent(尾部摘录)
const sandbox = await ctx.require(SandboxKey).get();
if (sandbox !== null) {
for (const { skills } of updates)
for (const skill of skills) await writeSkillPackageToSandbox({ sandbox, skill });
}
ctx.set(DynamicSkillManifestKey, newManifest);
ctx.setVirtualContext(PendingSkillAnnouncementKey, formatDynamicSkillAnnouncement(newManifest));

两个真实的命名规则坑:

  • 动态 skill 同名覆盖授权 skill。 动态写会覆盖同路径的授权文件,于是 load_skill 返回的是动态版正文; 但两个动态 resolver 撞同名是真歧义,直接抛错(context/dynamic-skill-lifecycle.ts:179 注释、:188 抛错)。
  • 公告复用同一个格式化器。 动态 skill 的公告和授权 skill 的清单走的是同一个 formatAvailableSkillsSection (context/dynamic-skill-lifecycle.ts:65),所以人看到的格式完全一致。

skill vs 工具:加载 skill 不增加执行面

一句必须记住的边界:加载一个 skill 只加指令,从不新增执行面。 工具是否可见,和有没有加载 skill 无关; 要新增「带类型的运行时行为」,该用工具,不是 skill(docs/skills.mdx:18)。

另一句:skills 按 agent 隔离。 一个 subagent 的 skills/ 对 root 不可见,反之亦然;没有共享 skill 机制, 共享的可执行逻辑放 lib/(docs/skills.mdx:60)。要从工具/hook 里读 packaged skill 的附属文件,用 ctx.getSkill(id) (handle 暴露 namefile(relativePath),内容从沙箱惰性读;public/definitions/callback-context.ts:36)。


5. 核心机制三:subagents —— 把整段活委派给隔离上下文

它要解决的小问题

有些活配得上自己的提示和工具面:要么需要不同的专家身份,要么想给它更窄的工具,要么想并行跑几个独立子任务。 这种活不该靠往 root 提示里堆东西解决——而该整段委派出去。

思路:subagent 也是一档上下文控制杠杆

关键认知:subagent 不是在 root 的上下文里「再接一段」,而是另起一个有自己 instructions / tools / sandbox / state 的隔离子 session (docs/concepts/context-control.md:67)。所以它天然是上下文控制的最重一档:被委派的活,完全不出现在 root 的提示里

两种 subagent

内建 agent 工具声明式 subagent
是什么自己的一份拷贝agent/subagents/<id>/ 下声明的专家
提示继承(同一个 agent)自己的 instructions.{md,ts},可选
工具继承自己的 tools/
沙箱与父共享自己的 sandbox/,否则回落框架默认
state全新全新
何时用拆并行、隔离复杂活需要明显不同的提示 / 角色 / 工具面

内建 agent 工具:自己的拷贝

每个 agent 默认都有一个 agent 工具,模型调它就是把子任务委派给自己的一份拷贝。这个默认工具就在 harness 工具表里现造 (若作者没有自定义同名工具):

// execution/node-step.ts:148 createNodeHarnessTools(摘录)
if (!tools.has("agent")) {
tools.set("agent", {
description: BUILT_IN_AGENT_TOOL_DESCRIPTION,
inputSchema: jsonSchema(SUBAGENT_TOOL_INPUT_SCHEMA),
name: "agent",
runtimeAction: { kind: "subagent-call", nodeId: input.node.nodeId, subagentName: "agent" },
});
}

它的描述把用法说清了:「委派给自己的一份新拷贝……一回合里发多个 agent 调用就并行跑一小撮固定的独立子任务…… 子代有全新的历史和 state,但共享你的工具和沙箱,所以把必要上下文放进 message,并给并行的写方不重叠的范围」 (execution/node-step.ts:27 BUILT_IN_AGENT_TOOL_DESCRIPTION)。

为什么内建拷贝共享沙箱?因为它们是「同一个 agent 在同一批文件上干活」。派发时专门为 agent 这一种把父沙箱状态接过去:

// execution/subagent-tool.ts:97 (摘录)
...(action.subagentName === "agent" && session.sandboxState
? { parentSandboxState: session.sandboxState, sandboxSessionId: session.sessionId }
: {}),

声明式 subagent 与隔离边界

声明式 subagent 住在 agent/subagents/<id>/,用和 root 同一个 defineAgentdescription必填——父读它来决定要不要委派, 缺了编译器直接拒(docs/subagents.mdx:38)。

最重要的一条:声明式 subagent 从 root 的授权槽位继承「什么都不」。 发现机制把它的目录当成自己的 agent 根, 所以它只有 subagents/<id>/ 下亲自授权的 instructions / tools / connections / skills / sandbox / hooks / 嵌套 subagent; 某个槽位空着就回落框架默认,而不是 root 的版本(docs/subagents.mdx:55)。

┌──────────── root agent ────────────┐
│ instructions / tools / skills / ... │
└──────────────┬──────────────────────┘
│ 调用 researcher(message)

┌──────────── researcher 子 session ────────────┐
│ 独立 instructions(空则框架默认,不是 root 的) │
│ 独立 tools / connections / skills │
│ 独立 sandbox(空则框架默认,不与 root 共享) │
│ 全新 state │
│ 看不到 root 的对话历史 ← 只收到 message │
└────────────────────────────────────────────────┘

所以声明式 subagent 要什么就得自带一份:两个 subagent 都要同一份 procedure,就把 markdown 各拷一份到各自 skills/, 或用 lib/ 共享带类型的 helper(docs/subagents.mdx:70)。defineState 对两种 subagent 都从不共享,每个子代都从全新 durable state 起步。

父代看到什么:统一降成一个工具

eve 把每种 subagent(内建拷贝、声明式、远程)都降成一个模型可见工具,输入 schema 永远是 { message, outputSchema? }:

// runtime/subagents/registry.ts:27 SUBAGENT_TOOL_INPUT_SCHEMA(摘录)
properties: {
message: { type: "string", /* 子代看不到父的历史,所有上下文都靠这个 message */ },
outputSchema: { type: "object", /* 给了就让子代跑 task 模式,产结构化输出当工具结果 */ },
},
required: ["message"],

注册时每个声明式 subagent 用裸的、由路径派生的名字,不带前缀:agent/subagents/researcher/ 注册成工具 researcher (对比 connection 工具的 <connection>__<tool> 命名空间)。因为它和授权工具共用同一个运行时工具命名空间, subagent 名撞上同名工具会让编译直接失败,eve 不替你选赢家(runtime/subagents/registry.ts:80reservedMessage;docs/subagents.mdx:108)。

子代每次委派都起自己的子 session 和 stream。父的 stream 上只有控制面事件 subagent.called / subagent.completed; 要跟子代的完整进度,读 subagent.called.data.childSessionId 去订阅它自己的 stream(docs/subagents.mdx:112)。

递归深度上限:别让委派无限套娃

subagent 委派默认封顶 3 层子 session,用 limits.maxSubagentDepth 调高或调低(docs/subagents.mdx:80)。 root session 是深度 0。深度判断的核心就一个小函数:

// harness/subagent-depth.ts:21 resolveSubagentDelegationLimit(摘录)
const currentDepth = parseSubagentDepth(session.subagentDepth);
const maxDepth = parseSubagentMaxDepth(session.subagentMaxDepth) ?? DEFAULT_SUBAGENT_MAX_DEPTH; // 3
return { currentDepth, maxDepth, nextChildDepth: currentDepth + 1, reached: currentDepth >= maxDepth };

这个 reached两个地方把闸:

  1. 到了上限就不再广播 subagent 工具——包括声明式子 agent、远程 agent 工具、内建 agent 工具,以及那个只暴露 subagent 的 Workflow 编排工具(docs/subagents.mdx:95)。过滤就发生在广播工具时:

    // harness/advertised-tools.ts:122 filterSubagentToolMapAtDepthLimit(摘录)
    if (delegationLimit.reached && isDelegatedRuntimeActionTool(tool)) {
    continue; // 到上限就把委派类工具从可见集里剔掉
    }
  2. 万一有过期或被强行发起的委派调用仍走到派发,eve 当场拦下,返回一个错误工具结果,而不是真的再起一个子 session:

    // execution/dispatch-runtime-actions-step.ts:101 (摘录)
    if (delegationLimit.reached && isSubagentDelegationAction(action)) {
    log.warn("subagent depth limit reached; blocking delegated call", { ... });
    results.push(createSubagentDepthLimitResult({ action, delegationLimit })); // 返回 depth-limit 错误结果
    continue;
    }

两道闸的分工很清楚:第一道(广播)是事前预防——模型根本看不到委派工具就不会去调;第二道(派发)是事后兜底—— 防住已经在途、过期或被绕过的调用。两者都靠同一个 resolveSubagentDelegationLimit,口径一致。

另外 Workflowroot-only 的:被委派的子 session 在到达上限前仍能调自己可见的 subagent 工具,但不会拿到 Workflow 编排封装 (docs/subagents.mdx:97)。

HITL 代理:子代要问人,信号怎么传上去

subagent 跑在隔离子 session 里,但人在和 root 对话。要是子代触发了 human-in-the-loop(要审批、要补输入), 这个「请求输入」的信号必须沿委派链一路传到 root,响应再沿原路传回那个子代。eve 用一个 framework adapter 做这件事。

向上代理: 子代发出 input.requested 时,subagent adapter 把它打包,通过 durable 工作流的 resumeHook 转发给父的 continuation token:

// execution/subagent-adapter.ts:70 SUBAGENT_ADAPTER["input.requested"](摘录)
const hookPayload: SubagentInputRequestHookPayload = {
callId: state.callId,
childContinuationToken: ctx.ctx.require(ContinuationTokenKey),
childSessionId: ctx.ctx.require(SessionIdKey),
event: { requests: data.requests, sequence: data.sequence, stepIndex: data.stepIndex, turnId: data.turnId },
kind: "subagent-input-request",
subagentName: state.subagentName,
};
await forwardSubagentInputRequestStep({ hookPayload, parentContinuationToken: state.parentContinuationToken });

adapter state(callId / parentContinuationToken / parentSessionId / subagentName)是派发时由 buildSubagentRunInput 种到子运行上的 (execution/subagent-tool.ts:89adapter.state),让子代始终带着「回父代」需要的血缘元数据。

向下路由: 父代收到响应后,按本 session 的 proxy 映射把一个 deliver payload 拆成「父自己的」和「该转给各子代的」两桶:

// execution/subagent-hitl-proxy.ts:75 routeDeliverPayload(职责)
// forSelf:父本地的余量(全转走则 undefined)
// forChildren:每个后代 token 一条

它按 response.requestId 在 proxy 表里查对应的 childContinuationToken,查到的归该子代、查不到的留给父自己 (execution/subagent-hitl-proxy.ts:85)。链路因此可嵌套:一个子代自己又委派出去的孙代,信号也能逐跳上传、响应逐跳下达。

边界提醒:别把「委派给 subagent」本身当成审批边界。 敏感工具该用 approval / connection 审批 / 路由或 session 授权去守, 不论它能从哪里被调到(docs/subagents.mdx:110)。沙箱信任边界细节见 05-channels-connections-security


6. 巧妙之处(可借鉴的技术)

  • 「永远在场的提示故意不变」是为了缓存。 把会变的(加载哪个 skill、谁在调用)从 base 提示里剥出去, 让系统提示整个 session 不变,prompt caching 一直命中。妙在 skills 清单永远全量、激活后正文走工具结果而非回填提示 (execution/skills/instructions.ts:8 注释)。

  • progressive disclosure 落地成「清单进提示、正文进工具结果」。 模型用一次 load_skill 工具调用把成本从「每回合」变成「按需一次」 (runtime/framework-tools/skill.ts:68runtime/skills/sandbox-access.ts:37)。

  • packaged skill 的附属文件不进提示、进 workspace。 参考/脚本/素材留在 /workspace/skills/<id>/ 下,模型用 bash 去翻, 提示更小、文件操作更显式(docs/concepts/context-control.md:55)。

  • subagent 统一降成一个 {message, outputSchema?} 工具。 内建拷贝、声明式、远程三种委派,父代看到的是同一种工具形状, 心智负担最小(runtime/subagents/registry.ts:27)。

  • 深度上限两道闸、同一口径。 事前从广播里剔除委派工具、事后在派发处兜底拦截,都用同一个 resolveSubagentDelegationLimit (harness/advertised-tools.ts:122execution/dispatch-runtime-actions-step.ts:101)。

  • HITL 沿委派链可嵌套地代理。 用 continuation token + proxy 映射做「向上转发请求、向下路由响应」, 孙代的问人请求也能逐跳传到 root(execution/subagent-adapter.ts:70execution/subagent-hitl-proxy.ts:75)。


7. 边界与局限(诚实)

  • skills 不增加执行面。 加载 skill 只加指令;要新工具行为得真写工具(docs/skills.mdx:18)。
  • 声明式 subagent 不继承 root 的授权槽位。 空槽位回落框架默认而非 root 版本;要的东西得自带一份,没有共享 skill 机制 (docs/subagents.mdx:55:70)。
  • schedules/ 与 channels 是 root-only,声明式 subagent 里不支持 schedules(docs/subagents.mdx:52:67)。
  • Workflow 编排工具 root-only,被委派的子 session 不会拿到它(docs/subagents.mdx:97)。
  • 委派不是审批边界。 别靠「藏在 subagent 后面」当安全;敏感工具要单独加审批/授权(docs/subagents.mdx:110)。
  • 命名空间会撞。 subagent 名和工具名同名 → 编译失败;两个动态 resolver 同名 skill → 运行时抛错 (runtime/subagents/registry.ts:80context/dynamic-skill-lifecycle.ts:188)。
  • 子代看不到父的历史。 所有上下文必须塞进 message;别把不该给这个子代(及其工具/连接/沙箱/遥测路径)的敏感数据放进去 (docs/subagents.mdx:21)。

8. 代码地图(导航索引)

主题文件(packages/eve/src/)符号
base 提示拼装(不压平 skills)runtime/prompt/compose.tscomposeRuntimeBasePrompt
instructions 定义 + 构建期一次public/definitions/instructions.tsInstructionsDefinition · defineInstructions
动态指令注入(session/turn)context/dynamic-instruction-lifecycle.tsbuildDynamicInstructionMessages · dispatchDynamicInstructionEvent
skills 清单格式化(全量、只名+描述)execution/skills/instructions.tsformatAvailableSkillsSection · formatAvailableSkillLine
load_skill 工具定义runtime/framework-tools/skill.tsSKILL_TOOL_DEFINITION · executeLoadSkillTool
从沙箱读 SKILL.md + id 安全校验runtime/skills/sandbox-access.tsloadSkillFromSandbox · assertSafeSkillId · createSandboxSkillHandle
动态 skills 物化 + 公告context/dynamic-skill-lifecycle.tsdispatchDynamicSkillEvent · PendingSkillAnnouncementKey
每回合注入动态指令+技能公告harness/tool-loop.ts(约 :564)
内建 agent 工具execution/node-step.tscreateNodeHarnessTools · BUILT_IN_AGENT_TOOL_DESCRIPTION
subagent 降成模型工具runtime/subagents/registry.tscreateRuntimeSubagentRegistry · SUBAGENT_TOOL_INPUT_SCHEMA
构建子运行输入 + 共享沙箱execution/subagent-tool.tsbuildSubagentRunInput
子代输入消息格式化execution/subagent-invocation.tsformatSubagentInput
深度上限计算harness/subagent-depth.tsresolveSubagentDelegationLimit · DEFAULT_SUBAGENT_MAX_DEPTH
事前:广播里剔除委派工具harness/advertised-tools.tsfilterSubagentToolMapAtDepthLimit
事后:派发处拦截超限委派execution/dispatch-runtime-actions-step.tscreateSubagentDepthLimitResult
HITL 向上代理 adapterexecution/subagent-adapter.tsSUBAGENT_ADAPTER · SubagentAdapterState
HITL 向下路由execution/subagent-hitl-proxy.tsrouteDeliverPayload · emitProxiedInputRequest

相邻章节: index(架构总览) · 01-filesystem-discovery(发现与编译) · 02-execution-durability(session/turn/step) · 03-harness-tool-loop(默认 harness 与 compaction) · 05-channels-connections-security(channels / connections / 安全)