refactor(agent): scope queue actions to edit and remove
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: 86a5c0525cd0ca40cbeb7e4b1acbcefc417e811d
|
||||
README.zh.md: b128876afb0931a758b472cbac0aa2423b4f4ab0
|
||||
README.md: a1617a1ef871f61157e0d70a06d055168170dced
|
||||
README.zh.md: 6ba945a41e700331929dabb557802c14256921fb
|
||||
|
||||
@@ -57,7 +57,7 @@ The concrete `ReactLoopAgent`, its queued input, outbox, and run controls are pa
|
||||
|
||||
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.
|
||||
|
||||
Every FIFO acceptance mints an `InboxItemId` and publishes `agent/inbox/enqueue` with the complete occurrence. `updateInbox()` owns the synchronous pending-item boundary: edit freezes replacement content without changing message identity or position, remove publishes discard, and promote moves the occurrence to the head of its queued or steering FIFO; promoting queued work also makes it waking. Edit and promote publish `agent/inbox/update`. Claim publishes `agent/inbox/dequeue` and irrevocably removes the live address before prompt admission, so a racing update returns `not-found`; `cancel()` without `keepInbox` publishes `agent/inbox/discard`.
|
||||
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`.
|
||||
|
||||
### Loop lifecycle (`agent.ts`)
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ interface Config {
|
||||
|
||||
统一的 `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`,不开启轮次也不运行模型。
|
||||
|
||||
每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步待处理项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard;前移会把该项移至其 queued 或 steering FIFO 的队首,其中 queued 工作还会变为可唤醒。编辑和前移会发布 `agent/inbox/update`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新会返回 `not-found`;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。
|
||||
每次 FIFO 接受项时都会铸造一个 `InboxItemId`,并通过 `agent/inbox/enqueue` 发布完整的单次入队项。`updateInbox()` 持有同步 queued 项边界:编辑会冻结替换内容,但不改变消息标识或位置;移除会发布 discard。编辑会发布 `agent/inbox/update`;steering 项和已被认领的项会返回 `not-found`。认领操作会发布 `agent/inbox/dequeue`,并在提示词接纳前不可逆地移除实时寻址标识,因此竞态中的更新无法改写持久历史;`cancel()` 在不带 `keepInbox` 时会发布 `agent/inbox/discard`。
|
||||
|
||||
### 循环生命周期(`agent.ts`)
|
||||
|
||||
|
||||
@@ -137,19 +137,14 @@ export class ReactLoopAgent implements Agent {
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/enqueue', item)
|
||||
}
|
||||
|
||||
/** Apply one synchronous mutation to a still-pending inbox occurrence. */
|
||||
/** Apply one synchronous mutation to a still-pending queued occurrence. */
|
||||
updateInbox(id: InboxItemIdType, action: InboxAction): InboxActionResult {
|
||||
const queuedIndex = this.queued.findIndex(candidate => candidate.item.id === id)
|
||||
const outboxIndex = queuedIndex === -1
|
||||
? this.outbox.findIndex(candidate => candidate.item?.id === id)
|
||||
: -1
|
||||
if (queuedIndex === -1 && outboxIndex === -1) return 'not-found'
|
||||
if (queuedIndex === -1) return 'not-found'
|
||||
|
||||
const pending = queuedIndex === -1 ? this.outbox[outboxIndex] : this.queued[queuedIndex]
|
||||
/* v8 ignore next 2 -- indices are derived from these arrays in this synchronous method. */
|
||||
if (pending === undefined || pending.item === undefined) {
|
||||
throw new Error(`agent "${this.id}" inbox index changed during synchronous update`)
|
||||
}
|
||||
const pending = this.queued[queuedIndex]
|
||||
/* v8 ignore next -- the index was resolved from this array without an async boundary. */
|
||||
if (pending === undefined) throw new Error(`agent "${this.id}" queued item disappeared during update`)
|
||||
|
||||
/* v8 ignore next -- InboxAction is a closed discriminated union; all variants are covered below. */
|
||||
switch (action.kind) {
|
||||
@@ -158,42 +153,15 @@ export class ReactLoopAgent implements Agent {
|
||||
...pending.item,
|
||||
message: freezeMessage({ ...pending.item.message, content: action.content }),
|
||||
})
|
||||
if (queuedIndex !== -1) {
|
||||
const queued = this.queued[queuedIndex]
|
||||
/* v8 ignore next -- the index was resolved from this array without an async boundary. */
|
||||
if (queued === undefined) throw new Error(`agent "${this.id}" queued item disappeared during edit`)
|
||||
this.queued[queuedIndex] = { ...queued, item }
|
||||
} else {
|
||||
const outbox = this.outbox[outboxIndex]
|
||||
/* v8 ignore next -- the index was resolved from this array without an async boundary. */
|
||||
if (outbox === undefined) throw new Error(`agent "${this.id}" steering item disappeared during edit`)
|
||||
this.outbox[outboxIndex] = { ...outbox, message: item.message, item }
|
||||
}
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/update', item, 'edit')
|
||||
this.queued[queuedIndex] = { ...pending, item }
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/update', item)
|
||||
return 'applied'
|
||||
}
|
||||
case 'remove': {
|
||||
if (queuedIndex !== -1) this.queued.splice(queuedIndex, 1)
|
||||
else this.outbox.splice(outboxIndex, 1)
|
||||
this.queued.splice(queuedIndex, 1)
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/discard', [pending.item])
|
||||
return 'applied'
|
||||
}
|
||||
case 'promote': {
|
||||
if (queuedIndex !== -1) {
|
||||
const queued = this.queued.splice(queuedIndex, 1)[0]
|
||||
/* v8 ignore next -- the index was resolved from this array without an async boundary. */
|
||||
if (queued === undefined) throw new Error(`agent "${this.id}" queued item disappeared during promotion`)
|
||||
this.queued.unshift({ item: queued.item, wakeup: true })
|
||||
this.scheduleKick()
|
||||
} else {
|
||||
const outbox = this.outbox.splice(outboxIndex, 1)[0]
|
||||
/* v8 ignore next -- the index was resolved from this array without an async boundary. */
|
||||
if (outbox === undefined) throw new Error(`agent "${this.id}" steering item disappeared during promotion`)
|
||||
this.outbox.unshift(outbox)
|
||||
}
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/inbox/update', pending.item, 'promote')
|
||||
return 'applied'
|
||||
}
|
||||
default:
|
||||
/* v8 ignore next -- InboxAction is a closed discriminated union. */
|
||||
return assertNever(action)
|
||||
|
||||
@@ -60,10 +60,9 @@ function inboxText(item: InboxItem): string {
|
||||
}
|
||||
|
||||
describe('addressable inbox operations', () => {
|
||||
it('edits in place, removes exactly one item, and promotes the next independent turn', async () => {
|
||||
it('edits in place and removes exactly one queued item', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
textResponse('first reply'),
|
||||
textResponse('promoted reply'),
|
||||
textResponse('edited reply'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -79,13 +78,13 @@ describe('addressable inbox operations', () => {
|
||||
})
|
||||
|
||||
const pending: InboxItem[] = []
|
||||
const updates: { id: string; action: string; text: string }[] = []
|
||||
const updates: { id: string; text: string }[] = []
|
||||
const discards: string[][] = []
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject === agent && inboxText(item) !== 'first') pending.push(item)
|
||||
})
|
||||
ctx.on('agent/inbox/update', (subject, item, action) => {
|
||||
if (subject === agent) updates.push({ id: item.id, action, text: inboxText(item) })
|
||||
ctx.on('agent/inbox/update', (subject, item) => {
|
||||
if (subject === agent) updates.push({ id: item.id, text: inboxText(item) })
|
||||
})
|
||||
ctx.on('agent/inbox/discard', (subject, items) => {
|
||||
if (subject === agent) discards.push(items.map(item => item.id))
|
||||
@@ -95,22 +94,16 @@ describe('addressable inbox operations', () => {
|
||||
await admission.promise
|
||||
send(agent, 'remove me')
|
||||
send(agent, 'edit me')
|
||||
send(agent, 'promote me')
|
||||
expect(pending.map(inboxText)).toEqual(['remove me', 'edit me', 'promote me'])
|
||||
expect(pending.map(inboxText)).toEqual(['remove me', 'edit me'])
|
||||
|
||||
const remove = pending[0]!
|
||||
const edit = pending[1]!
|
||||
const promote = pending[2]!
|
||||
expect(agent.updateInbox(edit.id, {
|
||||
kind: 'edit',
|
||||
content: [{ type: 'text', text: 'edited' }],
|
||||
})).toBe('applied')
|
||||
expect(agent.updateInbox(remove.id, { kind: 'remove' })).toBe('applied')
|
||||
expect(agent.updateInbox(promote.id, { kind: 'promote' })).toBe('applied')
|
||||
expect(updates).toEqual([
|
||||
{ id: edit.id, action: 'edit', text: 'edited' },
|
||||
{ id: promote.id, action: 'promote', text: 'promote me' },
|
||||
])
|
||||
expect(updates).toEqual([{ id: edit.id, text: 'edited' }])
|
||||
expect(discards).toEqual([[remove.id]])
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
@@ -121,11 +114,11 @@ describe('addressable inbox operations', () => {
|
||||
.map(event => event.type === 'user/message'
|
||||
? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
|
||||
: ''))
|
||||
.toEqual(['first', 'promote me', 'edited'])
|
||||
expect(agent.updateInbox(promote.id, { kind: 'remove' })).toBe('not-found')
|
||||
.toEqual(['first', 'edited'])
|
||||
expect(agent.updateInbox(edit.id, { kind: 'remove' })).toBe('not-found')
|
||||
})
|
||||
|
||||
it('edits, removes, and promotes steering occurrences before admission commits', async () => {
|
||||
it('does not mutate steering occurrences', async () => {
|
||||
const adapter = new MockAdapter([textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('steering-inbox-actions'), { provider: 'mock', model: 'mock' })
|
||||
@@ -137,40 +130,22 @@ describe('addressable inbox operations', () => {
|
||||
})
|
||||
|
||||
const pending: InboxItem[] = []
|
||||
const updates: { id: string; action: string; text: string }[] = []
|
||||
const discards: string[][] = []
|
||||
ctx.on('agent/inbox/enqueue', (subject, item) => {
|
||||
if (subject === agent && item.placement === 'steering') pending.push(item)
|
||||
})
|
||||
ctx.on('agent/inbox/update', (subject, item, action) => {
|
||||
if (subject === agent) updates.push({ id: item.id, action, text: inboxText(item) })
|
||||
})
|
||||
ctx.on('agent/inbox/discard', (subject, items) => {
|
||||
if (subject === agent) discards.push(items.map(item => item.id))
|
||||
})
|
||||
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
send(agent, 'admitted prompt')
|
||||
await entered.promise
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'remove me' }], source: { kind: 'user' } }))
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'edit me' }], source: { kind: 'user' } }))
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'promote me' }], source: { kind: 'user' } }))
|
||||
expect(pending.map(inboxText)).toEqual(['remove me', 'edit me', 'promote me'])
|
||||
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'keep me' }], source: { kind: 'user' } }))
|
||||
expect(pending.map(inboxText)).toEqual(['keep me'])
|
||||
|
||||
const remove = pending[0]!
|
||||
const edit = pending[1]!
|
||||
const promote = pending[2]!
|
||||
expect(agent.updateInbox(edit.id, {
|
||||
const steering = pending[0]!
|
||||
expect(agent.updateInbox(steering.id, {
|
||||
kind: 'edit',
|
||||
content: [{ type: 'text', text: 'edited' }],
|
||||
})).toBe('applied')
|
||||
expect(agent.updateInbox(remove.id, { kind: 'remove' })).toBe('applied')
|
||||
expect(agent.updateInbox(promote.id, { kind: 'promote' })).toBe('applied')
|
||||
expect(updates).toEqual([
|
||||
{ id: edit.id, action: 'edit', text: 'edited' },
|
||||
{ id: promote.id, action: 'promote', text: 'promote me' },
|
||||
])
|
||||
expect(discards).toEqual([[remove.id]])
|
||||
})).toBe('not-found')
|
||||
expect(agent.updateInbox(steering.id, { kind: 'remove' })).toBe('not-found')
|
||||
|
||||
decision.resolve({ kind: 'allow' })
|
||||
await idle
|
||||
@@ -179,7 +154,7 @@ describe('addressable inbox operations', () => {
|
||||
.map(event => event.type === 'steering/message'
|
||||
? event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
|
||||
: ''))
|
||||
.toEqual(['promote me', 'edited'])
|
||||
.toEqual(['keep me'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user