refactor(agent): clarify turn lifecycle event names

This commit is contained in:
_Kerman
2026-07-27 17:38:42 +08:00
parent 7d5cc498d3
commit 52174e32cb
67 changed files with 180 additions and 176 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: 0edb9bf3fb73fd1d1e7c5000770eca87d4f58fcd
README.zh.md: 5d0ebc605e6c5ce29786b7113adaab3d5671b92e
README.md: bdf1367446bb9345d56f0a44e65e43467dd82144
README.zh.md: 3540499bfc64fc465853bc26b9a9c7f3cd1df15e

View File

@@ -128,4 +128,4 @@ Append-only; each synthetic result follows the reusable request prefix and does
- **Classification is unary** — calls whose safety depends on comparing siblings or resources must remain exclusive ([rationale](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)).
- **Config labels are fresh by default** — omitting `sessionId` creates a fresh `${id}-session-<uuid>` on every startup; exact resume-or-create behavior requires an explicit stable `sessionId`, while `resumeSessionId` requires existing persisted history.
- **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options.
- **No built-in turn budget** — tool calls or steering continue the current turn; a policy that bounds runaway turns must cancel from an existing lifecycle seam such as `agent/stopping`.
- **No built-in turn budget** — tool calls or steering continue the current turn; a policy that bounds runaway turns must cancel from an existing lifecycle seam such as `agent/turn-stopping`.

View File

@@ -128,4 +128,4 @@ interface Config {
- **分类是一元的**:安全性取决于比较同级调用或资源的调用必须保持独占(参见[设计原理](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md))。
- **配置 label 默认每次新建**:省略 `sessionId` 会在每次启动时创建全新的 `${id}-session-<uuid>`;确切的恢复或创建行为要求显式提供稳定的 `sessionId`,而 `resumeSessionId` 要求已有持久化历史。
- **配置 agent 没有逐 agent persona 字段或 setup 钩子**:它们使用部署 persona只有编程式 `ctx.agents.create()` / `resume()` 工厂选项支持带作用域的 persona工具组合。
- **没有内置轮次预算**:工具调用或 steering 会让当前轮次继续;限制失控轮次的策略必须从既有生命周期 seam`agent/stopping`)执行取消。
- **没有内置轮次预算**:工具调用或 steering 会让当前轮次继续;限制失控轮次的策略必须从既有生命周期 seam`agent/turn-stopping`)执行取消。

View File

@@ -19,7 +19,7 @@ import type {
AgentInterruptReason,
AgentOptions,
AgentStatus,
IdleReason,
SettleReason,
PromptDecision,
RequestError,
SendOptions,
@@ -288,7 +288,7 @@ export class ReactLoopAgent implements Agent {
let step = 0
let opened = false
let reason: TurnEndReason = { kind: 'completed' }
let idle: IdleReason = { kind: 'completed' }
let settleReason: SettleReason = { kind: 'completed' }
let retry = false
const cancelRetry = (): void => { retry = false }
signal.addEventListener('abort', cancelRetry, { once: true })
@@ -316,7 +316,7 @@ export class ReactLoopAgent implements Agent {
if (outcome.maxTokens) reason = { kind: 'max-tokens' }
// A concluding tool result is terminal: steering already in the
// log waits for the next turn's request instead of reopening this
// one, and the agent/stopping drain below is skipped for the same
// one, and the agent/turn-stopping drain below is skipped for the same
// reason.
if (outcome.concluded) break steps
if (outcome.continueTurn || this.outbox.some(item => 'id' in item)) continue
@@ -354,14 +354,14 @@ export class ReactLoopAgent implements Agent {
}
const settlement = this.settle(turn, step, outcome.error, signal, outcome.failure)
reason = settlement.reason
idle = settlement.idle
settleReason = settlement.settleReason
break steps
}
/* v8 ignore next 2 -- closed-union exhaustiveness guard */
default:
assertNever(outcome)
}
await this.loopCtx.serial(agentCarrier(this), 'agent/stopping', this, turn, signal)
await this.loopCtx.serial(agentCarrier(this), 'agent/turn-stopping', this, turn, signal)
signal.throwIfAborted()
if (!this.drainOutbox(turn)) break
}
@@ -379,7 +379,7 @@ export class ReactLoopAgent implements Agent {
this.loopCtx.logger.warn(`agent "${this.id}": closing step ${turn}/${step} failed: ${errorChain(closeError)}`)
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, closeError)
}
({ reason, idle } = this.settle(turn, step, caught, signal))
({ reason, settleReason } = this.settle(turn, step, caught, signal))
} finally {
// Every step-close happens before this point on both success and
// failure paths (step(), the request-failed branch, the catch), so the
@@ -406,10 +406,10 @@ export class ReactLoopAgent implements Agent {
if (retry) {
await this.run({ kind: 'retry' })
} else {
// agent/idle names only committed turns: a run aborted or rejected
// agent/settled names only committed turns: a run aborted or rejected
// before turn/start has no durable turn/end for consumers to settle
// against, so it exits without the notification.
if (opened) emitAgentEvent(this.loopCtx, this, 'agent/idle', turn, idle)
if (opened) emitAgentEvent(this.loopCtx, this, 'agent/settled', turn, settleReason)
this.continueOrIdle()
}
}
@@ -615,7 +615,7 @@ export class ReactLoopAgent implements Agent {
/**
* The single settlement funnel: classify one turn failure (interruption
* beats error) into the durable turn/end reason and the live idle report.
* beats error) into the durable turn/end reason and live settlement report.
*/
private settle(
turn: number,
@@ -623,13 +623,16 @@ export class ReactLoopAgent implements Agent {
error: unknown,
signal: AbortSignal,
failure?: LlmFailure,
): { reason: TurnEndReason; idle: IdleReason } {
): { reason: TurnEndReason; settleReason: SettleReason } {
if (signal.aborted) {
// Slot invariant, stated rather than re-validated: the turn controller
// is machine-private and cancel() is its only aborter, always with one
// frozen canonical cause as the reason.
const interrupt = signal.reason as AgentInterruptReason
return { reason: { kind: interrupt.kind === 'disposed' ? 'disposed' : 'aborted' }, idle: { kind: 'aborted' } }
return {
reason: { kind: interrupt.kind === 'disposed' ? 'disposed' : 'aborted' },
settleReason: { kind: 'aborted' },
}
}
if (failure !== undefined) {
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
@@ -639,13 +642,13 @@ export class ReactLoopAgent implements Agent {
const rendered = errorChain(error)
return {
reason: { kind: 'error', step, failure: { ...failure, ...rendered === '<unrenderable value>' ? {} : { message: rendered } } },
idle: { kind: 'error', error, failure },
settleReason: { kind: 'error', error, failure },
}
}
emitAgentEvent(this.loopCtx, this, 'agent/error', turn, step, error)
return {
reason: { kind: 'error', step, message: errorChain(error), ...isHarnessError(error) ? { code: error.code } : {} },
idle: { kind: 'error', error },
settleReason: { kind: 'error', error },
}
}

View File

@@ -178,7 +178,7 @@ describe('AgentLoop initiator scope', () => {
if (subject === agent) capture(signal)
return next()
})
ctx.on('agent/stopping', (subject, _turn, signal) => {
ctx.on('agent/turn-stopping', (subject, _turn, signal) => {
if (subject === agent) capture(signal)
})
ctx.tools.register(defineContentToolFixture({

View File

@@ -482,7 +482,7 @@ describe('Agent.cancel()', () => {
})
let cancelled = false
ctx.on('agent/stopping', (subject) => {
ctx.on('agent/turn-stopping', (subject) => {
if (subject === agent && !cancelled) {
cancelled = true
agent.cancel({ kind: 'user' })
@@ -719,7 +719,7 @@ describe('Agent.cancel()', () => {
})
break
case 'stopping':
ctx.on('agent/stopping', async (subject, _turn, signal) => {
ctx.on('agent/turn-stopping', async (subject, _turn, signal) => {
if (subject === agent) await blockUntilAbort(signal)
})
break

View File

@@ -270,7 +270,7 @@ describe('abort during tool execution ends the turn', () => {
})
describe('steering from late extension points is never stranded', () => {
it('steer() from an agent/stopping listener continues the same turn', async () => {
it('steer() from an agent/turn-stopping listener continues the same turn', async () => {
const adapter = new MockAdapter([
textResponse('no tools, would stop here'),
textResponse('continued because of steering'),
@@ -279,7 +279,7 @@ describe('steering from late extension points is never stranded', () => {
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let steeredOnce = false
ctx.on('agent/stopping', () => {
ctx.on('agent/turn-stopping', () => {
if (!steeredOnce) {
steeredOnce = true
agent.steer({ content: [{ type: 'text', text: 'one more thing' }], source: { kind: 'user' } })
@@ -355,13 +355,13 @@ describe('steering from late extension points is never stranded', () => {
})
describe('plugin exceptions are contained', () => {
it('a throwing agent/stopping listener ends the turn with an error, loop survives', async () => {
it('a throwing agent/turn-stopping listener ends the turn with an error, loop survives', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let threwOnce = false
ctx.on('agent/stopping', async () => {
ctx.on('agent/turn-stopping', async () => {
if (!threwOnce) {
threwOnce = true
throw new Error('broken continuation plugin')

View File

@@ -348,17 +348,18 @@ describe('stream failure edges', () => {
})
describe('post-turn continuation edges', () => {
it('an agent/idle listener that enqueues a waking prompt preempts continueOrIdle', async () => {
it('an agent/settled listener that enqueues a waking prompt preempts continueOrIdle', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('idle-preempt'), { provider: 'mock', model: 'mock' })
const agent = ctx.agentLoop.create(SessionId('settled-preempt'), { provider: 'mock', model: 'mock' })
let injected = false
ctx.on('agent/idle', (subject) => {
ctx.on('agent/settled', (subject) => {
if (subject !== agent || injected) return
expect(subject.status).toBe('running')
injected = true
// kick() installs the next admission synchronously, so the following
// continueOrIdle() sees an abort owner and yields to it.
send(agent, 'follow-up from idle listener')
send(agent, 'follow-up from settled listener')
})
send(agent, 'go')
@@ -531,8 +532,8 @@ describe('driver bookkeeping edges', () => {
// (the run's containment covers only session appends); the waiter's
// catch arm must treat that rejection as quiescence instead of
// propagating it.
ctx.on('agent/idle', (subject) => {
if (subject === agent) throw new Error('idle listener exploded')
ctx.on('agent/settled', (subject) => {
if (subject === agent) throw new Error('settled listener exploded')
})
send(agent, 'one')

View File

@@ -11,7 +11,7 @@ import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
/**
* The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`,
* `agent/session-start`, `agent/stopping`, and the
* `agent/session-start`, `agent/turn-stopping`, and the
* `tools/pre-execute` / `tools/post-execute`
* split with `additionalContexts` buffering. These verify the canonical event
* surface a hook bridge (or a native plugin) programs against, WITHOUT any

View File

@@ -463,7 +463,7 @@ describe('agent loop', () => {
expect(agent.session.events.some(event => event.type === 'user/message' && event.data.source.kind === 'plugin')).toBe(false)
})
it('agent/stopping can steer another step (/loop pattern)', async () => {
it('agent/turn-stopping can steer another step (/loop pattern)', async () => {
const adapter = new MockAdapter([
textResponse('step 1'),
textResponse('step 2'),
@@ -474,7 +474,7 @@ describe('agent loop', () => {
let steps = 0
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
ctx.on('agent/stopping', (subject) => {
ctx.on('agent/turn-stopping', (subject) => {
if (steps < 3) {
subject.steer({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'plugin', plugin: 'loop-test' } })
}
@@ -722,7 +722,7 @@ describe('agent loop', () => {
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
// Force exactly one continuation (step 1 → step 2), then defer to default
// (step 2 is a plain stop with no tool calls → stops).
ctx.on('agent/stopping', (subject) => {
ctx.on('agent/turn-stopping', (subject) => {
if (steps < 2) {
subject.steer({ content: [{ type: 'text', text: 'continue after truncation' }], source: { kind: 'plugin', plugin: 'max-tokens-test' } })
}

View File

@@ -57,12 +57,12 @@ describe('agent/request-error', () => {
const agent = ctx.agentLoop.create(SessionId('request-error-retry'), { provider: 'mock', model: 'mock' })
const seen: { turn: number; step: number; failure: LlmFailure }[] = []
const statuses: string[] = []
const idleTurns: number[] = []
const settledTurns: number[] = []
ctx.on('agent/status', (subject, status) => {
if (subject === agent) statuses.push(status)
})
ctx.on('agent/idle', (subject, turn) => {
if (subject === agent) idleTurns.push(turn)
ctx.on('agent/settled', (subject, turn) => {
if (subject === agent) settledTurns.push(turn)
})
ctx.on('agent/request-error', async (subject, turn, step, _error, failure) => {
expect(subject).toBe(agent)
@@ -101,7 +101,7 @@ describe('agent/request-error', () => {
{ kind: 'retry' },
])
expect(statuses).toEqual(['running', 'idle'])
expect(idleTurns).toEqual([3])
expect(settledTurns).toEqual([3])
})
it('lets cancellation win over a retry request', async () => {

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: 1a16c8ad5a8b7d6e06d4ac36a7b9978fda64e7b5
README.zh.md: 4e03816707ba6a3a707697db1e6d8b117aa87a6a
README.md: d3aced3c680f2d4ae8df9c3f2bf83ae01435d05b
README.zh.md: e2c57ba50162deeac502d4f8eff23f7f2f7dab71

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/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 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.
`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/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失败步骤关闭后它接收确切错误、规范化失败事实和信号。拥有恢复权的监听器调用 `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)拥有。
`PromptDecision.additionalContexts` 是数组,因此每个上下文都保留自己的来源。获准的提示词内容与每个附加上下文都会在轮次运行前成为各自独立、面向模型的 `user/message` 事件。包装下游允许决策的监听器会保留其 `content``additionalContexts`,除非有意替换任一字段;返回的允许决策是权威来源。

View File

@@ -113,11 +113,11 @@ export type PromptDecision =
export type RequestError = Error & { code?: string }
/**
* Why a turn ended, reported live on `agent/idle` right after the turn's
* 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;
* model-request recovery runs earlier through `agent/request-error`.
*/
export type IdleReason =
export type SettleReason =
| { kind: 'completed' }
| { kind: 'aborted' }
| { kind: 'error'; error: unknown; failure?: LlmFailure }
@@ -375,7 +375,7 @@ declare module 'cordis' {
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode serial
*/
'agent/stopping'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void
'agent/turn-stopping'(this: Scoped<Agent>, agent: Agent, turn: number, signal: AbortSignal): Promise<void> | void
/**
* One drain chain reached its terminal turn: that turn's `turn/end` is
* already committed. Automatically recovered failed turns do not emit this
@@ -389,7 +389,7 @@ declare module 'cordis' {
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode emit
*/
'agent/idle'(this: Scoped<Agent>, agent: Agent, turn: number, reason: IdleReason): void
'agent/settled'(this: Scoped<Agent>, agent: Agent, turn: number, reason: SettleReason): void
// ---- error notifications (emit) ----
/**

View File

@@ -12,7 +12,6 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
'agent/created': args => args[0],
'agent/disposed': args => args[0],
'agent/error': args => args[0],
'agent/idle': args => args[0],
'agent/inbox/dequeue': args => args[0],
'agent/inbox/discard': args => args[0],
'agent/inbox/enqueue': args => args[0],
@@ -20,9 +19,10 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
'agent/request': args => args[0],
'agent/request-error': args => args[0],
'agent/session-start': args => args[0],
'agent/settled': args => args[0],
'agent/status': args => args[0],
'agent/step': args => args[0],
'agent/stopping': args => args[0],
'agent/turn-stopping': args => args[0],
'approval/request': args => (args[0] as Record<string, unknown>)['agent'],
'goal/changed': args => args[0],
'session/created': null,

View File

@@ -58,8 +58,8 @@ describe('scoped-dispatch invariants', () => {
signal,
() => Promise.resolve(),
],
'agent/stopping': [agent, 1, signal],
'agent/idle': [agent, 1, { kind: 'completed' }],
'agent/turn-stopping': [agent, 1, signal],
'agent/settled': [agent, 1, { kind: 'completed' }],
'agent/error': [agent, 1, 0, new Error('x')],
} satisfies { [K in AgentEventName]: EventArgs<K> }
const rows: Array<[string, unknown[]]> = [