fix(agent-loop): refresh provider context at actual request admission

This commit is contained in:
Tianyi Cui committed 2026-10-06 01:31:52 +08:00
1 parent 2489bbba16
commit 9c679b21f4
26 files changed
+444 -22

No files matched your search

+2 -2
View File
@@ -24,8 +24,8 @@
en: 214c3c81c21df6f9
zh: 75d2cdc3c9fc9930
/deepseek-harness-architecture/turn-flow:
en: 826adf5273586473
zh: 17b1c94a4c432e47
en: e6402b28c0ed15fa
zh: 6c07be9047b42056
/deepseek-harness-architecture/session-log:
en: a2e35c43438fec6d
zh: e27bdac8e9feff32
+1 -1
View File
@@ -108,7 +108,7 @@ turn/end
One inbox feeds the driver; injected context waits for a waking message. AgentLoop’s durable `inbox` projection exposes pending input without live Agents.
`agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Every attempt reconciles the rendered assembly, admits users once, restores changed or removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes append non-empty updates after cached history, including supported tool updates; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)).
`agent/pre-step` decides the accepted input. Listeners may rewrite or reject claimed messages; a rejected or empty first claim closes a durable turn without a step. An enter decision may set `startsRequestSeries`: the loop logs a fresh `request/header` (reason `series`, or `change` with `startsSeries: true` when the envelope also changed). Wrapping listeners preserve that declaration with `{ ...decision, messages }`. After assembly and `step/start`, `agent/request` and `prepareCall()` resolve the actual route before the system prompt and accepted users are committed; cancellation during either async phase commits neither. Prepared call capabilities govern prompt admission, not preceding `request/context`. Each attempt refreshes registered runtime facts, reconciles the bound prompt, admits users once, and restores removed runtime context, logs header/context as needed, and freezes the request before streaming the bound call. Retries do not repeat assembly or `agent/pre-step`. Surface replacements and image-offload decisions after attachment start a new request series, including during the first resumed pre-step; unchanged resume continues the series. The first admitted step reserves the system head before user messages even for an empty prompt (no wire message). The prompt travels only as `system/message` history: an empty rendering clears all active system nodes, leaving no old prompt model-visible; capable routes append non-empty updates after cached history, including supported tool updates; incapable routes and new request series consolidate non-empty prompt text at the first system node, with logged empty replacements for non-empty later system nodes ([decision](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.md); [decision rule](../packages/core/agent-loop/README.md#understand-the-implementation)).
The loop sends immutable requests with live cancellation and reuses freeze evidence for frozen identities; [agent-loop](../packages/core/agent-loop/README.md) owns construction and cancellation causes.
+1 -1
View File
@@ -112,7 +112,7 @@ turn/end
输入通过同一个 inbox 到达驱动器;注入的上下文等待一条唤醒消息。AgentLoop 的持久 `inbox` 投影使待处理输入在没有活跃 Agent 时仍可读取。
`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试协调渲染后的组装结果、仅接纳一次用户消息、恢复已变化或被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新,包括同时发生的受支持工具更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。
`agent/pre-step` 决定接纳的输入。监听器可以改写或拒绝已领取消息;首次领取被拒绝或为空时,关闭不含步骤的持久轮次。enter 决策可设置 `startsRequestSeries`:循环记录新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。包装监听器通过 `{ ...decision, messages }` 保留该声明。组装与 `step/start` 之后,`agent/request` 和 `prepareCall()` 先解析实际路由,再提交系统提示词与已接纳用户消息;在任一异步阶段取消都不会提交这两者。提示词准入依据已准备调用的能力,而非先前的 `request/context`。每次尝试刷新已注册运行时事实、协调绑定的提示词、仅接纳一次用户消息并恢复被移除的运行时上下文、按需记录 header/context,再冻结请求并通过绑定调用发起流式请求。重试不重复组装或 `agent/pre-step`。附接后的 surface 替换和图片省略决定开启新请求序列,包括恢复后的首次 pre-step 中发生的替换;未变化的恢复延续序列。首次接纳的步骤在用户消息之前预留系统头节点,即使提示词为空(不产生协议消息)。提示词仅通过 `system/message` 历史传递:空渲染文本清除所有生效的系统节点,模型不再看到旧提示词;具备能力的路由可在缓存前缀之后追加非空更新,包括同时发生的受支持工具更新;不具备能力的路由与新请求序列将非空提示词文本归并到首个系统节点,并为非空的后续系统节点记录空内容替换([决策](../.agents/notes/implemented/architecture/2026-09-02-system-prompt-as-surface-node.zh.md);[决策规则](../packages/core/agent-loop/README.zh.md#understand-the-implementation))。
循环发送不可变请求,同时保持取消有效,仅对完全冻结的对象复用冻结证据;[agent-loop](../packages/core/agent-loop/README.zh.md) 负责请求构建与取消原因。
@@ -12,5 +12,5 @@
en: d2738fe9f819599d
zh: 4eaacaeadcd80adf
/migrate-working-directory-configuration-and-tool-presentation/migration:
en: 2c0921b75b8154da
zh: b64d4f4174356753
en: 01ae6e89e3312de8
zh: 503981500691b714
@@ -21,3 +21,5 @@ Tool `output.presentationMeta` also runs for successful nested PTC calls. Author
2. Remove `config.cwd` from ACP and DSH-SDK subagent provider rows in your profile patches. To select a directory for one child, pass `cwd` in its [start request](../../../subsystems/subagent.md). To change the parent's execution directory, use `working_directory({ cd: "/absolute/project" })` before starting the child.
3. Run `working_directory({})` in the affected Session and confirm the reported directory. Start a child without an override and check its execution directory; use an explicit start `cwd` when the child must use another checkout. Read `Session.header.cwd` only for original project identity or permission roots, rather than current-directory operations.
4. Keep custom `output.presentationMeta` projections pure and return JSON-safe metadata from their arguments and canonical result. Exercise both a native call and a nested PTC call, and confirm the recorded metadata identifies the operation's actual target without changing its result.
Dynamic context providers are evaluated again at request admission, after pre-step listeners and route preparation. Return current state without relying on one evaluation per step. Transform registered context inside its provider; admission refresh replaces its cached text while preserving assembly-only context contributions and the accepted system prompt, tool schemas, and variables.
@@ -21,3 +21,5 @@ description: "Session 工作目录替代内置 cwd 配置,工具展示元数
2. 从 profile patch 中的 ACP 与 DSH-SDK 子代理 provider 行移除 `config.cwd`。为单个子代理选择目录时,在其[启动请求](../../../subsystems/subagent.zh.md)中传入 `cwd`。改变父代理执行目录时,先调用 `working_directory({ cd: "/absolute/project" })`,再启动子代理。
3. 在相关 Session 中调用 `working_directory({})` 并确认报告的目录。启动一个不带目录覆盖的子代理并检查其执行目录;子代理需要另一个 checkout 时,显式提供启动 `cwd`。仅用 `Session.header.cwd` 读取原始项目标识或权限根目录,当前目录操作使用工作目录服务。
4. 保持自定义 `output.presentationMeta` 投影纯净,并从调用参数和规范结果返回可用于 JSON 的元数据。分别执行原生调用和嵌套 PTC 调用,确认记录的元数据指向操作的实际目标,同时不改变调用结果。
动态上下文 provider 会在请求准入时再次求值,晚于 pre-step 监听器与路由准备。返回当前状态,不依赖每个步骤只求值一次。对已注册上下文的转换应在其 provider 内完成;准入刷新会替换缓存文本,同时保留仅由组装添加的上下文,以及已接纳的系统提示词、工具 schema 与变量。
+2 -2
View File
@@ -45,8 +45,8 @@
en: da056b9f0dde4ab3
zh: ba9ce3b19c17ca22
/deepseek-ai-dsh-agent-loop/understand-the-implementation/turn-and-step-flow:
en: fa75527f92ea6725
zh: e4d80c44d3ed49cd
en: 28939261ccc7defe
zh: 9d157a321307ebd4
/deepseek-ai-dsh-agent-loop/understand-the-implementation/failure-and-cancellation:
en: eea56903c4ff82f5
zh: c0d5abb5767780ed
+1 -1
View File
@@ -115,7 +115,7 @@ The loop is the production acquisition point for session write handles. When `ct
### Turn and step flow
The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. `AgentLoop` registers the standard `inbox` projection for its service lifetime, so cold reads work before any Agent exists and after all Agents unload. Its package-internal `ReactLoopInbox` uses that shared projection for structural commands and loop-only claims. At a turn boundary it opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. The driver assembles the prompt and tools and runs `agent/pre-step`; the fallback projects runtime context after listeners run. A rejected decision or empty first batch opens no step. On the first attempt after acceptance, `step/start` precedes the `agent/request` waterfall and `prepareCall()`; neither async phase sees the pending system prompt or accepted users committed to history, and cancellation during either commits neither. For every attempt, the loop then synchronously reconciles the rendered prompt against the surviving `system/message` nodes using the prepared call capability, appends the accepted `user/message` batch only on the first attempt, restores changed or removed runtime context, logs the header and context as needed, and derives and freezes the request before streaming through that bound prepared call. Retries reuse the same rendered assembly without repeating assembly, `agent/pre-step`, or user admission. Reconciliation sees pre-step and retry compaction; a broken series consolidates the prompt at the head rather than appending an update after already-committed users. The request is `header.config`, `deriveMessages()`, and `header.tools`; it carries no `system` field. Each model attempt emits one process-local `start`, emits every `chunk` only after the matching durable assistant-frame settlement, and emits exactly one terminal `end`; final assembly or message-append failure settles it as `aborted`, while `committed` follows the durable `assistant/message`. Each successful model call appends one message anchor, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results... (line truncated to 2000 chars)
The driver owns one agent for its lifetime and runs inside `ctx.agents.withInitiator(agent, ...)`. `AgentLoop` registers the standard `inbox` projection for its service lifetime, so cold reads work before any Agent exists and after all Agents unload. Its package-internal `ReactLoopInbox` uses that shared projection for structural commands and loop-only claims. At a turn boundary it opens the durable turn, then atomically claims pending next-step input plus one queued prompt; between steps it claims only next-step input. The driver assembles the prompt and tools and runs `agent/pre-step`; the fallback projects runtime context after listeners run. A rejected decision or empty first batch opens no step. On the first attempt after acceptance, `step/start` precedes the `agent/request` waterfall and `prepareCall()`; neither async phase sees the pending system prompt or accepted users committed to history, and cancellation during either commits neither. For every attempt, the loop then synchronously reconciles the rendered prompt against the surviving `system/message` nodes using the prepared call capability, refreshes registered runtime facts without reassembling the bound prompt or tools, appends the accepted `user/message` batch only on the first attempt with its owned context candidate replaced in place, and restores removed runtime context, logs the header and context as needed, and derives and freezes the request before streaming through that bound prepared call. Retries reuse the same rendered assembly without repeating assembly, `agent/pre-step`, or user admission. Reconciliation sees pre-step and retry compaction; a broken series consolidates the prompt at the head rather than appending an update after already-committed users. The request is `header.config`, `deriveMessages()`, and `header.tools`; it carries no `system` field. Each model attempt emits one process-local `start`, emits every `chunk` only after the matching durable assistant-frame settlement, and emits exactly one terminal `end`; final assembly or message-append failure settles it as `aborted`, while `committed` follows the durable `assistant/message`. Each successful model call appends one message anchor, and a cancelled stream appends an `interrupted: true` anchor with the delivered prefix so the next request contains what the user saw. Within a step, exclusive calls form barriers and parallel-safe calls use the bounded rolling pool; policy, durable results... (line truncated to 2000 chars)
Prompt admission uses the actual `prepareCall()` result, not the preceding `request/context`. With no system node, even an empty prompt is appended (reserving node 0 without a wire message). On an incapable route or at a new request series, a non-empty rendering is consolidated at the first system node: each non-empty later system node receives a logged per-node empty replacement, then the head is rewritten if needed. Dormant empty tails need no replacement and do not determine the effective text. Consolidation applies even when the latest effective text is unchanged. On a continuing `in-history` series, an unchanged effective prompt produces no event and a non-empty change appends. An empty rendering clears every non-empty later system node through a logged per-node empty replacement, then empties the head if needed, regardless of route or series state. No older instructions remain model-visible. An empty head with no active later system node means no prompt; repeated clears and resume leave it empty. A restored non-empty prompt follows the same route/series rule: a continuing capable route may append it, while an incapable route or new series refills the head. A step starts a series when the pre-step decision declares `startsRequestSeries` or `session.surface.contentGeneration` changed since attachment or the last request (a replacement or image-offload decision). Visible tool-schema changes force prompt consolidation only when the prepared route lacks `toolUpdate`. Routes supporting both tool and system-prompt updates can append a changed prompt and activate added tools without rewriting earlier messages. If another admission condition replaces a system node, the request starts a new series with current tool declarations. Resume and a provider or model swap alone continue the series; the prepared route still governs admission. Per-node empty replacements preserve intervening history without a surface delete operation.
+1 -1
View File
@@ -115,7 +115,7 @@ const handle = await ctx.agents.create({
### 轮次与步骤流程
驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。`AgentLoop` 在服务生命周期内注册标准 `inbox` 投影,因此冷读取在没有 Agent 和所有 Agent 卸载后都可用。其包内部 `ReactLoopInbox` 使用该共享投影执行结构化命令与仅供 loop 使用的领取操作。在轮次边界,它先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。驱动器组装提示词与工具,并运行 `agent/pre-step`;fallback 在监听器执行后投影运行时上下文。被拒绝的决定或空的首批输入不打开步骤。接纳后的首次尝试先记录 `step/start`,再运行 `agent/request` waterfall 与 `prepareCall()`;这两个异步阶段都看不到待提交的系统提示词与已接纳用户消息进入历史,在任一阶段取消都不会提交这两者。每次尝试时,循环随后依据已准备调用的能力,同步将渲染后的提示词与存活的 `system/message` 节点协调一致、仅在首次尝试追加已接纳的 `user/message` 批次、重新投影被替换或移除的运行时上下文、按需记录 header 与 context,再派生并冻结请求,通过该绑定的已准备调用发起流式请求。重试复用同一份已渲染组装结果,不重复组装、`agent/pre-step` 或用户消息准入。协调过程可见 pre-step 与重试中的压缩;序列中断时将提示词归并到头部,而非在已提交用户消息之后追加更新。请求由 `header.config`、`deriveMessages()` 与 `header.tools` 构成,不携带 `system` 字段。每次模型尝试会发出一个进程本地 `start`,仅在匹配的持久 assistant-frame 结算之后发出各个 `chunk`,并恰好发出一个终态 `end`;最终组装或消息追加失败时以 `aborted` 结算,`committed` 则出现在持久 `assistant/message` 之后。每次成功的模型调用都恰好追加一个 message 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。
驱动器在其整个生命周期内拥有一个 agent,并在 `ctx.agents.withInitiator(agent, ...)` 内运行。`AgentLoop` 在服务生命周期内注册标准 `inbox` 投影,因此冷读取在没有 Agent 和所有 Agent 卸载后都可用。其包内部 `ReactLoopInbox` 使用该共享投影执行结构化命令与仅供 loop 使用的领取操作。在轮次边界,它先打开持久轮次,再原子领取待处理的 next-step 输入与一条排队提示词;在步骤之间则只领取 next-step 输入。驱动器组装提示词与工具,并运行 `agent/pre-step`;fallback 在监听器执行后投影运行时上下文。被拒绝的决定或空的首批输入不打开步骤。接纳后的首次尝试先记录 `step/start`,再运行 `agent/request` waterfall 与 `prepareCall()`;这两个异步阶段都看不到待提交的系统提示词与已接纳用户消息进入历史,在任一阶段取消都不会提交这两者。每次尝试时,循环随后依据已准备调用的能力,同步将渲染后的提示词与存活的 `system/message` 节点协调一致、刷新已注册运行时事实而不重新组装绑定的提示词或工具、仅在首次尝试追加已接纳的 `user/message` 批次并原位替换循环自有上下文候选、恢复被移除的运行时上下文、按需记录 header 与 context,再派生并冻结请求,通过该绑定的已准备调用发起流式请求。重试复用同一份已渲染组装结果,不重复组装、`agent/pre-step` 或用户消息准入。协调过程可见 pre-step 与重试中的压缩;序列中断时将提示词归并到头部,而非在已提交用户消息之后追加更新。请求由 `header.config`、`deriveMessages()` 与 `header.tools` 构成,不携带 `system` 字段。每次模型尝试会发出一个进程本地 `start`,仅在匹配的持久 assistant-frame 结算之后发出各个 `chunk`,并恰好发出一个终态 `end`;最终组装或消息追加失败时以 `aborted` 结算,`committed` 则出现在持久 `assistant/message` 之后。每次成功的模型调用都恰好追加一个 message 锚点,被取消的流则追加带 `interrupted: true` 的锚点并携带已交付前缀,使下一次请求包含用户看到的内容。在步骤内,独占调用形成屏障,并行安全调用使用有界滚动池;策略、持久结果与结果上下文保持模型顺序。
提示词准入依据实际的 `prepareCall()` 结果,而非先前的 `request/context`。没有系统节点时,即使提示词为空也追加(预留第 0 号节点,但不产生协议消息)。在不具备能力的路由上或新请求序列开始时,非空渲染文本归并到首个系统节点:每个非空的后续系统节点分别收到有日志记录的空内容替换,随后按需重写头节点。未生效的空尾节点无需替换,也不决定有效文本。即使最新有效文本未变,也执行归并。延续中的 `in-history` 序列在有效提示词不变时不产生事件,非空变更则追加。无论路由或序列状态如何,空渲染文本都会通过有日志记录的逐节点空内容替换清除每个非空的后续系统节点,再按需清空头节点。模型不会继续看到旧指令。空头节点且没有生效的后续系统节点表示没有提示词;重复清除与恢复会话都保持为空。重新提供的非空提示词遵循同一路由/序列规则:延续中的具备能力路由可以追加它,不具备能力的路由或新序列则重新填充头节点。以下情况开启序列:pre-step 决定声明 `startsRequestSeries`,或 `session.surface.contentGeneration` 自附接或上次请求以来发生变化(替换或图片省略决定)。仅当已准备路由未声明 `toolUpdate` 时,可见工具 schema 变化才强制归并提示词。同时支持工具和系统提示词更新的路由可以追加变更后的提示词并激活新增工具,无需改写此前消息。如果其他准入条件替换系统节点,请求会以当前工具声明开启新序列。恢复与单纯的提供方或模型切换都延续序列;准入仍由已准备的路由决定。逐节点的空内容替换保留其间历史,无需 surface 删除操作。
+14 -4
View File
@@ -408,6 +408,10 @@ export class ReactLoopAgent implements Agent {
let firstAttempt = true
while (true) {
const { config, preparedCall } = await this.prepareRequest(turn, step, signal)
const currentContext = this.loopCtx.systemPrompt.refreshContext(assembly, assembleContextFor(this, signal))
const sections = renderContextSections(currentContext)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
signal.throwIfAborted()
const startsRequestSeries = firstAttempt && decision.startsRequestSeries === true
const commits = this.systemPrompt.project(renderedPrompt, {
inHistory: preparedCall?.systemPromptUpdate === 'in-history',
@@ -418,14 +422,20 @@ export class ReactLoopAgent implements Agent {
for (const { message, intent } of commits) {
this.session.append('system/message', { turn, step, message }, intent)
}
let contextAdmitted = false
if (firstAttempt) {
for (const message of decision.messages) {
this.session.append('user/message', message, { surfaceOp: 'append' })
if (message.source.kind === 'runtime-context') {
if (context !== undefined && !contextAdmitted) {
this.session.append('user/message', context, { surfaceOp: 'append' })
contextAdmitted = true
}
} else {
this.session.append('user/message', message, { surfaceOp: 'append' })
}
}
}
const sections = renderContextSections(assembly)
const context = this.runtimeContext.project(joinContextSections(sections), sections)
if (context !== undefined) {
if (context !== undefined && !contextAdmitted) {
this.session.append('user/message', context, { surfaceOp: 'append' })
}
firstAttempt = false
@@ -807,6 +807,73 @@ describe('agent loop', () => {
}
})
it('replaces a stale owned snapshot at its accepted position and admits it once', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
try {
let current = 'before'
ctx.systemPrompt.context({ name: 'fact', order: 0, text: () => current })
const agent = await ctx.agentLoop.create(SessionId('context-position'), { provider: 'mock', model: 'mock' })
ctx.on('agent/pre-step', async (_payload, next) => {
const decision = await next()
current = 'after'
if (decision.kind === 'reject') return decision
const context = decision.messages.find(message => message.source.kind === 'runtime-context')!
return { ...decision, messages: [context, ...decision.messages.filter(message => message !== context), context] }
})
send(agent, 'accepted user')
await agent.whenIdle()
const users = adapter.requests[0]?.messages.filter(message => message.role === 'user') ?? []
expect(users.map(message => message.source?.kind)).toEqual(['runtime-context', 'user'])
expect(users[0]?.content).toEqual([{ type: 'text', text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nafter' }])
expect(users[1]?.content).toEqual([{ type: 'text', text: 'accepted user' }])
expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message')).toHaveLength(2)
} finally {
await ctx.fiber.dispose()
}
})
it('drops an uncommitted optional snapshot suppressed after the pre-step fallback', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
try {
ctx.systemPrompt.context({ name: 'fact', order: 0, text: 'optional' })
const agent = await ctx.agentLoop.create(SessionId('context-suppressed-at-admission'), { provider: 'mock', model: 'mock' })
ctx.on('agent/pre-step', async (_payload, next) => {
const decision = await next()
ctx.systemPrompt.suppressRuntimeContext()
return decision
})
send(agent, 'accepted user')
await agent.whenIdle()
expect(adapter.requests[0]?.messages.filter(message => message.role === 'user').map(message => message.source?.kind)).toEqual(['user'])
expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message')).toHaveLength(1)
} finally {
await ctx.fiber.dispose()
}
})
it('commits no pending user or prompt when a context provider cancels admission', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
try {
const agent = await ctx.agentLoop.create(SessionId('context-canceled-at-admission'), { provider: 'mock', model: 'mock' })
let evaluations = 0
ctx.systemPrompt.context({ name: 'cancel', order: 0, text: () => {
if (++evaluations === 2) agent.cancel({ kind: 'user' })
return 'pending'
} })
send(agent, 'do not commit')
await agent.whenIdle()
expect(evaluations).toBe(2)
expect(adapter.requests).toHaveLength(0)
expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' || event.type === 'system/message')).toHaveLength(0)
expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason.kind).toBe('aborted')
} finally {
await ctx.fiber.dispose()
}
})
it('restores runtime context during a retry without admitting the user batch twice', async () => {
const adapter = new MockAdapter([textResponse('one'), () => { throw new LlmError('overflow', 'CONTEXT_LENGTH') }, textResponse('two')])
const ctx = await harness(adapter)
+2 -2
View File
@@ -21,8 +21,8 @@
en: 87e28893e6cf58d7
zh: 06f476b38c4d2038
/deepseek-ai-dsh-system-prompt/use-this-package/contribute-a-prompt-section:
en: a833816d4e817d5c
zh: 72431c506aac0bb2
en: 165fecb5329b00b6
zh: a7c595d1787916c9
/deepseek-ai-dsh-system-prompt/use-this-package/contribute-a-prompt-variable:
en: b3e4c9b4ced59488
zh: d80c791e72bd5821
+2
View File
@@ -65,6 +65,8 @@ ctx.systemPrompt.section({
Set `interpolate: false` on a section or dynamic context to preserve its text literally, including `{{…}}` groups in generated tool documentation or directory names. Other contributions interpolate variables by default.
`refreshContext(assembly, context)` refreshes registered runtime facts after pre-step and route preparation. It preserves accepted sections, tools, variables, and contexts contributed only by the assembly waterfall, applies current optional-context suppression, and restores missing required entries. Put changing facts in context providers; registered context text is read again before admission.
### Contribute a prompt variable
Variables are referenced from section or context text as `{{name}}` and resolved at each assembly; scoped variables shadow a same-named global for that agent. The loop supplies `provider` and `model`; plugins can register the facts they own. The working-directory service supplies directory text as literal user context.
+2
View File
@@ -65,6 +65,8 @@ ctx.systemPrompt.section({
在段或动态上下文上设置 `interpolate: false` 可原样保留文本,包括生成的工具文档或目录名中的 `{{…}}` 组。其他贡献默认执行变量插值。
`refreshContext(assembly, context)` 在 pre-step 与路由准备之后刷新已注册的运行时事实。它保留已接纳的段、工具、变量及仅由组装 waterfall 添加的上下文,应用当前的可选上下文抑制,并恢复缺失的必需条目。将变化的事实放在上下文 provider 中;已注册上下文的文本会在准入前再次读取。
### 贡献提示词变量
变量从段或上下文文本中以 `{{name}}` 引用,每次组装时解析;带作用域的变量会为该 agent 遮蔽同名全局变量。循环提供 `provider` 和 `model`;插件可注册自己拥有的事实。工作目录服务通过不执行插值的用户上下文提供目录文本。
+43 -6
View File
@@ -81,7 +81,7 @@ export interface PromptContext {
readonly name: string
/** Contexts are joined in ascending order. */
readonly order: number
/** Static text or a provider evaluated for each assembly. Empty text contributes nothing. */
/** Static text or a provider evaluated at assembly and admission refresh. Empty text contributes nothing. */
readonly text: string | ((context: AssembleContext) => string)
/** Whether to interpolate prompt variables. Defaults to true; false preserves literal text. */
readonly interpolate?: boolean
@@ -372,6 +372,15 @@ function interpolate(
return result + text.slice(last)
}
/** Resolve one registered runtime fact without assembling sections or tools. */
function resolveContext(entry: PromptContext, context: AssembleContext): AssembledContext {
return {
name: entry.name,
text: typeof entry.text === 'function' ? entry.text(context) : entry.text,
...entry.interpolate !== undefined ? { interpolate: entry.interpolate } : {},
}
}
/** One tool-schema provider stored in a prompt layer. */
type ToolProvider = (context: AssembleContext) => ToolProviderResult
@@ -556,6 +565,38 @@ export class SystemPrompt extends Service {
)
}
/**
* Refresh accepted registered runtime facts for request admission. Contexts
* added only by the assembly waterfall retain their accepted values. Current
* suppression removes optional contexts, and missing required registrations
* are restored in registry order. Sections, tools, and interpolation variables
* retain the accepted assembly; their providers and waterfall do not rerun.
* @param assembly - accepted assembly for this scope and step.
* @param context - the same scope and current plugin-defined assembly fields.
* @returns the accepted assembly with current runtime-context provider text.
*/
refreshContext(assembly: PromptAssembly, context: AssembleContext = {}): PromptAssembly {
const providers = this.layers.merge(context.scope, layer => layer.contexts)
const suppressed = !this.layers.global.runtimeContextSuppressors.isEmpty()
|| this.layers.chainLayers(context.scope).some(layer => !layer.runtimeContextSuppressors.isEmpty())
const required = new Map([...providers].filter(([, entry]) => entry.required === true))
const contexts: AssembledContext[] = []
for (const accepted of assembly.contexts) {
const provider = providers.get(accepted.name)
required.delete(accepted.name)
if (suppressed && provider?.required !== true) continue
contexts.push(provider === undefined ? accepted : resolveContext(provider, context))
}
for (const provider of required.values()) {
const index = contexts.findIndex((entry) => {
const next = providers.get(entry.name)
return next !== undefined && next.order > provider.order
})
contexts.splice(index < 0 ? contexts.length : index, 0, resolveContext(provider, context))
}
return { ...assembly, contexts }
}
/**
* Assemble global and scoped providers, detach tool parameters, apply
* canonical ordering, then run the assembly waterfall. Scoped sections and
@@ -625,11 +666,7 @@ export class SystemPrompt extends Service {
contexts: [...contextByName.values()]
.filter(entry => !runtimeContextSuppressed || entry.required === true)
.sort((a, b) => a.order - b.order)
.map(entry => ({
name: entry.name,
text: typeof entry.text === 'function' ? entry.text(context) : entry.text,
...entry.interpolate !== undefined ? { interpolate: entry.interpolate } : {},
})),
.map(entry => resolveContext(entry, context)),
tools: orderTools(collected, this.toolOrder, knownNames),
variables,
}
@@ -205,6 +205,27 @@ describe('scoped tool providers and toolOrder × restriction', () => {
})
describe('scoped assemble dispatch', () => {
it('refreshes through the same scoped providers and suppression as assembly', async () => {
const ctx = await mount()
try {
const scope = await mintScope(ctx, 'refresh')
let current = 'before'
ctx.systemPrompt.context({ name: 'fact', order: 0, text: 'global' })
scope.ctx.systemPrompt.context({ name: 'fact', order: 0, required: true, text: () => current })
scope.ctx.systemPrompt.context({ name: 'optional', order: 10, text: 'optional' })
const context = { scope: scopeKeyOf(scope) }
const accepted = await ctx.systemPrompt.assemble(context)
current = 'after'
const restore = scope.ctx.systemPrompt.suppressRuntimeContext()
expect(ctx.systemPrompt.refreshContext(accepted, context).contexts).toEqual([{ name: 'fact', text: 'after' }])
restore()
expect(ctx.systemPrompt.refreshContext(await ctx.systemPrompt.assemble()).contexts).toEqual([{ name: 'fact', text: 'global' }])
} finally {
await ctx.fiber.dispose()
}
})
it('an agent.ctx assemble listener shapes only its own scope\'s assemblies', async () => {
const ctx = await mount()
const scope = await mintScope(ctx, 'child')
@@ -50,6 +50,76 @@ describe('SystemPrompt', () => {
await ctx.fiber.dispose()
}
})
it('refreshes registered context without reassembling accepted sections, schemas, variables, or expert additions', async () => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt)
let current = 'before'
let assemblies = 0
let tools = 0
let variables = 0
ctx.systemPrompt.context({ name: 'live', order: 0, text: () => current, interpolate: false })
ctx.systemPrompt.variable('fixed', () => { variables++; return current })
ctx.systemPrompt.tools(() => { tools++; return { schemas: [{ name: 'accepted_tool', description: current, parameters: {}, deferLoading: true }] } })
ctx.on('system-prompt/assemble', async (_assembly, _context, next) => {
assemblies++
const accepted = await next()
return { ...accepted, contexts: [...accepted.contexts, { name: 'expert', text: 'accepted only' }] }
})
const accepted = await ctx.systemPrompt.assemble()
current = 'after'
const refreshed = ctx.systemPrompt.refreshContext(accepted)
expect(refreshed.contexts).toEqual([
{ name: 'live', text: 'after', interpolate: false }, { name: 'expert', text: 'accepted only' },
])
expect(accepted.contexts[0]?.text).toBe('before')
expect(refreshed.sections).toBe(accepted.sections)
expect(refreshed.tools).toBe(accepted.tools)
expect(refreshed.tools[0]?.description).toBe('before')
expect(refreshed.variables).toBe(accepted.variables)
expect({ assemblies, tools, variables }).toEqual({ assemblies: 1, tools: 1, variables: 1 })
} finally {
await ctx.fiber.dispose()
}
})
it.each([false, true])('restores missing required contexts before a later registered context=%s', async (later) => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.context({ name: 'required', order: 0, text: 'fresh', required: true })
ctx.systemPrompt.context({ name: 'later', order: 10, text: 'last' })
const accepted = await ctx.systemPrompt.assemble()
accepted.contexts = later ? [{ name: 'expert', text: 'extra' }, accepted.contexts[1]!] : []
expect(ctx.systemPrompt.refreshContext(accepted).contexts).toEqual(later
? [{ name: 'expert', text: 'extra' }, { name: 'required', text: 'fresh' }, { name: 'later', text: 'last' }]
: [{ name: 'required', text: 'fresh' }])
} finally {
await ctx.fiber.dispose()
}
})
it('applies current suppression without evaluating optional providers or keeping expert additions', async () => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt)
let suppressed = false
ctx.systemPrompt.context({ name: 'required', order: 0, text: () => 'current', required: true })
ctx.systemPrompt.context({ name: 'optional', order: 10, text: () => {
if (suppressed) throw new Error('suppressed context was evaluated')
return 'optional'
} })
const accepted = await ctx.systemPrompt.assemble()
accepted.contexts.push({ name: 'expert', text: 'accepted optional' })
const restore = ctx.systemPrompt.suppressRuntimeContext()
suppressed = true
expect(ctx.systemPrompt.refreshContext(accepted).contexts).toEqual([{ name: 'required', text: 'current' }])
restore()
} finally {
await ctx.fiber.dispose()
}
})
it('preserves literal context in both the joined snapshot and attributed sections', async () => {
const ctx = new Context()
try {
@@ -36,6 +36,8 @@
"@deepseek-ai/dsh-system-prompt": "workspace:*"
},
"devDependencies": {
"@deepseek-ai/dsh-sandbox-policy": "workspace:*",
"@deepseek-ai/dsh-user-approval": "workspace:*",
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-agent": "workspace:*",
"@deepseek-ai/dsh-fs": "workspace:*",
@@ -0,0 +1,75 @@
/** Actual request admission follows directory and permission changes made after assembly. */
import { mkdtemp, mkdir, realpath, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { mountAgentLoopTestDependencies, mountAgentLoopTestHarness } from '@deepseek-ai/dsh-agent-loop-testkit'
import SandboxPolicy, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import { describe, expect, it, vi } from 'vitest'
import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
describe.each(['pre-step', 'prepareCall'] as const)('%s context changes', (phase) => {
it.each([false, true])('admits current directory and permissions with optional context=%s', async (optional) => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-directory-admission-')))
const selected = join(root, 'selected')
await mkdir(selected)
const ctx = new Context()
try {
await mountAgentLoopTestDependencies(ctx, {
workingDirectory: true, systemPrompt: { includeRuntimeContext: optional, includeHarnessIdentity: false },
})
await ctx.plugin(SandboxPolicy, { mode: 'read-only', workspaceRoot: root })
await ctx.plugin(ApprovalService, { policy: 'never' })
const adapter = new MockAdapter([textResponse('first reply'), textResponse('notice received')])
ctx.llm.registerAdapter(['mock'], adapter)
const harness = await mountAgentLoopTestHarness(ctx)
const agent = await harness.create(SessionId(`directory-admission-${phase}-${optional}`), { provider: 'mock', model: 'mock' }, { cwd: root })
let changed = false
const change = async (): Promise<void> => {
if (changed) return
changed = true
await ctx.workingDirectory.set(agent, selected)
setSandboxMode(agent.session, 'danger-full-access')
ctx.approval.setPolicy(agent, 'ask')
}
if (phase === 'pre-step') {
ctx.on('agent/pre-step', async (_payload, next) => { await change(); return next() })
} else {
const prepare = adapter.prepareCall.bind(adapter)
vi.spyOn(adapter, 'prepareCall').mockImplementation(async (provider, model, signal) => {
await change()
return prepare(provider, model, signal)
})
}
ctx.on('llm/stream', (request, next) => {
expect(request.messages).toEqual(Session.create(agent.id, agent.session.snapshotEvents(), agent.session.header).deriveMessages())
expect(Object.isFrozen(request.messages)).toBe(true)
expect(request.messages.every(message => Object.isFrozen(message))).toBe(true)
return next()
})
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Read the active project.' }], source: { kind: 'user' } }))
await agent.whenIdle()
const first = adapter.requests[0]
expect(first).toBeDefined()
const snapshots = first?.messages.filter(message => message.role === 'user' && message.source?.kind === 'runtime-context') ?? []
expect(snapshots).toHaveLength(1)
const body = snapshots.flatMap(message => message.content).map(block => block.type === 'text' ? block.text : '').join('')
expect(body).toContain(`Current working directory: ${JSON.stringify(selected)}.`)
expect(body).not.toContain(`Current working directory: ${JSON.stringify(root)}.`)
expect(body.includes('Current DSH file policy: danger-full-access')).toBe(optional)
expect(body.includes('Approval policy: ask.')).toBe(optional)
expect(body).not.toContain('Current DSH file policy: read-only')
expect(body).not.toContain('Approval prompts are disabled')
expect(ctx.workingDirectory.get(agent.session)).toBe(selected)
expect(agent.session.header.cwd).toBe(root)
expect(agent.session.snapshotEvents().filter(event => event.type === 'user/message' && event.data.source.kind === 'user')).toHaveLength(1)
expect(agent.session.snapshotEvents().findLast(event => event.type === 'turn/end')?.data.reason.kind).toBe('completed')
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
}
})
})
@@ -0,0 +1,36 @@
/** Loader fixture selecting a directory after initial prompt assembly. */
import assert from 'node:assert/strict'
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-working-directory'
/** Loader identity for the admission timing scene. */
export const name = 'working-directory-admission'
/** Directory owner used by the first pre-step transition. */
export const inject = ['workingDirectory', 'agents']
/**
* Select the scenario's existing subdirectory before its first request.
* @param ctx - fixture context owning the pre-step listener.
*/
export function apply(ctx: Context): void {
ctx.on('agent/pre-step', async ({ agent, turn, step, signal }, next) => {
if (turn === 1 && step === 1) await ctx.workingDirectory.set(agent, 'selected', signal)
return next()
})
let first = true
ctx.on('llm/stream', (request, next) => {
if (!first) return next()
first = false
const agent = request.sessionId === undefined ? undefined : ctx.agents.get(request.sessionId)
assert(agent !== undefined, 'admission request has no live Agent')
const current = `Current working directory: ${JSON.stringify(ctx.workingDirectory.get(agent.session))}.`
const old = `Current working directory: ${JSON.stringify(agent.session.header.cwd)}.`
const snapshots = request.messages.filter(message => message.role === 'user' && message.source?.kind === 'runtime-context')
assert.equal(snapshots.length, 1)
const text = snapshots.flatMap(message => message.content).flatMap(block => block.type === 'text' ? [block.text] : []).join('')
assert(text.includes(current), 'first admitted request omits the selected directory')
assert(!text.includes(old), 'first admitted request retains the previous directory')
return next()
})
}
+6
View File
@@ -11803,6 +11803,9 @@ importers:
'@deepseek-ai/dsh-llm':
specifier: workspace:*
version: link:../../llm/llm
'@deepseek-ai/dsh-sandbox-policy':
specifier: workspace:*
version: link:../../sandbox/sandbox-policy
'@deepseek-ai/dsh-session':
specifier: workspace:*
version: link:../../core/session
@@ -11818,6 +11821,9 @@ importers:
'@deepseek-ai/dsh-tools':
specifier: workspace:*
version: link:../../core/tools
'@deepseek-ai/dsh-user-approval':
specifier: workspace:*
version: link:../../interaction/user-approval
packages/settings/settings:
dependencies:
@@ -0,0 +1,49 @@
# Replay patch shared by the ordinary headless snapshot composition. The model
# script comes from the scenario's committed session JSONL.
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek-api-key'
disabled: true
- id: plugin-package-inventory-deepseek
disabled: true
- id: session-title-llm
disabled: true
- id: session-persistence-jsonl
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: !!js dshHomePath('sessions')
compression: none
- id: sandbox
name: '@deepseek-ai/dsh-sandbox-local'
config:
runnerCommand:
- bash
- -c
- while [ "$1" != "--" ]; do shift; done; shift; exec "$@"
- passthrough-runner
runnerFailureSignatures:
- 'passthrough-runner: profile rejected'
- insert:
- id: llm-replay
name: '@deepseek-ai/dsh-llm-replay'
config:
providers:
- id: deepseek-official
name: DeepSeek
models:
- id: deepseek-v4-flash
- id: deepseek-v4-pro
- id: deepseek-messages
name: DeepSeek Messages
models:
- id: deepseek-v4-flash
- id: deepseek-v4-pro
- insert:
- id: working-directory-admission
name: '../../../packages/test-support/session-snapshot/tests/fixtures/working-directory-admission.ts'
@@ -0,0 +1,3 @@
- insert:
- id: working-directory-admission
name: '../../../packages/test-support/session-snapshot/tests/fixtures/working-directory-admission.ts'
@@ -0,0 +1,26 @@
{"type":"session","version":4,"id":"{{session:1}}","createdAt":0,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0}
{"type":"permission/preset","data":{"preset":"danger-full-access"}}
{"type":"sandbox/mode","data":{"mode":"danger-full-access"}}
{"type":"approval/policy","data":{"policy":"never"}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Read the current directory with working_directory, then reply with exactly WORKING_DIRECTORY_ADMISSION_OK."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}}
{"type":"turn/start","data":{"turn":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"working-directory/change","data":{"cwd":"{{cwd}}/selected"}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"inserted":[{"content":[{"type":"text","text":"The working directory changed from \"{{cwd}}\" to \"{{cwd}}/selected\"."}],"source":{"kind":"working-directory"},"role":"user","id":"{{message:2}}"}]}}
{"type":"step/start","data":{"turn":1,"step":1}}
{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:3}}"}},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Read the current directory with working_directory, then reply with exactly WORKING_DIRECTORY_ADMISSION_OK."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}/selected\".\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}/selected\"."},{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Read the current directory with","messageSeqs":[10],"source":{"kind":"fallback"}}}
{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"directory-1","name":"working_directory","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1791190471189,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1791190471189,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"directory-1","name":"working_directory","arguments":"{}"}}},{"type":"chunk","time":1791190471189,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1791190471189,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"}
{"type":"tool/call","data":{"turn":1,"step":1,"callId":"directory-1","name":"working_directory","arguments":"{}"}}
{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"directory-1"},"toolCallId":"directory-1","content":[{"type":"text","text":"{{cwd}}/selected"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[16],"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":1}}
{"type":"agent/inbox/spliced","data":{"target":"next-step","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","data":{"turn":1,"step":2}}
{"type":"user/message","data":{"content":[{"type":"text","text":"The working directory changed from \"{{cwd}}\" to \"{{cwd}}/selected\"."}],"source":{"kind":"working-directory"},"role":"user","id":"{{message:2}}"},"surfaceOp":"append"}
{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"WORKING_DIRECTORY_ADMISSION_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":10,"outputTokens":5},"stream":[{"type":"chunk","time":1791190471212,"chunk":{"type":"block-start","index":0,"blockType":"text"}},{"type":"chunk","time":1791190471212,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKING_DIRECTORY_ADMISSION_OK"}}},{"type":"chunk","time":1791190471212,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}},{"type":"chunk","time":1791190471212,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"}
{"type":"step/end","data":{"turn":1,"step":2}}
{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}}
@@ -0,0 +1,11 @@
version: 1
scenario: working-directory-admission
profile: headless
composition: working-directory-admission
recording: authored
platform: posix
header:
class: working-directory-admission
pin: true
systemPromptSource: text-turn
toolSchemasSource: text-turn
@@ -0,0 +1 @@
The admitted directory remains unchanged.