refactor(agent): address inbox mutations by message id

This commit is contained in:
_Kerman
2026-08-04 13:10:22 +08:00
parent a90a1645aa
commit 0ac95437b4
20 changed files with 76 additions and 47 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/README.md
README.md: e1f1b121787645930fa9c41b3d0d5ee9880ef4ad
README.zh.md: becc0ae299269e9982d6a63d7a2df966b999cfa9
README.md: e47356370b23abfd8790a5210a6e11d4e7505627
README.zh.md: a2629732a04051e6b39642c190a2e3a687f850d9

View File

@@ -62,7 +62,7 @@ Turn and step boundaries and the model token stream are durable `session/event`
The handle every plugin programs against:
- `agent.inbox` — the agent-owned projection of durable `agent/inbox/spliced` events. `nextTurn` and `nextStep` expose pending `UserMessage` values. `append`, `prepend`, `update`, `remove`, `clear`, and `splice` mutate them; ordinary removals and `clear()` are durable cancellations and emit `agent/inbox/discarded`. `claim(target)` atomically removes the next proposed batch with pure deletion splices; the loop then emits `agent/inbox/claimed`. `MessageId` is the only occurrence identity and must remain unique while pending.
- `agent.inbox` — the agent-owned projection of durable `agent/inbox/spliced` events. `nextTurn` and `nextStep` expose pending `UserMessage` values. `append`, `prepend`, `replace`, `remove`, `clear`, and `splice` mutate them; `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists. Replacement may change identity and publishes the old message as discarded followed by the new message as inserted. Ordinary removals and `clear()` are durable cancellations and emit `agent/inbox/discarded`. `claim(target)` atomically removes the next proposed batch with pure deletion splices; the loop then emits `agent/inbox/claimed`. `MessageId` is the only occurrence identity and must remain unique while pending.
- `agent.followup(message)` — queue an ordinary `next-turn` message and wake the driver. It returns no completion handle; the message id identifies inbox insertion, claim, and discard facts, not a later output or `turn/end`.
- `agent.steer(message)` — queue waking `next-step` input. An idle driver schedules a turn; collecting and running drivers consume it at their next step boundary.
- `agent.inject(message)` — queue non-waking `next-step` context. A collecting or running driver claims it at the nearest later pre-step boundary; an idle driver leaves it pending until `followup()` or `steer()` wakes the driver. It may miss a request whose pre-step already claimed its batch.

View File

@@ -62,7 +62,7 @@ inbox 的实时通知刻意采用逐消息的最小载荷:`agent/inbox/inserte
每个插件面向的 handle
- `agent.inbox`agent 所拥有的持久 `agent/inbox/spliced` 事件投影。`nextTurn``nextStep` 暴露待处理的 `UserMessage` 值。`append``prepend``update``remove``clear``splice` 用于变更队列;普通删除和 `clear()` 都是持久取消,并发出 `agent/inbox/discarded``claim(target)` 通过纯删除 splice 原子移除下一个候选批次,随后由循环发出 `agent/inbox/claimed``MessageId` 是唯一的入队项标识,在消息待处理期间必须保持唯一。
- `agent.inbox`agent 所拥有的持久 `agent/inbox/spliced` 事件投影。`nextTurn``nextStep` 暴露待处理的 `UserMessage` 值。`append``prepend``replace``remove``clear``splice` 用于变更队列;`replace(messageId, newMessage)``remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都是持久取消,并发出 `agent/inbox/discarded``claim(target)` 通过纯删除 splice 原子移除下一个候选批次,随后由循环发出 `agent/inbox/claimed``MessageId` 是唯一的入队项标识,在消息待处理期间必须保持唯一。
- `agent.followup(message)`:将一条普通 `next-turn` 消息排队并唤醒驱动器。它不返回完成 handle消息 id 标识 inbox 的插入、领取与丢弃事实,而不标识之后的输出或 `turn/end`
- `agent.steer(message)`:将会唤醒的 `next-step` 输入排队。空闲驱动器会调度一个轮次collecting 和 running 驱动器会在各自的下一步骤边界消费该输入。
- `agent.inject(message)`:将不会唤醒的 `next-step` 上下文排队。collecting 或 running 驱动器会在最近的后续 pre-step 边界领取它idle 驱动器则会让它保持待处理,直至 `followup()``steer()` 唤醒驱动器。若某次请求的 pre-step 已经领取完批次,它可能赶不上该请求。

View File

@@ -95,30 +95,30 @@ export class Inbox {
}
/**
* Replace one pending message in place and durably record the mutation.
* @param target - pending list containing the message.
* @param messageId - identity of the message to replace.
* Replace one pending message in place, possibly changing its identity. A
* successful replacement publishes the old message as discarded and the new
* message as inserted.
* @param messageId - identity of the pending message to replace.
* @param newMessage - replacement message.
* @returns whether the message was still pending.
* @throws if the replacement duplicates another pending message identity.
*/
update(target: InboxTarget, messageId: MessageId, newMessage: UserMessage): boolean {
const index = this.state[target].findIndex(message => message.id === messageId)
if (index < 0) return false
this.splice(target, index, 1, [newMessage])
replace(messageId: MessageId, newMessage: UserMessage): boolean {
const location = this.locate(messageId)
if (location === undefined) return false
this.splice(location.target, location.index, 1, [newMessage])
return true
}
/**
* Remove one pending message and durably record its cancellation.
* @param target - pending list containing the message.
* @param messageId - identity of the message to remove.
* @param messageId - identity of the pending message to remove.
* @returns whether the message was still pending.
*/
remove(target: InboxTarget, messageId: MessageId): boolean {
const index = this.state[target].findIndex(message => message.id === messageId)
if (index < 0) return false
this.splice(target, index, 1, [])
remove(messageId: MessageId): boolean {
const location = this.locate(messageId)
if (location === undefined) return false
this.splice(location.target, location.index, 1, [])
return true
}
@@ -142,6 +142,15 @@ export class Inbox {
return this.mutate(target, start, deleteCount, inserted, true)
}
/** Locate one pending identity across both owned lists. */
private locate(messageId: MessageId): { target: InboxTarget; index: number } | undefined {
for (const target of ['next-turn', 'next-step'] as const) {
const index = this.state[target].findIndex(message => message.id === messageId)
if (index >= 0) return { target, index }
}
return undefined
}
/** Commit one normalized mutation and publish its live notifications. */
private mutate(
target: InboxTarget,

View File

@@ -49,25 +49,45 @@ describe('Inbox', () => {
.toThrow('invalid persisted inbox splice at session seq 0')
})
it('updates a pending message by identity and reports a missing identity', () => {
const session = new Session(SessionId('update-inbox'))
const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {} })
it('replaces a pending message by identity across both lists', () => {
const session = new Session(SessionId('replace-inbox'))
const inserted: UserMessage[] = []
const discarded: UserMessage[] = []
const inbox = new Inbox(session, {
inserted: message => void inserted.push(message),
discarded: message => void discarded.push(message),
})
const original = createUserMessage({
content: [{ type: 'text', text: 'original' }],
source: { kind: 'user' },
})
const replacement = freezeMessage({
...original,
const nextStep = createUserMessage({
content: [{ type: 'text', text: 'step' }],
source: { kind: 'user' },
})
const replacement = createUserMessage({
content: [{ type: 'text', text: 'replacement' }],
source: { kind: 'user' },
})
const editedStep = freezeMessage({
...nextStep,
content: [{ type: 'text', text: 'edited step' }],
})
inbox.append('next-turn', original)
inbox.append('next-step', nextStep)
expect(inbox.update('next-turn', createUserMessage({
expect(inbox.replace(createUserMessage({
content: [{ type: 'text', text: 'missing' }],
source: { kind: 'user' },
}).id, replacement)).toBe(false)
expect(inbox.update('next-turn', original.id, replacement)).toBe(true)
expect(inbox.replace(original.id, replacement)).toBe(true)
expect(inbox.replace(nextStep.id, editedStep)).toBe(true)
expect(inbox.nextTurn).toEqual([replacement])
expect(inbox.nextStep).toEqual([editedStep])
expect(discarded).toEqual([original, nextStep])
expect(inserted).toEqual([original, nextStep, replacement, editedStep])
expect(() => { inbox.replace(editedStep.id, replacement) })
.toThrow(`message "${replacement.id}" is already pending`)
})
it('normalizes splice coordinates, rejects duplicate identities, and reports missing removals', () => {
@@ -85,7 +105,7 @@ describe('Inbox', () => {
inbox.splice('next-turn', Number.NaN, Number.NaN, [first, second])
expect(inbox.nextTurn).toEqual([first, second])
expect(inbox.splice('next-turn', -1, 1, [])).toEqual([second])
expect(inbox.remove('next-turn', second.id)).toBe(false)
expect(inbox.remove(second.id)).toBe(false)
expect(() => { inbox.append('next-step', first) }).toThrow(`message "${first.id}" is already pending`)
})