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 () => {