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:
@@ -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 } : {},
|
||||
|
||||
Reference in New Issue
Block a user