feat(agent): add the agent/request-messages request-only message seam
A new waterfall near request construction lets plugins contribute
request-ONLY messages framing the derived history: RequestMessages
{ before, after } with a frozen empty seed, fired inside the open step
after the agent/request config waterfall, so the step/start boundary
snapshot and its same-sync-frame invariant are untouched. The request
becomes messagePrefix + boundary snapshot + messageSuffix.
Contributions never enter session history — deriveMessages() is
unchanged — so the request header is their durable record:
EpochHeader gains messagePrefix/messageSuffix (canonical absence for
empty arrays), request/header-delta replaces either array whole with
an empty array encoding the transition back to absence, and the
dev-mode reconstruction cross-check now expects the folded header's
framing around the boundary derivation.
This is the seam for per-request advisory context that must be
model-visible now without becoming durable history (a skills catalog,
an environment reminder), keeping the base system prompt
workspace-independent and provider prefix caches stable. The docs
carry the channel cost model: session-frozen content belongs in
before, low-frequency change notices belong in durable history via
inject() (paid once, prefix-cached thereafter), and after is reserved
for small frequently-refreshed state snapshots re-paid on every
request they ride. No shipped producer yet, so ACP snapshot fixtures
are byte-identical.
This commit is contained in:
@@ -59,8 +59,10 @@ forever:
|
||||
boundary = session.deriveMessages() ⟵ reconstruction boundary: same sync frame,
|
||||
session('step/start') strictly before step/start
|
||||
config = waterfall agent/request ⟵ frozen seed; return a replacement to switch
|
||||
reqMsgs = waterfall agent/request-messages ⟵ request-only before/after messages; recorded
|
||||
on the header, never session history
|
||||
session('request/header'[-delta]) ⟵ the header event this request owes the log
|
||||
stream llm.stream(freeze({header..., messages: boundary})) → session('assistant/chunk')
|
||||
stream llm.stream(freeze({header..., messages: before+boundary+after})) → session('assistant/chunk')
|
||||
message = waterfall agent/step-result
|
||||
session('assistant/message')
|
||||
each tool-call: session('tool/call')
|
||||
@@ -84,7 +86,7 @@ Cancellation: `agent.cancel()` is the single public stop primitive — it clears
|
||||
### What is NOT here
|
||||
|
||||
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
|
||||
- Hooks: `agent/session-start`, `agent/prompt-submit`, `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`
|
||||
- Hooks: `agent/session-start`, `agent/prompt-submit`, `agent/pre-step`, `agent/request`, `agent/request-messages`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`
|
||||
- Compaction: `agent/pre-step`
|
||||
- Sandbox, permission, plan mode: `tools/pre-execute` (deny/ask gate), `tools/post-execute`
|
||||
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while child streaming/progress and background/poll collection remain deferred.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import type { Context } from 'cordis'
|
||||
import type { FinishReason, GenerateOptions, LlmCallConfig, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { BlockAssembler, HarnessError, deepFreeze } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContinuationDecision, HookContext, PromptDecision, RequestMessages } from '@deepseek-ai/dsh-agent'
|
||||
import { canonicalHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
|
||||
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
|
||||
@@ -161,9 +161,11 @@ export interface LoopHandle {
|
||||
* boundary = session.deriveMessages() ⟵ the reconstruction boundary: snapshot in the
|
||||
* session('step/start') same sync frame, strictly before step/start
|
||||
* config = waterfall agent/request(config) ⟵ frozen seed; a returned replacement switches
|
||||
* reqMsgs = waterfall agent/request-messages ⟵ request-only before/after messages; logged on
|
||||
* the header, never session history
|
||||
* session('request/header'|'request/header-delta') ⟵ the header event this request owes the
|
||||
* log (initial/resume anchor, delta, fallback)
|
||||
* req = freeze({header..., messages: boundary, sessionId, signal})
|
||||
* req = freeze({header..., messages: before+boundary+after, sessionId, signal})
|
||||
* stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks, frozen req)
|
||||
* session('assistant/chunk')
|
||||
* msg = waterfall agent/step-result ⟵ BEFORE the log append, so the
|
||||
@@ -671,11 +673,11 @@ function drainSteering(agent: ReactLoopAgent, turn: number): boolean {
|
||||
}
|
||||
|
||||
/** One step: build the request from the boundary snapshot + the step's
|
||||
* header → log the header event the request owes → stream model → record →
|
||||
* execute tools. The caller assembles the system prompt, fires the
|
||||
* `agent/pre-step` seam, snapshots the derivation, and opens the step BEFORE
|
||||
* calling this, so `boundaryMessages` is exactly the surface prefix at
|
||||
* step/start and already reflects any compaction. */
|
||||
* header → collect request-only messages → log the header event the request
|
||||
* owes → stream model → record → execute tools. The caller assembles the
|
||||
* system prompt, fires the `agent/pre-step` seam, snapshots the derivation,
|
||||
* and opens the step BEFORE calling this, so `boundaryMessages` is exactly
|
||||
* the surface prefix at step/start and already reflects any compaction. */
|
||||
async function runStep(
|
||||
ctx: Context,
|
||||
agent: ReactLoopAgent,
|
||||
@@ -715,22 +717,43 @@ async function runStep(
|
||||
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
|
||||
}
|
||||
|
||||
// Collect request-ONLY messages: `before` contributions precede the boundary
|
||||
// snapshot in the request, `after` contributions follow it. They are not
|
||||
// session history — the header event below is their only durable record
|
||||
// (EpochHeader.messagePrefix/messageSuffix), which keeps the request a pure
|
||||
// function of the log. The frozen empty seed serves both the listener chain
|
||||
// and the no-listener fallback: a contribution is a RETURNED extension of
|
||||
// `await next()`, never an in-place push. Fired AFTER the boundary snapshot,
|
||||
// so a listener's session append lands past the boundary and joins the NEXT
|
||||
// request — the same window rule as the `agent/request` waterfall.
|
||||
const emptyRequestMessages: RequestMessages = deepFreeze({ before: [], after: [] })
|
||||
const requestMessages = await ctx.waterfall(
|
||||
'agent/request-messages', agent, turn, step, emptyRequestMessages,
|
||||
{ system, assembly, boundaryMessages, signal },
|
||||
() => Promise.resolve(emptyRequestMessages),
|
||||
)
|
||||
|
||||
// The request header (the log's request/header* vocabulary): canonical form,
|
||||
// recorded before dispatch so the log always explains the request.
|
||||
// recorded before dispatch so the log always explains the request —
|
||||
// including the request-only messages, which no other event carries.
|
||||
const header = canonicalHeader({
|
||||
config,
|
||||
...system ? { system } : {},
|
||||
...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
|
||||
...requestMessages.before.length > 0 ? { messagePrefix: requestMessages.before } : {},
|
||||
...requestMessages.after.length > 0 ? { messageSuffix: requestMessages.after } : {},
|
||||
})
|
||||
recordRequestHeader(session, transmission, header)
|
||||
|
||||
// Build and freeze: the request is a pure function of (boundary snapshot,
|
||||
// logged header) — llm/stream listeners and adapters read it, mutation
|
||||
// throws. sessionId + frozen is the loop-built marker the dev invariant
|
||||
// keys on.
|
||||
// keys on. Message order: header.messagePrefix, then the boundary snapshot,
|
||||
// then header.messageSuffix — the reconstruction equation the invariant
|
||||
// recomputes.
|
||||
const request: GenerateOptions = deepFreeze({
|
||||
model: header.config.model,
|
||||
messages: boundaryMessages,
|
||||
messages: [...header.messagePrefix ?? [], ...boundaryMessages, ...header.messageSuffix ?? []],
|
||||
...header.system !== undefined ? { system: header.system } : {},
|
||||
...header.tools !== undefined ? { tools: header.tools } : {},
|
||||
...header.config.temperature !== undefined ? { temperature: header.config.temperature } : {},
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { foldRequestHeader, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry, {
|
||||
AgentId,
|
||||
type ContinuationDecision,
|
||||
type PromptDecision,
|
||||
type RequestMessages,
|
||||
type SessionStartSource,
|
||||
} from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
|
||||
@@ -310,6 +311,145 @@ describe('agent/session-start', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent/request-messages (RequestMessages)', () => {
|
||||
it('frames the derived history: before precedes it, after follows it, and the header records both', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const reminder: Message = { role: 'user', content: [{ type: 'text', text: '<system-reminder>catalog</system-reminder>' }] }
|
||||
const trailer: Message = { role: 'user', content: [{ type: 'text', text: 'trailing note' }] }
|
||||
ctx.on('agent/request-messages', async (_agent, _turn, _step, _messages, _context, next): Promise<RequestMessages> => {
|
||||
const result = await next()
|
||||
return { before: [...result.before, reminder], after: [...result.after, trailer] }
|
||||
})
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The request carries before + derived history + after, in that order…
|
||||
const request = adapter.requests[0]!
|
||||
expect(request.messages).toEqual([
|
||||
reminder,
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
||||
trailer,
|
||||
])
|
||||
// …the header event is their durable record…
|
||||
const headerEvent = events(agent).find(e => e.type === 'request/header')
|
||||
expect(headerEvent?.type === 'request/header' && headerEvent.data.header.messagePrefix).toEqual([reminder])
|
||||
expect(headerEvent?.type === 'request/header' && headerEvent.data.header.messageSuffix).toEqual([trailer])
|
||||
// …and they never become session history.
|
||||
expect(agent.session.deriveMessages()).toEqual([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hi' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'ok' }] },
|
||||
])
|
||||
})
|
||||
|
||||
it('contributions compose across listeners and see the read-only request facts', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
const seen: { system: string; boundaryRoles: string[]; sectionCount: number }[] = []
|
||||
ctx.on('agent/request-messages', async (_agent, _turn, _step, _messages, context, next): Promise<RequestMessages> => {
|
||||
const result = await next()
|
||||
seen.push({
|
||||
system: context.system,
|
||||
boundaryRoles: context.boundaryMessages.map(m => m.role),
|
||||
sectionCount: context.assembly.sections.length,
|
||||
})
|
||||
return { before: [{ role: 'user', content: [{ type: 'text', text: 'first' }] }, ...result.before], after: result.after }
|
||||
})
|
||||
ctx.on('agent/request-messages', async (_agent, _turn, _step, _messages, _context, next): Promise<RequestMessages> => {
|
||||
const result = await next()
|
||||
return { before: [...result.before, { role: 'user', content: [{ type: 'text', text: 'second' }] }], after: result.after }
|
||||
})
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Registration order composes: the first listener runs last on the way
|
||||
// out (waterfall), so its prepend lands first.
|
||||
const texts = adapter.requests[0]!.messages.map(m => m.content[0]?.type === 'text' ? m.content[0].text : '')
|
||||
expect(texts).toEqual(['first', 'second', 'hi'])
|
||||
// The context carried the request facts: the rendered system prompt, the
|
||||
// boundary snapshot (exactly the drained user prompt), and the assembly.
|
||||
expect(seen).toHaveLength(1)
|
||||
expect(seen[0]!.boundaryRoles).toEqual(['user'])
|
||||
expect(typeof seen[0]!.system).toBe('string')
|
||||
})
|
||||
|
||||
it('with no contributions the header omits both fields and the request is the bare derivation', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
// A listener that delegates without contributing — the canonical no-op.
|
||||
ctx.on('agent/request-messages', async (_agent, _turn, _step, _messages, _context, next) => next())
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const headerEvent = events(agent).find(e => e.type === 'request/header')
|
||||
expect(headerEvent?.type === 'request/header' && 'messagePrefix' in headerEvent.data.header).toBe(false)
|
||||
expect(headerEvent?.type === 'request/header' && 'messageSuffix' in headerEvent.data.header).toBe(false)
|
||||
expect(adapter.requests[0]!.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }])
|
||||
})
|
||||
|
||||
it('the frozen seed rejects in-place mutation — a contribution is a returned extension', async () => {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let mutationError: unknown
|
||||
ctx.on('agent/request-messages', async (_agent, _turn, _step, messages, _context, next): Promise<RequestMessages> => {
|
||||
try {
|
||||
messages.before.push({ role: 'user', content: [{ type: 'text', text: 'smuggled' }] })
|
||||
} catch (error: unknown) {
|
||||
mutationError = error
|
||||
}
|
||||
return next()
|
||||
})
|
||||
|
||||
send(agent, 'hi')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(mutationError).toBeInstanceOf(TypeError)
|
||||
expect(adapter.requests[0]!.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }])
|
||||
})
|
||||
|
||||
it('a per-step contribution change is logged as a header delta, so every request stays reconstructable', async () => {
|
||||
const adapter = new MockAdapter([
|
||||
toolCallResponse('c1', 'echo', { text: 'ping' }),
|
||||
textResponse('done'),
|
||||
])
|
||||
const ctx = await harness(adapter)
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
|
||||
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
|
||||
}))
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
let step = 0
|
||||
ctx.on('agent/request-messages', async (_agent, _turn, _step, _messages, _context, next): Promise<RequestMessages> => {
|
||||
const result = await next()
|
||||
step += 1
|
||||
return { before: [...result.before, { role: 'user', content: [{ type: 'text', text: `reminder v${step}` }] }], after: result.after }
|
||||
})
|
||||
|
||||
send(agent, 'go')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(adapter.requests[0]!.messages[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'reminder v1' }] })
|
||||
expect(adapter.requests[1]!.messages[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'reminder v2' }] })
|
||||
// Step 2's changed prefix rides a request/header-delta whose fold matches
|
||||
// what the second request actually sent.
|
||||
const delta = events(agent).find(e => e.type === 'request/header-delta')
|
||||
expect(delta?.type === 'request/header-delta' && delta.data.messagePrefix).toEqual([{ role: 'user', content: [{ type: 'text', text: 'reminder v2' }] }])
|
||||
expect(foldRequestHeader(agent.session.events)?.messagePrefix).toEqual([{ role: 'user', content: [{ type: 'text', text: 'reminder v2' }] }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent/turn-continuation (ContinuationDecision)', () => {
|
||||
it('a continue decision with a reason records next-step steering in the same turn', async () => {
|
||||
const adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')])
|
||||
|
||||
@@ -45,6 +45,7 @@ Turn and step boundaries are NOT mirrored as `agent/*` emits: a consumer that ne
|
||||
- `agent/prompt-submit` — decide what happens to one drained queued message before it becomes a `user/message`: `PromptDecision` = `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (drop it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`). Maps onto Claude Code's `UserPromptSubmit`.
|
||||
- `agent/pre-step` (serial) — mutate the session surface before the step opens and history is derived (compaction). Fires after `turn/start` and before `step/start`, so a listener's appended events land outside the step.
|
||||
- `agent/request` — shape the call config before the model call: a frozen `LlmCallConfig` seed in, a replacement out (model switching, sampling overrides). Content is not shapeable here — every request is a pure function of the session log ([reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)); the loop logs whatever config the request actually uses as a `request/header*` event
|
||||
- `agent/request-messages` — contribute request-ONLY messages around the derived history: a frozen empty `RequestMessages` seed in, an extension of `await next()` out (`before` messages precede the boundary snapshot in the request, `after` messages follow it). For per-request advisory context the model must see now but that must not become durable history; the loop records the contributions on the request's `request/header*` event (`EpochHeader.messagePrefix`/`messageSuffix`), so `deriveMessages()` stays untouched and the request stays reconstructable. Cost model: contributions ride the request's uncached tail and are re-paid at full price on every request they appear in — put session-frozen content in `before` (cacheable prefix; a mid-session change busts the cache for everything after it), route low-frequency change notices through `agent.inject()` instead (paid once, prefix-cached thereafter), and reserve `after` for small, frequently refreshed state snapshots
|
||||
- `agent/step-result` — post-process the assembled assistant message before tool dispatch (validates what the log records)
|
||||
- `agent/turn-continuation` — override the continue/stop decision via `ContinuationDecision` = `{action:'stop'}` or `{action:'continue', reason?}` (a `continue` `reason` is recorded as next-step steering in the same turn — the typed `/goal` pattern). Force-continue `/loop`, force-stop budget guard.
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* consumer that wants the live transcript subscribes here.
|
||||
* - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the
|
||||
* live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/
|
||||
* `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls and
|
||||
* `agent/request`/`agent/request-messages`/`agent/step-result`/
|
||||
* `agent/turn-continuation` waterfalls and
|
||||
* the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits
|
||||
* (`agent/status`, `agent/error`, `agent/created`/
|
||||
* `agent/disposed`, `agent/queued`, `agent/session-start`)
|
||||
@@ -45,7 +46,7 @@
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
/** Identifies one live agent in the registry. */
|
||||
export type AgentId = Branded<'AgentId'>
|
||||
@@ -138,6 +139,49 @@ export type ContinuationDecision =
|
||||
| { action: 'stop' }
|
||||
| { action: 'continue'; reason?: HookContext }
|
||||
|
||||
/**
|
||||
* Request-ONLY messages an `agent/request-messages` waterfall listener
|
||||
* contributes around the derived history of ONE LLM request: `before` messages
|
||||
* precede the derived history in `GenerateOptions.messages`, `after` messages
|
||||
* follow it. They are NOT session events — nothing here enters the session log
|
||||
* as durable history, `Session.deriveMessages()` never returns them, and the
|
||||
* next step recomputes them from scratch. The loop records the non-empty
|
||||
* arrays on the request's `request/header*` event (`EpochHeader.messagePrefix`
|
||||
* / `messageSuffix`), so the request stays reconstructable from the log (the
|
||||
* reconstructability RFC). For content that must become durable conversation
|
||||
* history, use the log channels instead: `agent.inject()`, steering, or
|
||||
* prompt-submit `additionalContext`.
|
||||
*/
|
||||
export interface RequestMessages {
|
||||
/** Messages placed before the derived history in the request. */
|
||||
before: Message[]
|
||||
/** Messages placed after the derived history in the request. */
|
||||
after: Message[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only facts about the request an `agent/request-messages` listener is
|
||||
* contributing to. Everything here is already fixed when the seam fires: the
|
||||
* step is open, the boundary snapshot is taken, and the system prompt is
|
||||
* assembled — a listener uses these to DECIDE what to contribute (e.g. render
|
||||
* a workspace-dependent reminder, or skip one already present in history),
|
||||
* never to mutate them.
|
||||
*/
|
||||
export interface RequestMessagesContext {
|
||||
/** The rendered system prompt this request will carry. */
|
||||
system: string
|
||||
/** The prompt assembly the system prompt was rendered from (sections + tools). */
|
||||
assembly: PromptAssembly
|
||||
/**
|
||||
* The boundary snapshot: the derived history this request will carry between
|
||||
* `before` and `after`. A frozen snapshot — treat it as read-only; content
|
||||
* for the NEXT request flows through the log channels.
|
||||
*/
|
||||
boundaryMessages: readonly Message[]
|
||||
/** Aborts in-flight listener work when the step is torn down. */
|
||||
signal: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an agent's session lifecycle began, carried by `agent/session-start`. A
|
||||
* bridge keys its SessionStart hook's matcher on this (Claude Code's
|
||||
@@ -351,8 +395,9 @@ declare module 'cordis' {
|
||||
* ALL a listener shapes here: every request is a pure function of the
|
||||
* session log (the reconstructability RFC), so model-visible content
|
||||
* flows through the log channels — `inject()`, steering, prompt-submit
|
||||
* `additionalContext`, prompt sections via `system-prompt/assemble` —
|
||||
* never through request mutation, and the loop records whatever config
|
||||
* `additionalContext`, prompt sections via `system-prompt/assemble`, or
|
||||
* header-logged request-only messages via {@link agent/request-messages}
|
||||
* — never through request mutation, and the loop records whatever config
|
||||
* the request actually uses as a `request/header*` event before dispatch.
|
||||
* The step's messages are already snapshotted when this fires (the
|
||||
* `step/start` boundary): an `inject()` from a listener here lands in the
|
||||
@@ -367,6 +412,47 @@ declare module 'cordis' {
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
/**
|
||||
* Waterfall: contribute request-ONLY messages around the derived history —
|
||||
* a {@link RequestMessages} whose `before` messages precede the boundary
|
||||
* snapshot in `GenerateOptions.messages` and whose `after` messages follow
|
||||
* it. Fires once per step, inside the open step, after the
|
||||
* {@link agent/request} config waterfall and before the loop logs the
|
||||
* request header. This is the seam for per-request advisory context the
|
||||
* model must see NOW but that must NOT become durable history (a skills
|
||||
* catalog, an environment reminder): contributions are recorded on the
|
||||
* request's `request/header*` event (`EpochHeader.messagePrefix` /
|
||||
* `messageSuffix`) — never as session messages — so
|
||||
* `Session.deriveMessages()` stays untouched and the request remains
|
||||
* reconstructable from the log.
|
||||
*
|
||||
* The seed is frozen and empty; a contributing listener returns a NEW
|
||||
* {@link RequestMessages} extending `await next()` (spread its arrays —
|
||||
* never mutate them), so contributions compose across plugins in
|
||||
* registration order. The boundary snapshot is already taken when this
|
||||
* fires: a `session.append`/`inject()` from a listener here lands in the
|
||||
* log but joins the NEXT request — contribute through the returned value,
|
||||
* not the session. Call `next()` to delegate, or return a
|
||||
* {@link RequestMessages} without it to short-circuit.
|
||||
*
|
||||
* Pick the channel by change frequency (the cost model): a contribution
|
||||
* rides the request's uncached tail, re-tokenized at full price on EVERY
|
||||
* request it appears in — cheap only while small. Session-FROZEN content
|
||||
* belongs in `before`, where it extends the cacheable prefix at zero
|
||||
* marginal cost (but changing it mid-session invalidates the provider
|
||||
* cache for the entire history after it). A LOW-FREQUENCY change notice
|
||||
* belongs in durable history via `agent.inject()` — appended once,
|
||||
* prefix-cached thereafter. Reserve `after` for small, frequently
|
||||
* refreshed state snapshots, where a durable chain of stale copies would
|
||||
* bloat the log and mislead the model.
|
||||
* @param agent - the agent making the model call.
|
||||
* @param turn - the open turn number.
|
||||
* @param step - the step whose request this is.
|
||||
* @param messages - the frozen empty seed; return an extended replacement to contribute.
|
||||
* @param context - read-only request facts ({@link RequestMessagesContext}).
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request-messages'(agent: Agent, turn: number, step: number, messages: RequestMessages, context: RequestMessagesContext, next: () => Promise<RequestMessages>): Promise<RequestMessages>
|
||||
/**
|
||||
* Waterfall: post-process the assembled assistant {@link Message} before
|
||||
* tool dispatch (validation, content rewriting, …).
|
||||
|
||||
@@ -51,7 +51,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
### Request-header reconstruction (`request-header.ts`)
|
||||
|
||||
The `request/header` (full `EpochHeader` snapshot with a `RequestHeaderReason`) and `request/header-delta` (system line-trim / name-keyed tools delta / whole config) events make the request envelope logged session state, so every conversation request is a pure function of the log. The pure trio reconstructs it: `foldRequestHeader(events)` folds a log (or any prefix) into the header in force; `diffHeader(prev, next)` encodes a change (undefined when equal); `applyHeaderDelta(prev, delta)` replays one. Writer contract: every logged delta is round-trip-verified (`apply(prev, delta)` deep-equals the new header) with a `'fallback'` snapshot when the encoding cannot express the change (a pure tool reordering), so folding never needs error recovery on a well-formed log. `canonicalHeader` pins the one representation of absence (empty system/tools ≡ absent fields).
|
||||
The `request/header` (full `EpochHeader` snapshot with a `RequestHeaderReason`) and `request/header-delta` (system line-trim / name-keyed tools delta / whole config / whole request-only message arrays) events make the request envelope logged session state, so every conversation request is a pure function of the log. The pure trio reconstructs it: `foldRequestHeader(events)` folds a log (or any prefix) into the header in force; `diffHeader(prev, next)` encodes a change (undefined when equal); `applyHeaderDelta(prev, delta)` replays one. Writer contract: every logged delta is round-trip-verified (`apply(prev, delta)` deep-equals the new header) with a `'fallback'` snapshot when the encoding cannot express the change (a pure tool reordering), so folding never needs error recovery on a well-formed log. `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix/messageSuffix ≡ absent fields; a delta's EMPTY message array encodes the transition back to absence). `EpochHeader.messagePrefix`/`messageSuffix` are the durable record of the `agent/request-messages` waterfall's request-only contributions — the request is `messagePrefix + derived history + messageSuffix`, and `deriveMessages()` never returns them.
|
||||
|
||||
### Session event vocabulary (`types.ts`)
|
||||
|
||||
|
||||
@@ -13,14 +13,24 @@
|
||||
*/
|
||||
|
||||
import { callConfigEquals } from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmCallConfig, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmCallConfig, Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { EpochHeader, SessionEvent, SystemDelta, ToolsDelta } from './types.ts'
|
||||
|
||||
/** The `request/header-delta` payload shape: each present field amends the folded header. */
|
||||
type HeaderDelta = {
|
||||
system?: SystemDelta
|
||||
tools?: ToolsDelta
|
||||
config?: LlmCallConfig
|
||||
messagePrefix?: Message[]
|
||||
messageSuffix?: Message[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a header to canonical form: an empty system prompt and an empty
|
||||
* tool list become ABSENT fields, matching how requests are built (both
|
||||
* request-build spreads skip empty values). Diff, fold, and comparison all
|
||||
* operate on canonical headers, so "no system prompt" has exactly one
|
||||
* Normalize a header to canonical form: an empty system prompt, an empty
|
||||
* tool list, and empty request-only message arrays become ABSENT fields,
|
||||
* matching how requests are built (the request-build spreads skip empty
|
||||
* values). Diff, fold, and comparison all operate on canonical headers, so
|
||||
* "no system prompt" (and "no request-only messages") has exactly one
|
||||
* representation.
|
||||
* @param header - the header to normalize (not mutated).
|
||||
* @returns the canonical header.
|
||||
@@ -30,6 +40,8 @@ export function canonicalHeader(header: EpochHeader): EpochHeader {
|
||||
config: header.config,
|
||||
...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {},
|
||||
...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {},
|
||||
...header.messagePrefix !== undefined && header.messagePrefix.length > 0 ? { messagePrefix: header.messagePrefix } : {},
|
||||
...header.messageSuffix !== undefined && header.messageSuffix.length > 0 ? { messageSuffix: header.messageSuffix } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,37 +121,47 @@ function applyTools(prev: readonly ToolSchema[], delta: ToolsDelta): ToolSchema[
|
||||
* writer's round-trip guard runs (`applyHeaderDelta(prev, delta)` must equal
|
||||
* the intended header) and the loop runs to skip logging an unchanged header.
|
||||
* Tools compare per-schema IN ORDER (canonical JSON), so a pure reordering is
|
||||
* correctly unequal.
|
||||
* correctly unequal; request-only message arrays compare as canonical JSON
|
||||
* (both sides come from the same build path, so key order matches when the
|
||||
* values do).
|
||||
* @param a - one canonical header.
|
||||
* @param b - the other.
|
||||
* @returns whether config, system, and tools (in order) all match.
|
||||
* @returns whether config, system, tools (in order), and request-only messages all match.
|
||||
*/
|
||||
export function headerEquals(a: EpochHeader, b: EpochHeader): boolean {
|
||||
if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false
|
||||
if (!sameMessages(a.messagePrefix, b.messagePrefix) || !sameMessages(a.messageSuffix, b.messageSuffix)) return false
|
||||
const at = a.tools ?? []
|
||||
const bt = b.tools ?? []
|
||||
return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema))
|
||||
}
|
||||
|
||||
/** Canonical JSON equality over request-only message arrays; absence equals the empty array. */
|
||||
function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] | undefined): boolean {
|
||||
return JSON.stringify(a ?? []) === JSON.stringify(b ?? [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the `request/header-delta` payload between two canonical headers,
|
||||
* or undefined when they are equal. The caller MUST round-trip the result
|
||||
* ({@link applyHeaderDelta} on `prev` deep-equals `next`) before logging it —
|
||||
* the encoding cannot express every change (a pure tool reordering) — and
|
||||
* fall back to a full `request/header` snapshot when the check fails.
|
||||
* Request-only messages are replaced whole (small advisory content, not worth
|
||||
* diffing); an empty replacement array encodes the transition to "none".
|
||||
* @param prev - the folded header the log currently implies.
|
||||
* @param next - the header the next request will actually use.
|
||||
* @returns the delta payload, or undefined when nothing changed.
|
||||
*/
|
||||
export function diffHeader(
|
||||
prev: EpochHeader, next: EpochHeader,
|
||||
): { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } | undefined {
|
||||
const delta: { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig } = {}
|
||||
export function diffHeader(prev: EpochHeader, next: EpochHeader): HeaderDelta | undefined {
|
||||
const delta: HeaderDelta = {}
|
||||
if (prev.system !== next.system) delta.system = diffSystem(prev.system, next.system)
|
||||
const prevTools = prev.tools ?? []
|
||||
const nextTools = next.tools ?? []
|
||||
if (JSON.stringify(prevTools) !== JSON.stringify(nextTools)) delta.tools = diffTools(prevTools, nextTools)
|
||||
if (!callConfigEquals(prev.config, next.config)) delta.config = next.config
|
||||
if (!sameMessages(prev.messagePrefix, next.messagePrefix)) delta.messagePrefix = next.messagePrefix ?? []
|
||||
if (!sameMessages(prev.messageSuffix, next.messageSuffix)) delta.messageSuffix = next.messageSuffix ?? []
|
||||
return Object.keys(delta).length > 0 ? delta : undefined
|
||||
}
|
||||
|
||||
@@ -151,15 +173,17 @@ export function diffHeader(
|
||||
* @param delta - the logged delta payload.
|
||||
* @returns the canonical header after the delta.
|
||||
*/
|
||||
export function applyHeaderDelta(
|
||||
prev: EpochHeader, delta: { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig },
|
||||
): EpochHeader {
|
||||
export function applyHeaderDelta(prev: EpochHeader, delta: HeaderDelta): EpochHeader {
|
||||
const system = delta.system !== undefined ? applySystem(prev.system, delta.system) : prev.system
|
||||
const tools = delta.tools !== undefined ? applyTools(prev.tools ?? [], delta.tools) : prev.tools
|
||||
const messagePrefix = delta.messagePrefix ?? prev.messagePrefix
|
||||
const messageSuffix = delta.messageSuffix ?? prev.messageSuffix
|
||||
return canonicalHeader({
|
||||
config: delta.config ?? prev.config,
|
||||
...system !== undefined ? { system } : {},
|
||||
...tools !== undefined ? { tools } : {},
|
||||
...messagePrefix !== undefined ? { messagePrefix } : {},
|
||||
...messageSuffix !== undefined ? { messageSuffix } : {},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { CallId, ContentBlock, LlmCallConfig, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { CallId, ContentBlock, LlmCallConfig, Message, MessageSource, StreamChunk, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Identifies one session in the store (and its persistence artifacts). */
|
||||
export type SessionId = Branded<'SessionId'>
|
||||
@@ -177,14 +177,16 @@ export interface TodoItem {
|
||||
}
|
||||
|
||||
/**
|
||||
* The request header: everything about an LLM request besides its message
|
||||
* content — the call configuration plus the rendered system prompt and tool
|
||||
* schemas. Logged session state (the reconstructability RFC): a
|
||||
* The request header: everything about an LLM request besides its derived
|
||||
* message history — the call configuration plus the rendered system prompt,
|
||||
* tool schemas, and any request-only messages. Logged session state (the
|
||||
* reconstructability RFC): a
|
||||
* {@link SessionEventMap} `request/header` snapshot installs one, a
|
||||
* `request/header-delta` amends it, and folding those events over the log
|
||||
* (`foldRequestHeader`) reconstructs the header any request was built under.
|
||||
* Canonical form: an empty system prompt and an empty tool list are ABSENT
|
||||
* fields, matching how requests are built.
|
||||
* Canonical form: an empty system prompt, an empty tool list, and empty
|
||||
* request-only message arrays are ABSENT fields, matching how requests are
|
||||
* built.
|
||||
*/
|
||||
export interface EpochHeader {
|
||||
/** The conversation's call configuration (model + sampling scalars). */
|
||||
@@ -193,6 +195,15 @@ export interface EpochHeader {
|
||||
system?: string
|
||||
/** Assembled tool schemas; absent for a tool-less request. */
|
||||
tools?: ToolSchema[]
|
||||
/**
|
||||
* Request-only messages sent BEFORE the derived history (the
|
||||
* `agent/request-messages` waterfall's `before` contributions). Not session
|
||||
* history — `deriveMessages()` never returns them — so the header is their
|
||||
* only durable record; absent when the request carried none.
|
||||
*/
|
||||
messagePrefix?: Message[]
|
||||
/** Request-only messages sent AFTER the derived history; absent when none. */
|
||||
messageSuffix?: Message[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -350,15 +361,19 @@ export interface SessionEventMap {
|
||||
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
|
||||
/**
|
||||
* Amendment to the folded {@link EpochHeader}: at least one of a
|
||||
* {@link SystemDelta}, a {@link ToolsDelta}, or a whole replacement
|
||||
* {@link LlmCallConfig} (four scalars — not worth diffing). Appended by the
|
||||
* {@link SystemDelta}, a {@link ToolsDelta}, a whole replacement
|
||||
* {@link LlmCallConfig} (four scalars — not worth diffing), or a whole
|
||||
* replacement request-only message array (`messagePrefix`/`messageSuffix` —
|
||||
* small advisory content, replaced whole; an EMPTY array encodes the
|
||||
* transition to "none", mirroring the canonical form's absent field).
|
||||
* Appended by the
|
||||
* loop inside the step, before dispatch, when the header for this request
|
||||
* differs from the fold of the log so far; the writer verifies
|
||||
* `applyHeaderDelta(previous, delta)` reproduces the new header exactly and
|
||||
* falls back to a `'fallback'` `request/header` snapshot when it cannot, so
|
||||
* a logged delta ALWAYS round-trips. NOT a {@link SurfaceEventType}.
|
||||
*/
|
||||
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig }
|
||||
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[]; messageSuffix?: Message[] }
|
||||
}
|
||||
|
||||
export type SessionEventType = keyof SessionEventMap
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Session, SessionId, applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId, applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
|
||||
import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
const CONFIG = { model: 'm' }
|
||||
|
||||
@@ -18,6 +18,10 @@ function tool(name: string, description = 'd'): ToolSchema {
|
||||
return { name, description, parameters: { type: 'object' } }
|
||||
}
|
||||
|
||||
function msg(text: string): Message {
|
||||
return { role: 'user', content: [{ type: 'text', text }] }
|
||||
}
|
||||
|
||||
/** Round-trip helper: diff must reproduce `next` from `prev` exactly. */
|
||||
function roundTrip(prev: EpochHeader, next: EpochHeader): ReturnType<typeof diffHeader> {
|
||||
const delta = diffHeader(prev, next)
|
||||
@@ -103,6 +107,49 @@ describe('diffHeader / applyHeaderDelta', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('request-only messages (messagePrefix / messageSuffix)', () => {
|
||||
it('canonicalHeader normalizes empty arrays to absent fields', () => {
|
||||
expect(canonicalHeader({ config: CONFIG, messagePrefix: [], messageSuffix: [] })).toEqual({ config: CONFIG })
|
||||
const full = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')], messageSuffix: [msg('s')] })
|
||||
expect(full.messagePrefix).toEqual([msg('p')])
|
||||
expect(full.messageSuffix).toEqual([msg('s')])
|
||||
})
|
||||
|
||||
it('headerEquals treats absence and empty as one representation, content differences as unequal', () => {
|
||||
expect(headerEquals(canonicalHeader({ config: CONFIG }), { config: CONFIG, messagePrefix: [] })).toBe(true)
|
||||
expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG, messagePrefix: [msg('b')] })).toBe(false)
|
||||
expect(headerEquals({ config: CONFIG, messageSuffix: [msg('a')] }, { config: CONFIG })).toBe(false)
|
||||
})
|
||||
|
||||
it('replaces a changed prefix whole and leaves an untouched suffix alone', () => {
|
||||
const prev = canonicalHeader({ config: CONFIG, messagePrefix: [msg('old')], messageSuffix: [msg('keep')] })
|
||||
const next = canonicalHeader({ config: CONFIG, messagePrefix: [msg('new'), msg('more')], messageSuffix: [msg('keep')] })
|
||||
const delta = roundTrip(prev, next)
|
||||
expect(delta).toEqual({ messagePrefix: [msg('new'), msg('more')] })
|
||||
})
|
||||
|
||||
it('round-trips framing gained from a bare header and lost back to one (empty array encodes absence)', () => {
|
||||
const none = canonicalHeader({ config: CONFIG })
|
||||
const some = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')], messageSuffix: [msg('s')] })
|
||||
const gained = roundTrip(none, some)
|
||||
expect(gained).toEqual({ messagePrefix: [msg('p')], messageSuffix: [msg('s')] })
|
||||
const lost = roundTrip(some, none)
|
||||
expect(lost).toEqual({ messagePrefix: [], messageSuffix: [] })
|
||||
})
|
||||
|
||||
it('folds framing deltas over the log like any other header amendment', () => {
|
||||
const session = new Session(SessionId('fold-framing'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const first = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v1')] })
|
||||
session.append('request/header', { header: first, reason: 'initial' })
|
||||
const second = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v2')] })
|
||||
session.append('request/header-delta', diffHeader(first, second)!)
|
||||
expect(foldRequestHeader(session.events)).toEqual(second)
|
||||
session.append('request/header-delta', diffHeader(second, canonicalHeader({ config: CONFIG }))!)
|
||||
expect(foldRequestHeader(session.events)).toEqual({ config: CONFIG })
|
||||
})
|
||||
})
|
||||
|
||||
describe('foldRequestHeader', () => {
|
||||
function headerEvents(session: Session): readonly SessionEvent[] {
|
||||
return session.events
|
||||
|
||||
Reference in New Issue
Block a user