round 1: implement bracket-first manual compaction

This commit is contained in:
Hypatia May
2026-07-30 17:40:25 +08:00
parent 86b95a3856
commit faac9b4fd5
101 changed files with 3452 additions and 295 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: a1617a1ef871f61157e0d70a06d055168170dced
README.zh.md: 6ba945a41e700331929dabb557802c14256921fb
README.md: a837c3eb71c923f8035cf912096026c5a052b900
README.zh.md: 7b0b158bae2fe90b2e3430dffe3528f0adb6037e

View File

@@ -55,7 +55,7 @@ Configured agents start automatically. A model call requires both `provider` and
The concrete `ReactLoopAgent`, its queued input, outbox, and run 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. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model.
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. `reserveTurnAdmission()` can synchronously hold that idle boundary for a standalone durable operation: accepted waking work has right of way, later sends keep their ordinary queue identity and FIFO position, release re-arms the same driver path, and `whenIdle()` waits for the reservation without making teardown await it. The loop opens a private next-step acceptance window before `agent/prompt-submit` and closes it before `turn/end`. During that window, `steer()` and `inject()` stage in one outbox; an allowed admission opens the turn, records the prompt and returned `additionalContexts`, then drains the staged input before the first request. A blocked or failed admission writes no prompt or hook-produced context. A caller-staged context-only batch then takes idle injection's immediate append, while steering and context staged beside it remain pending for retry or a later admitted prompt. Outside the window, steering becomes a waking queued prompt and injection immediately appends `user/message` without opening a turn or running the model.
Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous queued-item boundary: edit freezes replacement content without changing message identity or position, while remove publishes discard. Edit publishes `agent/inbox/update`; steering and claimed occurrences return `not-found`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update cannot rewrite durable history; `cancel()` without `keepInbox` publishes `agent/inbox/discard`.

View File

@@ -55,7 +55,7 @@ interface Config {
实体 `ReactLoopAgent`、其排队输入、outbox 与运行控制均为包内部实现。包根只导出插件/服务/配置契约,包导出映射不提供 `./src/*` 逃逸路径;生命周期拥有方通过 `ctx.agents` 创建 agent而不是点名、构造或启动驱动器内部组件。一个准备完成的会话只能由一个实体驱动器认领所有可观测行为都通过会话事件和 `agent/*` 事件分类体系发生。
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()``inject()` 会暂存到同一个 outbox接纳获准后会开启轮次记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering中途引导及与其一同暂存的上下文则继续待处理以供重试或之后获准的提示词使用。窗口之外steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。
统一的 `send()` 原语按(`target` × `wakeup`)路由内容与来源;`followup`/`steer`/`inject` 是它的固定预设别名。`next-turn` 项加入排队 FIFO除非 `wakeup: false`,否则会唤醒驱动器;接纳发生在任何轮次开启之前。`reserveTurnAdmission()` 可以为独立持久操作同步保留该空闲边界:已获接纳的唤醒工作拥有优先权,之后发送的项保留普通队列身份与 FIFO 位置,释放会重新启用同一驱动器路径,`whenIdle()` 会等待预留结束,但 teardown 不会等待它。循环在 `agent/prompt-submit` 之前打开一个私有的 next-step 接收窗口,并在 `turn/end` 之前关闭它。在该窗口内,`steer()``inject()` 会暂存到同一个 outbox接纳获准后会开启轮次记录提示词及其返回的 `additionalContexts`,再于首次请求前排空暂存输入。接纳被阻止或失败时,不会写入提示词或钩子生成的上下文。之后,仅含调用方暂存上下文的批次会采用空闲注入的立即追加行为,而 steering中途引导及与其一同暂存的上下文则继续待处理以供重试或之后获准的提示词使用。窗口之外steering 会成为唤醒驱动器的排队提示词,而注入会立即追加 `user/message`,不开启轮次也不运行模型。
每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard。编辑会发布 `agent/inbox/update`steering 项和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`

View File

@@ -2,7 +2,8 @@
* Concrete Agent loop over two pending-input lists: queued prompts each open a
* turn that logs its admitted input after `turn/start` commits, while steering
* and injected context enter through the outbox at step boundaries. Every
* request is derived from the session log.
* request is derived from the session log. An idle turn-admission reservation
* can withhold the driver from the queue without touching its contents.
*
* @module dsh-agent-loop/agent
*/
@@ -68,6 +69,12 @@ export class ReactLoopAgent implements Agent {
private busy = false
/** Whether an idle waking send has deferred driver admission. */
private wakeScheduled = false
/**
* The live idle turn-admission reservation, holding the driver out of the
* queue until its owner releases. It settles idle waiters instead of
* {@link done} so lifecycle teardown never awaits the reserving operation.
*/
private admission: { readonly settled: Promise<void>; readonly settle: () => void } | undefined
/** Whether next-step input belongs to the current admission or open turn. */
acceptsNextStep = false
/** Abort owner for the current admission or turn. */
@@ -192,6 +199,32 @@ export class ReactLoopAgent implements Agent {
})
}
/**
* Hold the idle admission boundary so no queued prompt can open a turn until
* the returned release runs. Later sends keep their ordinary placement and
* `wakeup` facts; only the driver's claim waits.
* @returns the idempotent release, or `undefined` when the driver is active or already committed to waking work.
*/
reserveTurnAdmission(): (() => void) | undefined {
// `busy` covers every abort owner: kick() and run() mark the interval
// running before they install one. `wakeScheduled` is the same-tick state
// of an accepted waking prompt whose claim is still a pending microtask.
if (this.busy || this.wakeScheduled || this.admission !== undefined
|| this.queued.some(item => item.wakeup)) return undefined
const pending = Promise.withResolvers<void>()
const reservation = { settled: pending.promise, settle: pending.resolve }
this.admission = reservation
return () => {
// Idempotent, and inert once a later reservation owns the boundary.
if (this.admission !== reservation) return
this.admission = undefined
// Re-arm the ordinary path first, so an idle waiter released below
// re-reads live admission activity instead of settled state.
if (this.queued.some(item => item.wakeup)) this.scheduleKick()
reservation.settle()
}
}
/**
* Clear all pending work and abort the active turn; the first cause wins.
* The cause is signal payload for observers and the durable turn/end
@@ -225,19 +258,34 @@ export class ReactLoopAgent implements Agent {
/** Resolve at idle quiescence: no run driving and no waking prompt waiting. */
async whenIdle(): Promise<void> {
// `done` is replaced per activity, so re-reading it follows chained turns.
// Every driver failure today is contained before it can reject `done`,
// but the waiter must not gamble quiescence on that: a future escape
// still counts as settled activity.
/* v8 ignore next 3 -- the catch arm backstops rejection paths that are all currently contained */
while (this.busy || this.wakeScheduled || this.abort !== undefined || this.queued.some(item => item.wakeup)) {
await this.done.catch(() => undefined)
while (true) {
// `done` is replaced per activity, so re-reading it follows chained turns.
// Every driver failure today is contained before it can reject `done`,
// but the waiter must not gamble quiescence on that: a future escape
// still counts as settled activity.
/* v8 ignore next 3 -- the catch arm backstops rejection paths that are all currently contained */
while (this.busy || this.wakeScheduled || this.abort !== undefined || this.runnableWakingQueued) {
await this.done.catch(() => undefined)
}
// A reservation is unfinished activity even with an empty queue, and a
// prompt it withholds is not quiescent — but `done` never owns it, so
// waiting on the queue alone would spin on an already-settled promise.
const reservation = this.admission
if (reservation === undefined) return
await reservation.settled
}
}
/** Whether a queued waking prompt may claim the driver now. */
private get runnableWakingQueued(): boolean {
return this.admission === undefined && this.queued.some(item => item.wakeup)
}
/** Defer idle admission while keeping {@link done} as its quiescence owner. */
private scheduleKick(): void {
if (this.abort !== undefined || this.wakeScheduled) return
// A held reservation keeps the item queued with no scheduled claim; its
// release re-arms this path for whatever is queued by then.
if (this.abort !== undefined || this.wakeScheduled || this.admission !== undefined) return
this.wakeScheduled = true
const pending = Promise.withResolvers<void>()
const scheduled = pending.promise
@@ -259,7 +307,7 @@ export class ReactLoopAgent implements Agent {
/** Claim and admit the next queued prompt, then start its turn. */
private kick(): void {
if (this.abort !== undefined || !this.queued.some(item => item.wakeup)) return
if (this.abort !== undefined || !this.runnableWakingQueued) return
// The some() guard above proves the queue is non-empty; the non-null
// assertion expresses that invariant.
// oxlint-disable-next-line typescript/no-non-null-assertion
@@ -762,7 +810,7 @@ export class ReactLoopAgent implements Agent {
/** Continue with a waking prompt, or publish the idle status. */
private continueOrIdle(): void {
if (this.queued.some(item => item.wakeup)) {
if (this.runnableWakingQueued) {
this.kick()
} else {
// Every caller sits inside an admission or run whose install marked the

View File

@@ -0,0 +1,286 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import AgentRegistry, { type Agent, type InboxItem } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import LlmService, { createUserMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function prompt(agent: Agent, text: string): void {
agent.followup(createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
}))
}
function itemText(item: InboxItem): string {
return item.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
}
interface InboxRecording {
readonly events: string[]
readonly enqueued: InboxItem['id'][]
readonly dequeued: InboxItem['id'][]
readonly discarded: InboxItem['id'][]
}
/** Record the complete inbox lifecycle of one agent for order and identity assertions. */
function recordInbox(ctx: Context): InboxRecording {
const events: string[] = []
const enqueued: InboxItem['id'][] = []
const dequeued: InboxItem['id'][] = []
const discarded: InboxItem['id'][] = []
ctx.on('agent/inbox/enqueue', (_agent, item) => {
events.push(`enqueue:${item.placement}:${itemText(item)}`)
enqueued.push(item.id)
})
ctx.on('agent/inbox/dequeue', (_agent, item) => {
events.push(`dequeue:${itemText(item)}`)
dequeued.push(item.id)
})
ctx.on('agent/inbox/discard', (_agent, items) => {
events.push(`discard:${items.map(itemText).join(',')}`)
discarded.push(...items.map(item => item.id))
})
return { events, enqueued, dequeued, discarded }
}
/** Text of every ordinary prompt the log admitted, in durable order. */
function promptTexts(agent: Agent): string[] {
return agent.session.events.flatMap(event => event.type === 'user/message'
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : [])
: [])
}
describe('idle turn admission reservation', () => {
it('holds later waking prompts in the FIFO until release', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const inbox = recordInbox(ctx)
const release = agent.reserveTurnAdmission()
expect(release).toBeDefined()
prompt(agent, 'first prompt')
prompt(agent, 'second prompt')
expect(agent.acceptsNextStep).toBe(false)
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
expect(agent.status).toBe('idle')
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events).toHaveLength(0)
expect(inbox.events).toEqual([
'enqueue:queued:first prompt',
'enqueue:queued:second prompt',
])
release?.()
await agent.whenIdle()
expect(promptTexts(agent)).toEqual(['first prompt', 'second prompt'])
expect(agent.session.events.flatMap(event =>
event.type === 'turn/start' ? [event.data.turn] : [])).toEqual([1, 2])
expect(inbox.events).toEqual([
'enqueue:queued:first prompt',
'enqueue:queued:second prompt',
'dequeue:first prompt',
'dequeue:second prompt',
])
expect(inbox.dequeued).toEqual(inbox.enqueued)
expect(inbox.discarded).toEqual([])
})
it('refuses acquisition when an accepted waking prompt still owns the next turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
prompt(agent, 'accepted first')
expect(agent.status).toBe('idle')
expect(agent.reserveTurnAdmission()).toBeUndefined()
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
})
it('refuses acquisition while a turn is running', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const reserved: unknown[] = []
ctx.on('agent/step', () => {
reserved.push(agent.reserveTurnAdmission())
})
prompt(agent, 'running')
await agent.whenIdle()
expect(agent.status).toBe('idle')
expect(reserved).toEqual([undefined])
})
it('refuses a second reservation and releases idempotently', 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 = agent.reserveTurnAdmission()
expect(agent.reserveTurnAdmission()).toBeUndefined()
prompt(agent, 'queued behind the reservation')
release?.()
release?.()
await agent.whenIdle()
expect(promptTexts(agent)).toEqual(['queued behind the reservation'])
expect(adapter.requests).toHaveLength(1)
const second = agent.reserveTurnAdmission()
expect(second).toBeDefined()
second?.()
})
it('ignores a stale release once a later reservation owns the boundary', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const stale = agent.reserveTurnAdmission()
stale?.()
const live = agent.reserveTurnAdmission()
prompt(agent, 'held by the live reservation')
stale?.()
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
expect(adapter.requests).toHaveLength(0)
live?.()
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
})
it('acquires beside quiet queued work and leaves it queued', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
agent.send(createUserMessage({
content: [{ type: 'text', text: 'quiet' }],
source: { kind: 'user' },
}), {
target: 'next-turn',
wakeup: false,
})
const release = agent.reserveTurnAdmission()
expect(release).toBeDefined()
release?.()
await agent.whenIdle()
expect(adapter.requests).toHaveLength(0)
})
it('makes whenIdle() wait for release without spinning on a settled promise', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const machine = agent as Agent & { done: Promise<void> }
let backing = machine.done
let reads = 0
Object.defineProperty(agent, 'done', {
configurable: true,
get(): Promise<void> {
reads += 1
return backing
},
set(value: Promise<void>) {
backing = value
},
})
const release = agent.reserveTurnAdmission()
prompt(agent, 'waiting for the reservation')
let settled = false
const idle = agent.whenIdle().then(() => { settled = true })
for (let tick = 0; tick < 5; tick += 1) {
await new Promise<void>((resolve) => { setTimeout(resolve, 1) })
}
expect(settled).toBe(false)
expect(reads).toBeLessThanOrEqual(2)
release?.()
await idle
expect(settled).toBe(true)
expect(adapter.requests).toHaveLength(1)
})
it('resolves whenIdle() after release with nothing queued', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const release = agent.reserveTurnAdmission()
let settled = false
const idle = agent.whenIdle().then(() => { settled = true })
await new Promise<void>((resolve) => { setTimeout(resolve, 5) })
expect(settled).toBe(false)
release?.()
await idle
expect(agent.status).toBe('idle')
})
it('lets cancellation discard held prompts and keeps the boundary quiet', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
const inbox = recordInbox(ctx)
const release = agent.reserveTurnAdmission()
prompt(agent, 'discarded while held')
agent.cancel({ kind: 'user' })
expect(inbox.events).toEqual([
'enqueue:queued:discarded while held',
'discard:discarded while held',
])
expect(inbox.discarded).toEqual(inbox.enqueued)
expect(inbox.dequeued).toEqual([])
release?.()
await agent.whenIdle()
expect(adapter.requests).toHaveLength(0)
expect(agent.session.events).toHaveLength(0)
})
it('disposes the agent without waiting for the reservation to be released', async () => {
const adapter = new MockAdapter([])
const ctx = await harness(adapter)
const handle = await ctx.agents.create({
sessionId: SessionId('a1'),
agentOptions: { provider: 'mock', model: 'mock' },
})
const { agent } = handle
const release = agent.reserveTurnAdmission()
prompt(agent, 'discarded by disposal')
await handle.dispose()
expect(ctx.agents.list()).toEqual([])
expect(adapter.requests).toHaveLength(0)
release?.()
})
})