refactor(agent): clarify turn lifecycle event names
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`)执行取消。
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' } })
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user