From b3c1abac672fb2cfc810b74cc5adacc67b6301eb Mon Sep 17 00:00:00 2001 From: _Kerman Date: Fri, 24 Jul 2026 16:40:33 +0800 Subject: [PATCH] refactor(agent-loop): rely on eager session persistence --- .../2026-07-17-one-send-one-turn.i18n.yaml | 4 +- .../2026-07-17-one-send-one-turn.md | 2 +- .../2026-07-17-one-send-one-turn.zh.md | 2 +- docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 4 +- docs/architecture.zh.md | 4 +- packages/core/agent-loop/README.md | 4 +- packages/core/agent-loop/src/agent.ts | 23 +++------- packages/core/agent-loop/tests/agent.spec.ts | 43 +++---------------- packages/core/agent-loop/tests/resume.spec.ts | 22 ---------- .../agent-loop/tests/scope-lifecycle.spec.ts | 29 ------------- packages/core/agent/README.md | 4 +- packages/core/agent/src/index.ts | 8 +--- 13 files changed, 28 insertions(+), 125 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml index 5375f25653..60fd394329 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md index 12055e9dcb..5d25d46a2d 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md @@ -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 diff --git a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md index d57c10b127..e223f5bcb9 100644 --- a/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md @@ -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` 不表示轮次一定处于打开状态。 ## 曾考虑的替代方案 diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 47df845b22..4ee850a161 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index 154c7f69fc..978bb5ab45 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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)). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 54ce614ca0..452c79c105 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -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))。 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 3c6165eef7..42a3b2dfd8 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -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-` 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`) diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 5aaeb491da..04f882a323 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -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 { - // `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. */ diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 6b1231c075..69b21966f5 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -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 { @@ -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() 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 () => { diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index eced5be937..4e0d557ed9 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -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) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 8844e5c1a3..b9535e9448 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -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() - 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() - }) }) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index fc4267cc49..5e6e37d466 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -38,7 +38,7 @@ Agent *creation* is provided by the plugin implementing `AgentFactory` (`dsh-age - `ctx.agents.create(options: CreateAgentOptions): Promise` — 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` — 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 }`. 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 }`. 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` diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 38dab02993..61baa595f4 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -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