refactor(agent-loop): rely on eager session persistence

This commit is contained in:
_Kerman
2026-07-24 16:40:33 +08:00
parent 6945a2c37d
commit b3c1abac67
13 changed files with 28 additions and 125 deletions
@@ -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
2026-07-17-one-send-one-turn.md: 12055e9dcb6f59fb5a8f33a987717e37e20d9a1f
2026-07-17-one-send-one-turn.zh.md: d57c10b127a0e9610957d9eb201ddd07eff64915
2026-07-17-one-send-one-turn.md: 5d25d46a2d7a11be5761c49267b9f7e605035836
2026-07-17-one-send-one-turn.zh.md: e223f5bcb93cdc7032f8b0ccf550322b9c98facf
@@ -24,7 +24,7 @@ Prompt admission decides one message at a time. An allowed prompt becomes that t
The no-batching rule applies only to ordinary `send()`. Running `steer()` puts input in the outbox. While a turn remains open, the loop records that input at the next step boundary and steering makes another step the default. A failure before that boundary leaves the steering staged without waking the agent; `retry()` or a later prompt takes it, while cancellation or disposal can discard it. When the agent is idle, `steer()` delegates to `send()`, so it creates an independent ordinary queue item.
`inject()` continues to add model-facing context without submitting an ordinary message. During a turn it waits in the outbox for a safe step boundary; while idle it appends and flushes a `user/message` directly, without opening a turn or running the model. `whenIdle()` and disposal await that flush. `cancel()` remains a whole-agent operation that can clear all unstarted ordinary and steering input and abort the current step. `status` and `whenIdle()` also describe the whole agent, not one message. Several one-message turns can share one `running` interval, so `running` does not prove that a turn is open.
`inject()` continues to add model-facing context without submitting an ordinary message. During a turn it waits in the outbox for a safe step boundary; while idle it appends a `user/message` directly, without opening a turn or running the model. Persistence owns the resulting eager drain. `cancel()` remains a whole-agent operation that can clear all unstarted ordinary and steering input and abort the current step. `status` and `whenIdle()` also describe the whole agent, not one message. Several one-message turns can share one `running` interval, so `running` does not prove that a turn is open.
## Alternatives considered
@@ -24,7 +24,7 @@ Status: implemented
上述不合批规则只适用于普通 `send()`。agent 运行时,`steer()` 会把输入放入 outbox。只要当前轮次仍然打开,agent loop 就会在下一个步骤边界记录该输入,而 steering(中途引导)会默认让循环再执行一个步骤。在到达该边界前发生失败,会让 steering 保持暂存且不唤醒 agent`retry()` 或后续提示词会取走它,而取消或 dispose 可以将其丢弃。agent 空闲时,`steer()` 委托给 `send()`,因此会创建一个独立的普通队列项。
`inject()` 继续添加面向模型的上下文,而不提交普通消息。轮次打开时,该上下文会留在 outbox 中,等待安全的步骤边界;agent 空闲时,系统会直接追加一条 `user/message` 并完成持久化刷新,既不打开轮次,也不运行模型。`whenIdle()` 和 dispose 会等待该刷新完成`cancel()` 仍是面向整个 agent 的操作,可以清空所有尚未启动的普通输入和 steering,并中止当前步骤。`status``whenIdle()` 描述的也是整个 agent,而不是某一条消息。多个单消息轮次可以共用一个 `running` 区间,因此 `running` 不表示轮次一定处于打开状态。
`inject()` 继续添加面向模型的上下文,而不提交普通消息。轮次打开时,该上下文会留在 outbox 中,等待安全的步骤边界;agent 空闲时,系统会直接追加一条 `user/message`,既不打开轮次,也不运行模型。持久化层独立负责由此产生的即时排空`cancel()` 仍是面向整个 agent 的操作,可以清空所有尚未启动的普通输入和 steering,并中止当前步骤。`status``whenIdle()` 描述的也是整个 agent,而不是某一条消息。多个单消息轮次可以共用一个 `running` 区间,因此 `running` 不表示轮次一定处于打开状态。
## 曾考虑的替代方案
+2 -2
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
architecture.md: 154c7f69fca5d4c599563ac26d01f75138be26e3
architecture.zh.md: 54ce614ca0998b09a075323f93aed3ac07fb5edb
architecture.md: 978bb5ab45358dd93c86e54e0417318008ea8edd
architecture.zh.md: 452c79c1054543dec0d16b88e8ac1d70e30b66f0
+2 -2
View File
@@ -106,13 +106,13 @@ forever:
start the next waking queued message, or emit agent/status(idle)
idle inject:
append 'user/message' -> flush persistence
append 'user/message'
do not open a turn or run the model
```
Each step assembles ordered prompt sections, tool schemas, and variables; unknown references fail the turn. `dsh-system-prompt` owns identity and persona, while the loop supplies `model` and `cwd` ([prompt ownership](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)).
Tool-time context—including active-turn `inject()` and post-tool `additionalContexts`—settles after results. Steering drains at the same boundary and requests another step. Idle `inject()` instead appends and flushes context immediately without changing turn numbering; `whenIdle()` and disposal await that flush.
Tool-time context—including active-turn `inject()` and post-tool `additionalContexts`—settles after results. Steering drains at the same boundary and requests another step. Idle `inject()` instead appends context immediately without changing turn numbering; persistence owns its eager drain.
Pruning precedes summaries; overflow retries require durable progress. Bounded transient retries compose on `agent/request-error`; cancellation wins ([compaction](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md), [retry](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md)).
+2 -2
View File
@@ -106,13 +106,13 @@ forever:
start the next waking queued message, or emit agent/status(idle)
idle inject:
append 'user/message' -> flush persistence
append 'user/message'
do not open a turn or run the model
```
每个步骤都会组装有序提示词片段、工具 schema 和变量;未知引用会使该轮次失败。`dsh-system-prompt` 负责身份和角色设定,循环则提供 `model``cwd`[提示词归属](../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md))。
工具执行阶段的上下文,包括活跃轮次内的 `inject()` 和工具执行后的 `additionalContexts`,会在结果记录完毕后落定。steering(中途引导)会在同一边界排空,并请求再执行一个步骤。空闲状态下的 `inject()` 则会立即追加上下文并完成持久化刷新,且不改变轮次编号;`whenIdle()` 和 dispose(资源释放)会等待该刷新完成
工具执行阶段的上下文,包括活跃轮次内的 `inject()` 和工具执行后的 `additionalContexts`,会在结果记录完毕后落定。steering(中途引导)会在同一边界排空,并请求再执行一个步骤。空闲状态下的 `inject()` 则会立即追加上下文,且不改变轮次编号;持久化层独立负责由此产生的即时排空
裁剪先于摘要;溢出重试必须取得持久进展。有界的瞬态重试在 `agent/request-error` 上组合;取消优先([压缩](../.agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)、[重试](../.agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md))。
+2 -2
View File
@@ -12,7 +12,7 @@ Creation and resume are one rollback-covered transaction: construct a private se
The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear.
Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`.
Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`.
- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): Agent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and normally mints `${label}-session-<uuid>` before calling this boundary. An app may instead supply a stable exact `sessionId`: first use creates it, while a remount with persistence already present resumes its materialized history. `resumeSessionId` requires and loads an existing persisted id and is mutually exclusive with `sessionId`. This keeps default fresh restarts collision-free without retaining a second live routing identity.
@@ -52,7 +52,7 @@ Configured agents start automatically. A model call requires both `provider` and
The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy.
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. An allowed prompt and the prompt waterfall's `additionalContexts` enter the outbox as separate messages, then `run()` opens the turn and drains them together. A running `next-step`/wakeup `steer()` enters the same outbox without prompt admission and normally causes another step. A `next-step`/no-wakeup `inject()` waits there only while a turn is open; while idle it appends and flushes a `user/message` immediately without opening a turn or running the model. Every inbox enqueue publishes `agent/inbox/enqueue`; taking it publishes `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
The unified `send()` primitive routes content and source by (`target` × `wakeup`); `followup`/`steer`/`inject` are its fixed-preset aliases. A `next-turn` item joins the queued FIFO, waking the driver unless `wakeup: false`; admission happens before any turn opens. An allowed prompt and the prompt waterfall's `additionalContexts` enter the outbox as separate messages, then `run()` opens the turn and drains them together. A running `next-step`/wakeup `steer()` enters the same outbox without prompt admission and normally causes another step. A `next-step`/no-wakeup `inject()` waits there only while a turn is open; while idle it appends a `user/message` immediately without opening a turn or running the model. Persistence owns the resulting eager drain. Every inbox enqueue publishes `agent/inbox/enqueue`; taking it publishes `agent/inbox/dequeue`, and `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
### Loop lifecycle (`loop.ts`)
+6 -17
View File
@@ -139,11 +139,6 @@ export class ReactLoopAgent extends Agent {
return id
}
this.session.append('user/message', { content, source }, { surfaceOp: 'append' })
const previous = this.done
const flush = this.loopCtx.sessions.flush(this.session).catch((error: unknown) => {
this.loopCtx.logger.warn(`agent "${this.id}": flush after idle injection failed: ${errorChain(toError(error))}`)
})
this.done = Promise.all([previous, flush]).then(() => undefined)
return id
}
@@ -199,19 +194,15 @@ export class ReactLoopAgent extends Agent {
*/
retry(): void {
if (this.abort !== undefined) throw new Error(`agent "${this.id}" cannot retry while busy`)
const previous = this.done
const run = this.loopCtx.agents.withInitiator(this, () => this.run({ kind: 'retry' }))
this.done = Promise.all([previous, run]).then(() => undefined)
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 by runs and idle-injection flushes. Re-read after
// every settlement so work admitted by a synchronous observer is included.
while (true) {
const done = this.done
await done.catch(() => undefined)
if (done === this.done && this.abort === undefined && !this.queued.some(message => message.wakeup)) return
// `done` is replaced per activity, so re-reading it follows chained turns;
// a run failure still counts as quiescence for the waiter.
while (this.abort !== undefined || this.queued.some(message => message.wakeup)) {
await this.done.catch(() => undefined)
}
}
@@ -224,8 +215,7 @@ export class ReactLoopAgent extends Agent {
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', inboxMessage(message, false))
const admission = new AbortController()
this.abort = admission
const previous = this.done
const admissionTask = this.loopCtx.agents.withInitiator(this, async () => {
this.done = this.loopCtx.agents.withInitiator(this, async () => {
const signal = admission.signal
const trigger: TurnTrigger = { kind: 'message', source: message.source }
let admitted = false
@@ -258,7 +248,6 @@ export class ReactLoopAgent extends Agent {
}
await this.run(trigger)
})
this.done = Promise.all([previous, admissionTask]).then(() => undefined)
}
/** Own one complete turn over input already admitted by {@link kick}, or retry history as-is. */
+6 -37
View File
@@ -6,7 +6,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { bindReactLoopAgentContext, prepareReactLoopAgent, type ReactLoopAgent } from '../src/agent.ts'
import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts'
import { MockAdapter, textResponse } from './mock-adapter.ts'
function driverDone(agent: Agent): Promise<void> {
@@ -83,30 +83,19 @@ describe('Agent', () => {
await ctx.fiber.dispose()
})
it('idle inject() appends context and flushes without opening a turn', async () => {
it('idle inject() appends context without opening a turn or requesting a flush', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const release = Promise.withResolvers<void>()
let flushes = 0
ctx.on('session/flush', async (session) => {
if (session !== agent.session) return
flushes += 1
await release.promise
})
ctx.on('session/flush', () => { flushes += 1 })
agent.inject([{ type: 'text', text: 'context' }], { source: { kind: 'plugin', plugin: 'p' } })
expect(agent.session.events.map(event => event.type)).toEqual(['user/message'])
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(0)
let idle = false
const settled = agent.whenIdle().then(() => { idle = true })
await Promise.resolve()
expect(flushes).toBe(1)
expect(idle).toBe(false)
release.resolve()
await settled
await agent.whenIdle()
expect(flushes).toBe(0)
})
it('inject() defaults its source to an empty plugin, never user', async () => {
@@ -119,35 +108,15 @@ describe('Agent', () => {
await agent.whenIdle()
})
it('idle inject() contains a failing flush without inventing an agent turn error', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
ctx.on('session/flush', () => { throw new Error('disk gone') })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => { errors.push(error) })
expect(() => { agent.inject([{ type: 'text', text: 'notice' }], { source: { kind: 'plugin', plugin: 'p' } }) }).not.toThrow()
await agent.whenIdle()
expect(errors).toEqual([])
expect(agent.session.events.map(event => event.type)).toEqual(['user/message'])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('flush after idle injection failed'))
warn.mockRestore()
})
it('idle inject() does not flush input rejected before append', async () => {
it('idle inject() rejects invalid input before append', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
expect(() => {
agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
}).toThrow(/non-JSON-serializable/)
expect(agent.session.events).toHaveLength(0)
expect(flushes).toBe(0)
})
it('steer() when idle falls through to send() and starts a turn', async () => {
@@ -474,28 +474,6 @@ describe('the session-persistence Agent Note: AgentLoop factory create/resume',
await ctx2.fiber.dispose()
})
it('an idle inject() is flushed durably on its own (survives without explicit flush/dispose)', async () => {
// No clean disposal follows, so disk presence proves the idle injection's
// own checkpoint ran without a synthetic turn.
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent
a1.followup([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
await a1.whenIdle()
// A SEPARATE backend reads the on-disk log — proving the inject persisted
// itself, not a later dispose drain.
const probe = new Context()
await probe.plugin(SessionStore)
await probe.plugin(SessionPersistenceJsonl, { root })
const loaded = await probe.sessionPersistence.load(SessionId('inject-sess'))
expect(JSON.stringify(loaded.events)).toContain('background task 42 finished')
await probe.fiber.dispose()
await ctx1.fiber.dispose()
})
it('an idle inject() survives persist + resume without a synthetic turn', async () => {
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
@@ -1041,33 +1041,4 @@ describe('agent scope lifecycle', () => {
await ctx.fiber.dispose()
})
it('handle.dispose() awaits an idle-injection flush before unregistering or detaching', async () => {
const ctx = await harness()
const handle = await ctx.agents.create({
sessionId: SessionId('idle-flush-s'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const gate = Promise.withResolvers<undefined>()
let flushStarted = false
ctx.on('session/flush', (session) => {
if (session !== handle.agent.session) return
flushStarted = true
return gate.promise
})
handle.agent.inject(text('durable idle context'), { source: { kind: 'plugin', plugin: 'test' } })
expect(flushStarted).toBe(true)
let disposed = false
const disposal = handle.dispose().then(() => { disposed = true })
await new Promise(resolve => setTimeout(resolve, 0))
expect(disposed).toBe(false)
expect(ctx.agents.get(SessionId('idle-flush-s'))).toBe(handle.agent)
expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBe(handle.agent.session)
gate.resolve(undefined)
await disposal
expect(ctx.agents.get(SessionId('idle-flush-s'))).toBeUndefined()
expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBeUndefined()
})
})
+2 -2
View File
@@ -38,7 +38,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age
- `ctx.agents.create(options: CreateAgentOptions): Promise<AgentHandle>` — create a session and agent, await optional setup while unpublished, then publish through final `SessionStore.enter()` and `AgentRegistry.enter()` checks. Concurrent same-ID creation is unsupported: more than one operation may prepare, but only one can enter; every loser rolls its private scope/session/driver back. An optional creation-only `signal` cancels unpublished setup and is detached before the handle is returned; later cancellation uses `handle.dispose()` or `agent.cancel()`. Publication is rollback-covered and every delivered creation edge is paired during rollback. Rejects if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md)), mint a fresh unpublished agent scope, await optional setup, and use the same final-entry publication sequence. Its optional `signal` is likewise creation-only. Rejects if no factory is registered or session persistence is unconfigured.
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, `await`s its exit plus every outstanding idle-injection flush (not just the `disposed` status flip), unregisters the agent, removes its session from the store, and finally unwinds its scoped world. This order captures every agent-started `session/flush` before the session is detached and keeps scoped listeners alive through those checkpoints. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber.
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **consumer capability** — no observer holding the bare registry entry can tear the agent down. The caller fiber and the registered factory provider are structural co-owners: caller unload enforces structured ownership, while factory unload must stop old instances because their scoped dependency surface belongs to that provider. `dispose()` from any owner reaches one memoized quiescence boundary: it stops the loop, awaits its exit, unregisters the agent, removes its session from the store, and finally unwinds its scoped world. `ctx.agents.get(id)` still returns a bare `Agent`; the ACP bridge and in-process subagent backends hold consumer handles, while config-created agents are already owned by the loop fiber.
### Live events
@@ -59,7 +59,7 @@ The handle every plugin programs against:
- `agent.send(content, options?)` — the one delivery primitive over the (`target` × `wakeup`) matrix; `Agent` is an abstract class whose `followup`/`steer`/`inject` aliases are fixed-preset delegates to it. It returns the accepted message's opaque `AgentMessageId`, which the message's `agent/inbox/enqueue`/`dequeue`/`discard` events carry so a caller can correlate a queued item with its lifecycle. Omitting the whole options object selects `{ target: 'next-turn', wakeup: true, source: { kind: 'user' } }`; a supplied `SendOptions` must provide all three fields. `target: 'next-turn'` queues one independent FIFO item that, if admitted, becomes the sole ordinary prompt in its turn. `target: 'next-step'` with `wakeup: true` submits steering, while `target: 'next-step'` with `wakeup: false` injects durable context without running the model. The [one-send-one-turn Agent Note](../../../.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md) owns the turn rationale.
- `agent.followup(content, options?)` — the `next-turn`/wakeup preset of `send()`: queue an ordinary follow-up turn and wake the driver.
- `agent.steer(content, options?)` — the `next-step`/wakeup preset: while a turn is open, stage steering for its next safe boundary without dispatching `agent/prompt-submit`; when idle, delegate to a woken follow-up. Cancellation or disposal may discard pending steering.
- `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by `source`. While a turn is open, injection waits in the outbox for the next safe boundary. While idle, it appends immediately and starts a durability flush without opening a turn; `whenIdle()` and disposal await that flush. Injection emits no `agent/inbox/*` event.
- `agent.inject(content, options?)` — the `next-step`/no-wakeup preset: append model-facing context without running the model; the next request sees a verbatim user-role message whose provenance is carried by `source`. While a turn is open, injection waits in the outbox for the next safe boundary. While idle, it appends immediately without opening a turn; persistence reacts to `session/event` independently. Injection emits no `agent/inbox/*` event.
- `agent.cancel(cause, options?)` — cancel the active turn and, unless `options.keepInbox`, ALL pending work. Callers must choose the `user | parent` cause explicitly; an active holder copies its discriminant into a detached frozen signal reason before aborting. An effective call emits `agent/cancel-requested` with the cause before clearing queued and steering work; dropped items are reported on `agent/inbox/discard`, and observers may synchronize state but cannot veto cancellation. `keepInbox: true` aborts the turn but preserves queued and steering items (no discard, and un-started work is not dropped). The same-process typed seam adds no runtime validation or compatibility fallback for untyped callers. Repeated active-turn cancellation is first-wins for the signal, and idle cancellation is a safe no-op with no notification. ACP maps to `user`, while in-process parent propagation maps to `parent`. The cause is runtime-only; durable `turn/end` stays coarse `aborted`.
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
- `agent.session`, `agent.status`, `agent.options`, `agent.id`
+2 -6
View File
@@ -126,12 +126,8 @@ export interface ResumeAgentOptions {
* only the holder can tear this agent down. The registered factory provider is
* also a structural owner because the scoped agent depends on that provider's
* service surface; provider unload stops and drains every live handle it made.
* `dispose()` stops the loop, awaits its exit and every outstanding
* idle-injection flush (quiescence — NOT just the `disposed`
* status flip), unregisters the agent, removes its session from the store, and
* finally unwinds its scoped world. This order captures every agent-started
* `session/flush` before the session is detached and keeps scoped listeners
* alive through those checkpoints.
* `dispose()` stops the loop, awaits its exit, unregisters the agent, removes
* its session from the store, and finally unwinds its scoped world.
*
* `ctx.agents.get(id)` still returns a bare {@link Agent} — the handle is
* exposed only to the consumer owner that created it; the structural provider