Merge remote-tracking branch 'origin/feat/send-unify' into worktree/agent-message-intents
# Conflicts: # .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml # .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md # .agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md # docs/cordis-catalog/events.md # docs/event-producer-consumer.md # packages/core/agent-loop/src/agent.ts
This commit is contained in:
@@ -33,7 +33,7 @@ The private per-session cache is keyed by `session.surface.replaceGeneration` an
|
||||
|
||||
## Surface contract
|
||||
|
||||
`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead:
|
||||
`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead:
|
||||
|
||||
1. appends `compact/start` (log-only) — acquires the lock,
|
||||
2. summarizes the range,
|
||||
|
||||
@@ -18,9 +18,9 @@ When `timeZone` is omitted, the plugin resolves the Node process's system zone o
|
||||
|
||||
## Timing semantics
|
||||
|
||||
The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one `context/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing.
|
||||
The plugin prepends an `agent/pre-step` listener. When an injection is due, it appends one injected `user/message` through `agent.inject()` before `step/start` and ordinary automatic compaction, with source `{ kind: 'plugin', plugin: 'time-context' }`. A suppressed attempt appends nothing.
|
||||
|
||||
Positive-interval scheduling scans the raw durable session events for the latest `context/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently.
|
||||
Positive-interval scheduling scans the raw durable session events for the latest `user/message` with that source, including a reading shadowed by compaction. The schedule therefore applies across turns and resumed processes without process-local cache state. It reduces append frequency and history growth but never removes an existing reading, and sessions schedule independently.
|
||||
|
||||
Step 1 measures from the latest preceding model-visible message, including the prompt that opened the turn. Later steps measure from the preceding time-context event in the same turn. Both baselines use durable session-event timestamps; backward wall-clock movement clamps elapsed time to zero. A missing first-step baseline, or a later step with no earlier same-turn reading because interval suppression skipped it, reports `unavailable`.
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Instructions from: AGENTS.md
|
||||
</system-reminder>
|
||||
```
|
||||
|
||||
Newly reached scopes use a durable raw `context/message`:
|
||||
Newly reached scopes use a durable injected `user/message` (plugin source):
|
||||
|
||||
```md
|
||||
<system-reminder>
|
||||
@@ -42,11 +42,11 @@ These instructions apply to work under `packages/app`. Use them as guidance when
|
||||
|
||||
A same-file edit starts with `Updated instructions from: <path>` and says to use the new content instead of the previously loaded content. When a candidate disappears or becomes a per-directory duplicate of an earlier candidate, the message is `Instructions removed: <path>` followed by `The previously loaded instructions from this file no longer apply.` Literal `</system-reminder>` text inside an instruction file is escaped so file content cannot close the plugin-owned frame.
|
||||
|
||||
The plugin owns the complete `<system-reminder>` framing, and every `context/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping.
|
||||
The plugin owns the complete `<system-reminder>` framing, and every injected `user/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping.
|
||||
|
||||
## State And Refresh
|
||||
|
||||
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
|
||||
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `user/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
|
||||
|
||||
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only.
|
||||
|
||||
@@ -111,7 +111,7 @@ Prefix-stable within one loop instance because the baseline is frozen. A new or
|
||||
|
||||
#### What the model sees
|
||||
|
||||
After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained raw `context/message` with the newly applicable instruction file.
|
||||
After a successful first-party filesystem call reaches a deeper directory, the next request includes one retained injected `user/message` with the newly applicable instruction file.
|
||||
|
||||
##### Additional instruction template
|
||||
|
||||
|
||||
@@ -892,8 +892,8 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
name: 'agent/inbox/discard',
|
||||
mode: 'emit',
|
||||
signature: '\'agent/inbox/discard\'(this: Scoped<Agent>, agent: Agent, messages: AgentMessage[]): void',
|
||||
jsDoc: '/**\n * `cancel()` (without `keepInbox`) dropped pending inbox items without\n * delivering them. Fires once per effective clearing call with every\n * discarded item, after `agent/cancel-requested` and before the abort.\n * @param agent - the agent whose inbox was cleared.\n * @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
summary: '`cancel()` (without `keepInbox`) dropped pending inbox items without delivering them.',
|
||||
jsDoc: '/**\n * Pending inbox items were dropped without delivering them, so every\n * enqueued id receives exactly one terminal `agent/inbox/dequeue` OR\n * `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after\n * `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop`\n * dropping pending steering (in-turn and on the post-turn late-steering\n * drain); and disposal of any still-pending items (before\n * `agent/status(\'disposed\')`). Fires once per drop with every dropped item.\n * @param agent - the agent whose inbox items were dropped.\n * @param messages - the discarded messages in FIFO order (queued then steering); never empty.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.\n * @mode emit\n */',
|
||||
summary: 'Pending inbox items were dropped without delivering them, so every enqueued id receives exactly one terminal `agent/inbox/dequeue` OR `agent/inbox/discard`.',
|
||||
},
|
||||
{
|
||||
name: 'agent/inbox/enqueue',
|
||||
|
||||
@@ -298,13 +298,14 @@ export class ReactLoopAgent implements Agent {
|
||||
/** The `next-step`/no-wakeup injection path: durable context, no FIFO, no run. */
|
||||
private injectContext(input: Extract<ResolvedAgentInput, { target: 'next-step'; wakeup: false }>): void {
|
||||
const { content, source, meta } = input
|
||||
const context = {
|
||||
// Detach and validate the payload before any append, so malformed input
|
||||
// cannot open a one-shot turn or otherwise mutate the session.
|
||||
const accepted = this.acceptContext({
|
||||
content,
|
||||
source,
|
||||
...meta !== undefined ? { meta } : {},
|
||||
}
|
||||
})
|
||||
if (isTurnOpen(this.session)) {
|
||||
const accepted = this.acceptContext(context)
|
||||
// Provider protocols require every assistant tool-call batch to be
|
||||
// followed only by its tool results. Historical interrupted batches do
|
||||
// not own new context; only the currently executing batch may defer it.
|
||||
@@ -316,23 +317,25 @@ export class ReactLoopAgent implements Agent {
|
||||
return
|
||||
}
|
||||
// No turn open: wrap the injection in a one-shot turn so every event stays
|
||||
// turn-enclosed (the durability/replay boundary is the turn).
|
||||
// turn-enclosed (the durability/replay boundary is the turn). The payload is
|
||||
// validated above, but `Session.append` can still reject a turn/start
|
||||
// pre-commit (append re-entrancy from a session/event listener, or an
|
||||
// internal-dispatch veto), so the finally owes a turn/end only when
|
||||
// turn/start actually committed.
|
||||
const turn = lastTurnNumber(this.session) + 1
|
||||
// Once turn/start enters the log, a turn/end is owed even if the message
|
||||
// append fails acceptance or pre-commit validation. The finally re-checks
|
||||
// the log and closes only a turn that actually opened; post-commit observers
|
||||
// are contained by Session and cannot create a false append failure.
|
||||
try {
|
||||
this.session.append('turn/start', { turn, trigger: { kind: 'injection', source } })
|
||||
this.session.append('user/message', context, { surfaceOp: 'append' })
|
||||
this.session.append('user/message', accepted, { surfaceOp: 'append' })
|
||||
} finally {
|
||||
// Close the turn if turn/start made it into the log. A pre-commit veto
|
||||
// must escape rather than being mistaken for a committed turn/end.
|
||||
if (isTurnOpen(this.session)) {
|
||||
this.session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
// Decide the durability checkpoint from the log: an accepted one-shot
|
||||
// turn must be flushed even when its message append was the failing step.
|
||||
// Checkpoint only an accepted one-shot turn: a turn/start rejected
|
||||
// pre-commit recorded nothing, so it owes no flush (and a spurious flush
|
||||
// would emit a phantom-turn agent/error). The payload is validated up
|
||||
// front, so a committed turn/start is always followed by its user/message.
|
||||
const turnRecorded = this.session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
|
||||
// Keep inject() synchronous: report checkpoint failures live instead of
|
||||
// rejecting the caller, and track the task so disposal still drains it.
|
||||
@@ -483,8 +486,21 @@ export class ReactLoopAgent implements Agent {
|
||||
*/
|
||||
private [stopDriver](): Promise<void> | void {
|
||||
if (this._status !== 'disposed') {
|
||||
// Snapshot any still-pending inbox items, then CLEAR and mark disposed
|
||||
// BEFORE emitting the discard — mirroring cancel()'s snapshot→clear→emit
|
||||
// order so a re-entrant send()/cancel() from a discard listener throws
|
||||
// `disposed` (or finds an empty inbox) instead of leaking or double-
|
||||
// discarding an id. `send()` emits enqueue unconditionally, so the discard
|
||||
// is unconditional too (even on an unpublished rollback) to keep every
|
||||
// enqueued id matched.
|
||||
const discarded = this.#inbox.pending()
|
||||
this.#inbox.clear()
|
||||
this._status = 'disposed'
|
||||
this.resolveDisposed()
|
||||
if (discarded.length > 0) {
|
||||
const items = discarded.map(({ message, steering }) => agentMessage(message, steering))
|
||||
agentEvents(this.loopCtx, this).emit('agent/inbox/discard', items)
|
||||
}
|
||||
// Release whenIdle waiters BEFORE the (guarded) event emit — they are
|
||||
// internal state that must settle even if a listener throws below. Each
|
||||
// waiter chains `done`, so it resolves only once the loop actually exits.
|
||||
|
||||
@@ -29,7 +29,14 @@ export interface InboxMessage {
|
||||
* @returns the live-event message for enqueue/dequeue/discard.
|
||||
*/
|
||||
export function agentMessage(message: InboxMessage, steering: boolean): AgentMessage {
|
||||
return { id: message.id, content: message.content, source: message.source, contexts: message.contexts, steering, wakeup: message.wakeup }
|
||||
// Frozen: the fused emitter passes this exact object to every listener in
|
||||
// turn, so one listener must not be able to mutate a field (`id`, `steering`,
|
||||
// `content`, …) a later listener then observes. `message` is already a frozen
|
||||
// inbox record, so its nested fields need no re-clone.
|
||||
return Object.freeze({
|
||||
id: message.id, content: message.content, source: message.source,
|
||||
contexts: message.contexts, steering, wakeup: message.wakeup,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -132,6 +132,27 @@ describe('Agent', () => {
|
||||
expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed')
|
||||
})
|
||||
|
||||
it('disposal discards still-pending inbox items so every id gets a terminal event', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
let agent!: Agent
|
||||
const discarded: string[] = []
|
||||
ctx.on('agent/inbox/discard', (subject, messages) => {
|
||||
if (subject === agent) discarded.push(...messages.map(m => m.id))
|
||||
})
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
agent = inner.agentLoop.create(SessionId('scoped'), { provider: 'mock', model: 'mock' })
|
||||
}, { inject: ['agentLoop'] }))
|
||||
|
||||
// A quiet (non-waking) item stays parked in the inbox; disposal must drop it
|
||||
// WITH a discard so its enqueued id is not left dangling forever.
|
||||
const id = agent.queue([{ type: 'text', text: 'never runs' }])
|
||||
await fiber.dispose()
|
||||
await driverDone(agent)
|
||||
|
||||
expect(discarded).toEqual([id])
|
||||
})
|
||||
|
||||
it('steer() throws after disposal', async () => {
|
||||
const adapter = new MockAdapter(['hang'])
|
||||
const ctx = await harness(adapter)
|
||||
@@ -211,24 +232,54 @@ describe('Agent', () => {
|
||||
warn.mockRestore()
|
||||
})
|
||||
|
||||
it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => {
|
||||
it('idle inject() validates its payload BEFORE opening a turn, so invalid input appends nothing', 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 })
|
||||
|
||||
// Non-serializable injected content makes Session.append throw AFTER
|
||||
// turn/start was recorded. The turn/end must still be appended (finally),
|
||||
// AND the durability checkpoint must still fire — the balanced turn is in
|
||||
// memory and a crash before the next turn/dispose would otherwise lose it.
|
||||
// Non-serializable injected content is rejected by the up-front snapshot
|
||||
// BEFORE any append (the unified send contract: invalid input throws before
|
||||
// mutating the log). No one-shot turn opens and no durability checkpoint fires.
|
||||
expect(() => {
|
||||
agent.inject([{ type: 'text', text: 'x', bad: 1n } as never], { source: { kind: 'plugin', plugin: 'p' } })
|
||||
}).toThrow(/non-JSON-serializable/)
|
||||
const types = agent.session.events.map(e => e.type)
|
||||
expect(types).toEqual(['turn/start', 'turn/end']) // balanced, no open turn
|
||||
await new Promise(r => setTimeout(r, 10)) // let the fire-and-forget flush run
|
||||
expect(flushes).toBe(1) // checkpoint fired despite the throw
|
||||
}).toThrow(/losslessly JSON-serializable/)
|
||||
expect(agent.session.events).toHaveLength(0)
|
||||
await new Promise(r => setTimeout(r, 10)) // give any (erroneous) flush a chance
|
||||
expect(flushes).toBe(0) // nothing was appended, so no checkpoint
|
||||
})
|
||||
|
||||
it('idle inject() re-entered from a session/event listener is rejected pre-commit and opens no turn', 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 })
|
||||
// Injecting from inside a session/event listener re-enters Session.append,
|
||||
// which rejects pre-commit — so turn/start never commits. The finally sees
|
||||
// no open turn (closes nothing) and no recorded turn (no checkpoint), and
|
||||
// the reentrant throw is contained by Session's post-commit dispatch.
|
||||
// Fire on turn/end: at that instant the outer one-shot turn is closed (no
|
||||
// turn open), so the reentrant inject takes the idle one-shot-turn path and
|
||||
// its turn/start append re-enters Session and is rejected pre-commit.
|
||||
let reentered = false
|
||||
ctx.on('session/event', (_s, event) => {
|
||||
if (!reentered && event.type === 'turn/end') {
|
||||
reentered = true
|
||||
agent.inject([{ type: 'text', text: 'reentrant' }], { source: { kind: 'plugin', plugin: 'p' } })
|
||||
}
|
||||
})
|
||||
|
||||
agent.inject([{ type: 'text', text: 'outer' }], { source: { kind: 'plugin', plugin: 'p' } })
|
||||
// The outer injection's own one-shot turn is balanced; the reentrant one
|
||||
// opened no turn (its turn/start was rejected pre-commit).
|
||||
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start')
|
||||
expect(turnStarts).toHaveLength(1)
|
||||
const injected = agent.session.events.filter(e => e.type === 'user/message')
|
||||
expect(injected).toHaveLength(1) // the reentrant user/message never committed
|
||||
await new Promise(r => setTimeout(r, 10))
|
||||
expect(flushes).toBe(1) // only the outer accepted turn checkpointed
|
||||
})
|
||||
|
||||
it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => {
|
||||
@@ -278,13 +329,11 @@ describe('Agent', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// A non-serializable source makes the turn/start append throw BEFORE the
|
||||
// event is pushed (Session.append validates before push), so NO turn opens.
|
||||
// The finally's isTurnOpen() guard sees no open turn and appends nothing —
|
||||
// the log stays empty, not left with a dangling turn/start.
|
||||
// A non-serializable source is rejected by the up-front snapshot BEFORE any
|
||||
// append, so NO turn opens and the log stays empty.
|
||||
expect(() => {
|
||||
agent.inject([{ type: 'text', text: 'x' }], { source: { kind: 'plugin', plugin: 'p', bad: 1n } as never })
|
||||
}).toThrow(/non-JSON-serializable/)
|
||||
}).toThrow(/losslessly JSON-serializable/)
|
||||
expect(agent.session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import { Inbox } from '../src/inbox.ts'
|
||||
import { Inbox, agentMessage } from '../src/inbox.ts'
|
||||
|
||||
function message(text: string) {
|
||||
return { id: AgentMessageId(text), content: [{ type: 'text' as const, text }], source: { kind: 'user' as const }, contexts: [], wakeup: true }
|
||||
}
|
||||
|
||||
describe('agentMessage', () => {
|
||||
it('returns a frozen payload so a listener cannot mutate it for later listeners', () => {
|
||||
const payload = agentMessage(message('m'), false)
|
||||
expect(Object.isFrozen(payload)).toBe(true)
|
||||
expect(() => { (payload as { id: string }).id = 'mutated' }).toThrow()
|
||||
expect(payload.id).toBe(AgentMessageId('m'))
|
||||
})
|
||||
})
|
||||
|
||||
function resolverPair() {
|
||||
let r!: () => void
|
||||
const p = new Promise<void>((resolve) => { r = resolve })
|
||||
|
||||
@@ -325,11 +325,15 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/inbox/dequeue'(this: Scoped<Agent>, agent: Agent, message: AgentMessage): void
|
||||
/**
|
||||
* `cancel()` (without `keepInbox`) dropped pending inbox items without
|
||||
* delivering them. Fires once per effective clearing call with every
|
||||
* discarded item, after `agent/cancel-requested` and before the abort.
|
||||
* @param agent - the agent whose inbox was cleared.
|
||||
* @param messages - the discarded messages in FIFO order (queued then steering); empty when nothing was pending.
|
||||
* Pending inbox items were dropped without delivering them, so every
|
||||
* enqueued id receives exactly one terminal `agent/inbox/dequeue` OR
|
||||
* `agent/inbox/discard`. Emitters: `cancel()` without `keepInbox` (after
|
||||
* `agent/cancel-requested`, before the abort); a terminal `agent/turn-stop`
|
||||
* dropping pending steering (in-turn and on the post-turn late-steering
|
||||
* drain); and disposal of any still-pending items (before
|
||||
* `agent/status('disposed')`). Fires once per drop with every dropped item.
|
||||
* @param agent - the agent whose inbox items were dropped.
|
||||
* @param messages - the discarded messages in FIFO order (queued then steering); never empty.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @mode emit
|
||||
*/
|
||||
|
||||
@@ -19,7 +19,7 @@ Event-sourced same-session goal state. The service retains one current completio
|
||||
|
||||
At most one goal is current. Creation produces an active revision-one goal and arms it. A non-complete goal must be edited, transitioned, or cleared; a completed goal may be replaced by a globally fresh id. Edits retain phase, blocker reason, and activation. Pause, completion, blocking, and clear disarm activation. A block records a policy-owned lower-kebab-case code plus a normalized free-form explanation; provider limits, configured budgets, execution errors, and requests for human input all use this one durable phase rather than multiplying lifecycle states. Resume accepts a stopped phase or a disarmed active goal only while the configured round cap has remaining capacity; it clears any former blocker reason. An active armed goal rejects the redundant operation.
|
||||
|
||||
Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The `context/message` content projected verbatim to the model, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward.
|
||||
Every non-clear mutation appends a complete versioned snapshot through `agent.inject()`; clear appends a revisioned tombstone. The round-zero `user/message` content projected verbatim to the model, its `{ kind: 'goal' }` source, and its metadata must agree exactly. Replay rejects malformed shapes, source/content drift, discontinuous revisions, illegal lifecycle transitions, non-monotonic per-goal timestamps, and non-sequential goal rounds. Mutation timestamps clamp against the preceding goal update when wall time moves backward.
|
||||
|
||||
Injection may append immediately or wait in an active tool-batch FIFO. The service overlays accepted pending changes in memory and reconciles each exact payload when it enters the log, so consecutive model-tool mutations see their own latest revisions without treating an unlogged cache as durable state. Reentrant append observers see each accepted mutation exactly once, and incremental replay retains its cursor at the first corrupt event. `goal/changed` fires after the append or enqueue succeeds; listener failures are contained.
|
||||
|
||||
|
||||
@@ -30,11 +30,11 @@ The chain key is `(tool name, canonical arguments)` — canonicalization is a de
|
||||
|
||||
## Reminder delivery
|
||||
|
||||
Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as a `context/message` after the step's tool results, which the session renders as a plain synthetic user message — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array (both variants — a blocked call still gets the nudge); every entry retains its own source and metadata.
|
||||
Reminders ride the post-execute decision's `additionalContexts` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as an injected `user/message` after the step's tool results, which the session renders as a plain synthetic user message — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and prepends its reminder to the downstream decision's context array (both variants — a blocked call still gets the nudge); every entry retains its own source and metadata.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit suites drive a real agent loop against a mock adapter (no network) and cover the chain semantics above to per-file 100%. The snapshot tier owns the transcript surface: a scripted-replay scenario repeats a call five times and pins both reminder tiers (gentle at 3, detailed at 5) as `context/message`s in the ACP transcript.
|
||||
Unit suites drive a real agent loop against a mock adapter (no network) and cover the chain semantics above to per-file 100%. The snapshot tier owns the transcript surface: a scripted-replay scenario repeats a call five times and pins both reminder tiers (gentle at 3, detailed at 5) as injected `user/message`s in the ACP transcript.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
|
||||
|
||||
Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty).
|
||||
|
||||
Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks Agent Note.
|
||||
Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `user/message` is the durable evidence) — see the hooks Agent Note.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ Logged, per-agent plan collaboration state with deployment-owned guidance, direc
|
||||
|
||||
`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`.
|
||||
|
||||
`ctx.planMode.set(agent, active)` records a pending selection and flushes it inside the next turn boundary. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's optimistic selection. Prompt submission, ordinary continuation, and request-recovery retry are all covered; a changed user selection contributes one `context/message` notice when the last logged request header described the other state.
|
||||
`ctx.planMode.set(agent, active)` records a pending selection and flushes it inside the next turn boundary. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's optimistic selection. Prompt submission, ordinary continuation, and request-recovery retry are all covered; a changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state.
|
||||
|
||||
## Model and human surfaces
|
||||
|
||||
|
||||
Reference in New Issue
Block a user