refactor(agent): return request retry action

This commit is contained in:
_Kerman
2026-07-27 21:17:49 +08:00
parent c6073f07c2
commit ed67ad53d2
83 changed files with 277 additions and 309 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md
README.md: 25fa42ce09a7ad6463e9c52c08bf718d1f55adc1
README.zh.md: 0366ce01f8d093dcc142c0ae25cfdfd7b6effbc2
README.md: ab6df4d49f05ff00b16a210830e7fd21504a0a9f
README.zh.md: 5adb2a11ba3de2bb6fc7ff57b9d6dd07ac7f650e

View File

@@ -64,7 +64,7 @@ Every provider call that reaches a successful finish appends exactly one `assist
After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate any adapter-owned reasoning effort and materialize its configured default under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The effective config is logged before dispatch, so a listener can change effort between steps without hidden request drift. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance restores the last effort only when its initial provider/model route exactly matches the logged route; a route change discards that opaque model-owned ID and resolves the new model independently.
Plugin failure ends the current turn, not the loop. A model-request failure first closes its step and enters `agent/request-error` with the exact live error, normalized provider facts, and the turn signal. A handling listener calls `agent.retry()`; the loop coalesces repeated calls, closes the failed turn with its error, and opens one numbered retry turn without an intervening idle notification. An unhandled failure is terminal. Other failures close directly. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract.
Plugin failure ends the current turn, not the loop. A model-request failure first closes its step and enters `agent/request-error` with the exact live error, normalized provider facts, and the turn signal. A handling listener returns `{ kind: 'retry' }`; the loop closes the failed turn with its error and opens one numbered retry turn without an intervening idle notification. An unhandled failure is terminal. Other failures close directly. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract.
Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, and retains their finalized result context without distinguishing the cancellation cause.
@@ -73,7 +73,7 @@ Within a step, exclusive calls form barriers; parallel-safe calls use a bounded
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
- Hooks and policy: the relevant `agent/*` checkpoints plus the guarded `tools/pre-execute``tools/execute``tools/post-execute` → definition-owned `finalizeContent``tools/result` pipeline; exact event signatures and modes live in the [generated event catalog](../../../docs/cordis-catalog/events.md)
- Compaction: pressure on `agent/step`; canonical overflow repair on `agent/request-error`
- Transient model recovery: `dsh-llm-retry` records and waits its finite backoff on `agent/request-error`, then calls `agent.retry()`
- Transient model recovery: `dsh-llm-retry` records and waits its finite backoff on `agent/request-error`, then returns a retry action
- Sandbox, permission, plan mode: `tools/pre-execute` for extensible deny/ask, `tools.guard()` for monotonic owner policy, `tools/post-execute` for result decisions, and `tools/result` for final observation
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while generic [`ctx.tasks`](../../tasks/tasks/) plus [`dsh-tool-subagent`](../../subagent/tool-subagent/) own background collection.
- Persistence: eager write-behind from `session/event`; `session/flush` is an explicit observation barrier

View File

@@ -64,7 +64,7 @@ interface Config {
`agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`在活跃轮次信号的控制下校验由适配器持有的推理reasoning强度并填入其配置默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR热模块替换不会把某个适配器的能力解析结果与另一适配器的请求混用。生效配置会在分派前写入日志因此监听器可以在步骤之间更改推理强度而不会产生未记录的请求变化。没有已注册适配器的路由会保留原定配置使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例仅在初始提供方/模型路由与日志路由完全一致时恢复上次的推理强度;路由变化会丢弃由前一模型持有的不透明 ID并单独解析新模型。
插件失败会结束当前轮次,而不是结束循环。模型请求失败会先关闭其步骤,再带着确切的实时错误、规范化的提供方事实和轮次信号进入 `agent/request-error`。处理失败的监听器调用 `agent.retry()`;循环会合并重复调用,用其错误关闭失败轮次并在不插入空闲通知的情况下开启一个编号重试轮次。未被处理的失败是终态。其他失败直接关闭轮次。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end``user``parent` 记录 `aborted`dispose 则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call``ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式不改变对取消后已定案结果上下文的处理。Dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。
插件失败会结束当前轮次,而不是结束循环。模型请求失败会先关闭其步骤,再带着确切的实时错误、规范化的提供方事实和轮次信号进入 `agent/request-error`。处理失败的监听器返回 `{ kind: 'retry' }`循环用其错误关闭失败轮次并在不插入空闲通知的情况下开启一个编号重试轮次。未被处理的失败是终态。其他失败直接关闭轮次。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end``user``parent` 记录 `aborted`dispose 则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call``ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式不改变对取消后已定案结果上下文的处理。Dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。
在步骤内独占调用形成屏障并行安全调用使用有界滚动池并在启动前重新分类。只有分发主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用drain 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。
@@ -73,7 +73,7 @@ interface Config {
超出「调用模型、运行工具、重复」的所有内容,都属于监听事件分类体系的插件:
- 钩子与策略:相关的 `agent/*` 检查点,加上受守卫保护的 `tools/pre-execute``tools/execute``tools/post-execute` → 定义拥有的 `finalizeContent``tools/result` 流水线;确切事件签名与 mode 位于生成的[事件目录](../../../docs/cordis-catalog/events.md)
- 压缩compaction`agent/step` 上观测压力;在 `agent/request-error` 上修复规范溢出
- 瞬时模型恢复:`dsh-llm-retry``agent/request-error` 上记录并等待其有限退避,然后调用 `agent.retry()`
- 瞬时模型恢复:`dsh-llm-retry``agent/request-error` 上记录并等待其有限退避,然后返回重试动作
- 沙箱、权限、计划模式:使用 `tools/pre-execute` 提供可扩展的拒绝/询问,使用 `tools.guard()` 提供单调拥有方策略,使用 `tools/post-execute` 处理结果决定,并使用 `tools/result` 进行最终观测
- subagent在循环外部实现为 `ctx.subagents` 提供方;进程内提供方使用 `ctx.agents.create()` 和拥有的 `AgentHandle` 进行 teardown而通用的 [`ctx.tasks`](../../tasks/tasks/) 与 [`dsh-tool-subagent`](../../subagent/tool-subagent/) 负责后台收集。
- 持久化:从 `session/event` 立即后写;`session/flush` 是显式观测屏障

View File

@@ -23,6 +23,7 @@ import type {
SettleReason,
PromptDecision,
RequestError,
RequestErrorAction,
SendOptions,
} from '@deepseek-ai/dsh-agent'
import {
@@ -58,8 +59,6 @@ export class ReactLoopAgent implements Agent {
acceptsNextStep = false
/** Abort owner for the current admission or turn. */
private abort: AbortController | undefined
/** Coalesced retry capability scoped to the active request-error waterfall. */
private retryWindow: { requested: boolean } | undefined
/** Resolves when the current admission and turn exit. */
done: Promise<void> = Promise.resolve()
@@ -180,25 +179,10 @@ export class ReactLoopAgent implements Agent {
this.outbox.length = 0
if (discarded.length > 0) emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', discarded)
}
if (this.retryWindow !== undefined) this.retryWindow.requested = false
const reason = Object.freeze({ kind: cause.kind })
this.abort?.abort(reason)
}
/**
* Re-open a turn on the current session log without a new prompt — the
* recovery verb. A request-error listener schedules the retry that follows
* its failed turn; an idle caller starts one immediately.
*/
retry(): void {
if (this.abort !== undefined) {
if (this.retryWindow === undefined) throw new Error(`agent "${this.id}" cannot retry while busy`)
if (!this.abort.signal.aborted) this.retryWindow.requested = true
return
}
this.done = this.loopCtx.agents.withInitiator(this, () => this.run({ kind: 'retry' }))
}
/** Resolve at idle quiescence: no run driving and no waking prompt waiting. */
async whenIdle(): Promise<void> {
// `done` is replaced per activity, so re-reading it follows chained turns.
@@ -281,7 +265,7 @@ export class ReactLoopAgent implements Agent {
}
}
// cancel() aborts but never clears the slot, and kick()/run()/retry()
// cancel() aborts but never clears the slot, and kick()/run()
// all refuse to install a new owner while one exists, so the admission
// still owns the slot here and releasing it unconditionally is exact.
this.abort = undefined
@@ -321,16 +305,12 @@ export class ReactLoopAgent implements Agent {
inheritedOutboxLength = 0,
): Promise<void> {
// Both entries hold the invariant: kick() clears the admission slot before
// awaiting run(), and retry() returns early whenever a slot owner exists.
// awaiting run(), and a retry is entered only after the prior run clears it.
/* v8 ignore next -- unreachable guard: every caller clears or checks the abort slot first */
if (this.abort !== undefined) throw new Error(`agent "${this.id}" is already running`)
const controller = new AbortController()
this.abort = controller
this.acceptsNextStep = true
if (!this.busy) {
this.busy = true
emitAgentEvent(this.loopCtx, this, 'agent/status', 'running')
}
const signal = controller.signal
const turn = this.lastTurn + 1
let step = 0
@@ -378,30 +358,18 @@ export class ReactLoopAgent implements Agent {
this.stepOpen = false
this.session.append('step/end', { turn, step })
if (!signal.aborted) {
const retryWindow = { requested: false }
this.retryWindow = retryWindow
let recoveryCompleted = false
try {
await this.loopCtx.waterfall(
const action = await this.loopCtx.waterfall(
agentCarrier(this), 'agent/request-error', this, turn, step, outcome.error,
outcome.failure, signal,
() => Promise.resolve(),
() => Promise.resolve<RequestErrorAction>(undefined),
)
recoveryCompleted = true
retry = action?.kind === 'retry' && !signal.aborted
} catch (recoveryError: unknown) {
this.loopCtx.logger.warn(
`agent "${this.id}": request recovery failed at turn ${turn}, step ${step}: ${errorChain(recoveryError)}`,
)
} finally {
// Nothing else writes the window while the waterfall runs:
// cancel() only flips `requested` and a second run cannot
// start, so unconditional retirement is exact.
this.retryWindow = undefined
}
// A requested retry implies the signal is still live: cancel()
// retires the window before it aborts, and retry() refuses to
// arm a window whose signal already aborted.
retry = recoveryCompleted && retryWindow.requested
}
const settlement = this.settle(turn, step, outcome.error, signal, outcome.failure)
reason = settlement.reason
@@ -447,7 +415,6 @@ export class ReactLoopAgent implements Agent {
this.loopCtx.logger.warn(`agent "${this.id}": closing turn ${turn} failed: ${errorChain(error)}`)
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
}
this.retryWindow = undefined
// cancel() aborts but never clears the slot, and no second run can
// install a controller while this one is still unwinding, so the slot
// is still this run's controller here.
@@ -727,7 +694,6 @@ export class ReactLoopAgent implements Agent {
/** Continue with a waking prompt, or publish the idle status. */
private continueOrIdle(): void {
if (this.abort !== undefined) return
if (this.queued.some(item => item.wakeup)) {
this.kick()
} else {

View File

@@ -255,21 +255,8 @@ describe('structured tool error propagation (the runtime-validation Agent Note,
})
})
describe('retry() edges', () => {
it('throws while a turn runs with no request-error window open', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('retry-busy'), { provider: 'mock', model: 'mock' })
send(agent, 'go')
// Wait until the hung request is in flight (the run owns this.abort).
await new Promise(r => setTimeout(r, 30))
expect(() => { agent.retry() }).toThrow('cannot retry while busy')
agent.cancel({ kind: 'user' })
await agent.whenIdle()
})
it('ignores a retry request arriving after the recovery window was aborted', async () => {
describe('request-error action edges', () => {
it('ignores a retry action returned after the turn was aborted', async () => {
const { LlmError } = await import('@deepseek-ai/dsh-llm')
const adapter = new MockAdapter([
() => { throw new LlmError('busy', 'RATE_LIMIT') },
@@ -278,10 +265,8 @@ describe('retry() edges', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('retry-after-cancel'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request-error', async (subject) => {
// Cancellation lands first; the window survives structurally but its
// signal is aborted, so the request must not arm a retry turn.
subject.cancel({ kind: 'user' })
subject.retry()
return { kind: 'retry' }
})
send(agent, 'go')
@@ -302,11 +287,9 @@ describe('retry() edges', () => {
const agent = ctx.agentLoop.create(SessionId('retry-raced'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request-error', async (subject, _turn, _step, _error, _failure, signal, next) => {
await next()
// Recovery completes and requested the retry, but the turn signal
// aborts before the loop reads the window.
subject.retry()
subject.cancel({ kind: 'user' })
expect(signal.aborted).toBe(true)
return { kind: 'retry' }
})
send(agent, 'go')
@@ -348,29 +331,6 @@ describe('stream failure edges', () => {
})
describe('post-turn continuation edges', () => {
it('an agent/settled listener that starts a retry preempts continueOrIdle', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('settled-preempt'), { provider: 'mock', model: 'mock' })
let injected = false
ctx.on('agent/settled', (subject) => {
if (subject !== agent || injected) return
expect(subject.status).toBe('running')
injected = true
// retry() installs the next run synchronously, so the following
// continueOrIdle() sees its abort owner and yields to it.
subject.retry()
})
send(agent, 'go')
await agent.whenIdle()
expect(adapter.requests).toHaveLength(2)
const starts = agent.session.events.filter(e => e.type === 'turn/start')
expect(starts).toHaveLength(2)
// The busy interval never broke between the turns: one running->idle cycle.
})
it('whenIdle resolves for a waiter whose awaited run fails', async () => {
const adapter = new MockAdapter([textResponse('unused')])
const ctx = await harness(adapter)
@@ -472,8 +432,8 @@ describe('turn close failure containment', () => {
})
})
describe('recovery without a retry request', () => {
it('a completed recovery that never calls retry() leaves the failed turn terminal', async () => {
describe('recovery without a retry action', () => {
it('a completed recovery that returns no action leaves the failed turn terminal', async () => {
const { LlmError } = await import('@deepseek-ai/dsh-llm')
const adapter = new MockAdapter([
() => { throw new LlmError('down', 'SERVICE_UNAVAILABLE') },

View File

@@ -286,7 +286,7 @@ describe('agent/prompt-submit', () => {
const agent = ctx.agentLoop.create(SessionId('blocked-admission-outbox'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<PromptDecision>()
ctx.on('agent/prompt-submit', async () => {
const disposeBlock = ctx.on('agent/prompt-submit', async () => {
entered.resolve(undefined)
return decision.promise
})
@@ -307,13 +307,17 @@ describe('agent/prompt-submit', () => {
expect(events(agent)).toEqual([])
expect(adapter.requests).toEqual([])
const retryIdle = waitForIdle(ctx, agent)
agent.retry()
await retryIdle
disposeBlock()
send(agent, 'resume')
await waitForIdle(ctx, agent)
const staged = events(agent).filter(event =>
event.type === 'user/message' || event.type === 'steering/message')
expect(staged.map(event => event.type)).toEqual(['user/message', 'steering/message'])
expect(staged.map(event => event.type)).toEqual([
'user/message',
'steering/message',
'user/message',
])
expect(JSON.stringify(adapter.requests[0]?.messages)).not.toContain('blocked prompt')
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged context')
expect(JSON.stringify(adapter.requests[0]?.messages)).toContain('staged steering')
@@ -408,7 +412,7 @@ describe('agent/prompt-submit', () => {
})
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<PromptDecision>()
ctx.on('agent/prompt-submit', async () => {
const disposeBlock = ctx.on('agent/prompt-submit', async () => {
entered.resolve(undefined)
return decision.promise
})
@@ -425,9 +429,9 @@ describe('agent/prompt-submit', () => {
expect(events(agent)).toEqual([])
expect(warned).toHaveBeenCalledWith(expect.stringContaining('append unavailable'))
const idle = waitForIdle(ctx, agent)
agent.retry()
await idle
disposeBlock()
send(agent, 'resume')
await waitForIdle(ctx, agent)
expect(events(agent).some(event => event.type === 'user/message'
&& JSON.stringify(event.data.content).includes('retained context'))).toBe(true)

View File

@@ -317,7 +317,7 @@ describe('agent loop', () => {
expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('second idle steer')
})
it('keeps steering staged after a failed step until retry', async () => {
it('keeps steering staged after a failed step until the next admitted turn', async () => {
const adapter = new MockAdapter([textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('failed-steering'), { provider: 'mock', model: 'mock' })
@@ -336,9 +336,8 @@ describe('agent loop', () => {
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(1)
expect(agent.session.events.some(event => event.type === 'steering/message')).toBe(false)
const idle = waitForIdle(ctx, agent)
agent.retry()
await idle
send(agent, 'resume')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'turn/start')).toHaveLength(2)

View File

@@ -47,7 +47,7 @@ describe('agent/request-error', () => {
expect(adapter.requests).toHaveLength(0)
})
it('lets each failed request schedule a retry before its turn closes', async () => {
it('lets each failed request return a retry action before its turn closes', async () => {
const adapter = new MockAdapter([
fail('busy', 'RATE_LIMIT'),
fail('unavailable', 'SERVICE_UNAVAILABLE'),
@@ -71,8 +71,7 @@ describe('agent/request-error', () => {
data: { turn, step },
})
seen.push({ turn, step, failure })
subject.retry()
subject.retry()
return { kind: 'retry' }
})
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
@@ -104,13 +103,13 @@ describe('agent/request-error', () => {
expect(settledTurns).toEqual([3])
})
it('lets cancellation win over a retry request', async () => {
it('lets cancellation win over a retry action', async () => {
const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('request-error-cancel'), { provider: 'mock', model: 'mock' })
ctx.on('agent/request-error', async (subject) => {
subject.retry()
subject.cancel({ kind: 'user' })
return { kind: 'retry' }
})
agent.followup({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })
@@ -124,15 +123,14 @@ describe('agent/request-error', () => {
})
})
it('does not honor a retry requested by a failing recovery listener', async () => {
it('does not retry when the recovery listener fails before returning its action', async () => {
const adapter = new MockAdapter([fail('busy', 'RATE_LIMIT'), textResponse('unused')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('request-error-recovery-failed'), {
provider: 'mock',
model: 'mock',
})
ctx.on('agent/request-error', async (subject) => {
subject.retry()
ctx.on('agent/request-error', async () => {
throw new Error('recovery failed')
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/core/agent/README.md
README.md: b9e7914ac2238b677e5c799db58ca54088140352
README.zh.md: 1a88f0d2e4c4619defae95a4cbe49caec7768599
README.md: bb48fd8b227484a43af8f9f9e55f8adc8b990ce6
README.zh.md: 531db9905b3c091a5c129d31e944e4095e66123b

View File

@@ -48,7 +48,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
The lifecycle edges have two important local caveats. `agent/created` runs after scoped setup and after both session and agent registry entries exist. Setup is trusted composition-only code; the immediately following non-vetoing `agent/session-start` notification is the first supported startup injection point. `agent/disposed` always means the exact agent has left the registry. AgentLoop emits it after its driver is quiescent, while ordered teardown may still be detaching the session and unwinding the scope; custom agents registered directly own any stronger driver-ordering contract themselves.
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener calls `agent.retry()` and returns without `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
Most interception points are cooperative waterfalls. Turn-scoped asynchronous seams receive one explicit `AbortSignal`, with `signal` immediately before a waterfall's final `next`; listeners may cooperate but must not retain it as authority over another turn. `agent/step` is the serial checkpoint before request derivation, while `agent/request-error` is the failed-model-request recovery waterfall: it receives the exact error, normalized failure facts, and signal after the failed step closes. A listener returns `{ kind: 'retry' }` without calling `next()` when it owns recovery; the loop closes the failed turn and opens one numbered retry turn. `agent/turn-stopping` runs before an otherwise completed turn closes. Ordinary queued prompts remain intact. Effective broad cancellation first emits the observe-only `agent/cancel-requested` with its resolved typed cause, then clears queues and aborts; notification failures are contained and cannot veto the stop. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns signal lifetime; the [agent-scope runtime-design Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way) owns scoped dispatch and terminal settlement.
`PromptDecision.additionalContexts` is an array so every context keeps its own source. Allowed prompt content and every additional context become separate model-facing `user/message` events before the turn runs. A listener that wraps a downstream allow preserves its `content` and `additionalContexts` unless it intentionally replaces either field; the returned allow is authoritative.

View File

@@ -48,7 +48,7 @@ Agent *创建* 由实现 `AgentFactory` 的插件(`dsh-agent-loop`)提供,
生命周期边有两个重要的本地注意事项。`agent/created` 在作用域 setup 之后、会话与 agent 注册表条目都存在之后运行。Setup 是受信任、仅用于组合的代码;紧随其后且不可 veto 的 `agent/session-start` 通知是第一个受支持的启动注入点。`agent/disposed` 始终表示确切 agent 已离开注册表。AgentLoop 在其驱动器静默后发出该事件,而有序 teardown 此时可能仍在分离会话并撤销作用域;直接注册的自定义 agent 自行拥有任何更强的驱动器顺序契约。
大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall失败步骤关闭后它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器调用 `agent.retry()` 且不调用 `next()` 就返回;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
大多数拦截点都是协作式 waterfall。轮次作用域的异步 seam 接收一个显式 `AbortSignal`,其中 `signal` 紧邻 waterfall 最终的 `next`;监听器可以配合,但不得将它保留为控制另一轮次的权限。`agent/step` 是派生请求前的串行检查点,而 `agent/request-error` 是失败模型请求的恢复 waterfall失败步骤关闭后它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器返回 `{ kind: 'retry' }` 且不调用 `next()`;循环会关闭失败轮次,并打开一个编号重试轮次。`agent/turn-stopping` 在本可完成的轮次关闭前运行。普通排队提示词保持原样。有效的广义取消会先发出只观测的 `agent/cancel-requested` 及其解析后的类型化原因,再清空队列并中止;通知失败会被收容,不能 veto 停止。信号生命周期由[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)拥有;作用域分发与终止结算由 [agent 作用域 runtime 设计 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way)拥有。
`PromptDecision.additionalContexts` 是数组,因此每个上下文都保留自己的来源。获准的提示词内容与每个附加上下文都会在轮次运行前成为各自独立、面向模型的 `user/message` 事件。包装下游允许决策的监听器会保留其 `content``additionalContexts`,除非有意替换任一字段;返回的允许决策是权威来源。

View File

@@ -116,6 +116,9 @@ export type PromptDecision =
/** Model-request failure with an optional machine-routable provider code. */
export type RequestError = Error & { code?: string }
/** Action returned by a listener that owns model-request recovery. */
export type RequestErrorAction = { kind: 'retry' } | undefined
/**
* Why a turn ended, reported live on `agent/settled` right after the turn's
* durable `turn/end`. `error` carries the thrown value verbatim for observers;
@@ -226,15 +229,6 @@ export interface Agent {
* @returns the accepted message's {@link AgentMessageId}.
*/
inject(input: UserMessageData): AgentMessageId
/**
* Re-open a turn on the current session log without a new prompt — the
* explicit resummon verb. During `agent/request-error`, this schedules one
* retry turn after the failed turn closes; while idle, it starts one
* immediately. Repeated calls before the scheduled retry coalesce.
* @throws while other agent work is running.
*/
retry(): void
}
declare module 'cordis' {
@@ -368,9 +362,9 @@ declare module 'cordis' {
'agent/request'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, signal: AbortSignal, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
/**
* Handle a model-request failure after its failed step has closed but
* before the failed turn closes. A listener calls {@link Agent.retry} to
* schedule one retry turn, returns without `next()` when it owns the error,
* or calls `next()` to delegate. The default leaves the failure terminal.
* before the failed turn closes. A listener returns `{ kind: 'retry' }`
* without calling `next()` when it owns the error, or calls `next()` to
* delegate. The default `undefined` leaves the failure terminal.
* @param agent - the agent whose request failed.
* @param turn - the open turn number.
* @param step - the failed step number.
@@ -380,7 +374,7 @@ declare module 'cordis' {
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'agent/request-error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, signal: AbortSignal, next: () => Promise<void>): Promise<void>
'agent/request-error'(this: Scoped<Agent>, agent: Agent, turn: number, step: number, error: RequestError, failure: LlmFailure, signal: AbortSignal, next: () => Promise<RequestErrorAction>): Promise<RequestErrorAction>
/**
* The turn is about to close: the model owes no response (no live tool
* calls, no fresh steering). Awaited before the boundary commits — a

View File

@@ -29,7 +29,6 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
steer: () => AgentMessageId('stub'),
inject: () => AgentMessageId('stub'),
cancel() {},
retry() {},
whenIdle() { return Promise.resolve() },
}
return Object.assign(agent, overrides)

View File

@@ -56,7 +56,7 @@ describe('scoped-dispatch invariants', () => {
new Error('request'),
{ message: 'request', code: 'UNKNOWN' },
signal,
() => Promise.resolve(),
() => Promise.resolve(undefined),
],
'agent/turn-stopping': [agent, 1, signal],
'agent/settled': [agent, 1, { kind: 'completed' }],