refactor(agent): replace the per-step advice seam with agent/session-prefix
Review discussion converged on the industry shape (Claude Code caches user context per conversation; Codex separates initial context from diffs; Kimi appends at continuation boundaries to protect prompt caching): stable openers belong in a compose-once prefix, mid-session changes belong in append-only history — not in a per-request slot. agent/session-prefix fires ONCE per loop instance, lazily on its first request-building step: the composed Message[] is deep-frozen, cached on the transmission bookkeeping, recorded as EpochHeader.messagePrefix on the anchoring 'initial'/'resume' snapshot, and reused verbatim for every request the instance sends — prefix stability is structural, not a producer discipline, and a resume recomposes with attributable drift. The request is messagePrefix + boundary snapshot. The per-step RequestAdvice/RequestAdviceContext surface and the messageSuffix header field are dropped: the tail slot had no consumer, and every current update pattern (new AGENTS.md discovered, memory update, skills change) routes through the existing append-only history channels — inject(), tools/post-execute additionalContext, prompt-submit additionalContext — each paid once and prefix-cached thereafter. The messagePrefix delta arm stays for codec totality; the loop never produces one in practice.
This commit is contained in:
@@ -45,7 +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-advice` — contribute request-ONLY messages around the derived history: a frozen empty `RequestAdvice` 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/session-prefix` — compose the session prefix: request-only messages placed in front of the ENTIRE derived history on every request. Fired ONCE per loop instance, lazily on its first request; the composed result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the anchoring `request/header` snapshot, and reused verbatim afterwards — the prefix cannot change mid-session, so the provider prefix cache holds by construction (resume = a new instance = a recompose, attributably anchored by its `'resume'` snapshot). The home for session-stable openers that must not become durable history (a skills catalog, an AGENTS.md digest); `deriveMessages()` never returns it. Content that CHANGES mid-session belongs in the append-only history channels instead — `agent.inject()`, `tools/post-execute` `additionalContext`, prompt-submit `additionalContext` — each a durable `context/message` paid once and prefix-cached thereafter
|
||||
- `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,7 @@
|
||||
* 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/request-advice`/`agent/step-result`/
|
||||
* `agent/request`/`agent/session-prefix`/`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`/
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
/** Identifies one live agent in the registry. */
|
||||
export type AgentId = Branded<'AgentId'>
|
||||
@@ -155,54 +155,6 @@ export type ContinuationDecision =
|
||||
| { action: 'stop' }
|
||||
| { action: 'continue'; reason?: HookContext }
|
||||
|
||||
/**
|
||||
* The request-only ADVICE an `agent/request-advice` waterfall listener weaves
|
||||
* around the derived history of ONE LLM request — advice in both senses:
|
||||
* advisory content for the model, attached before/after the join point like
|
||||
* AOP advice, never modifying the history itself. In
|
||||
* `GenerateOptions.messages` the `before` messages sit in front of the ENTIRE
|
||||
* derived history (directly after the provider's system slot) and the `after`
|
||||
* messages follow its last message (the newest user prompt on a turn's first
|
||||
* step, the previous step's tool results afterwards). Advice is NOT session
|
||||
* state — nothing here enters the session log as durable history,
|
||||
* `Session.deriveMessages()` never returns it, and the next step recomputes
|
||||
* it 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 RequestAdvice {
|
||||
/** Before-advice: messages placed ahead of the entire derived history. */
|
||||
before: Message[]
|
||||
/** After-advice: messages placed after the derived history's last message. */
|
||||
after: Message[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only facts about the request an `agent/request-advice` 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 RequestAdviceContext {
|
||||
/** 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
|
||||
@@ -417,7 +369,7 @@ declare module 'cordis' {
|
||||
* 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`, or
|
||||
* header-logged request-only messages via {@link agent/request-advice}
|
||||
* the header-logged session prefix via {@link agent/session-prefix}
|
||||
* — 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
|
||||
@@ -434,47 +386,38 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/request'(agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise<LlmCallConfig>): Promise<LlmCallConfig>
|
||||
/**
|
||||
* Waterfall: weave request-ONLY advice around the derived history — a
|
||||
* {@link RequestAdvice} whose `before` messages sit in front of the
|
||||
* ENTIRE boundary snapshot in `GenerateOptions.messages` and whose
|
||||
* `after` messages follow its last message. 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.
|
||||
* Waterfall: compose the SESSION PREFIX — request-only messages placed in
|
||||
* front of the ENTIRE derived history (directly after the provider's
|
||||
* system slot) on every request this loop instance sends. Fired ONCE per
|
||||
* loop instance, lazily on its first request-building step; the composed
|
||||
* result is deep-frozen, recorded as `EpochHeader.messagePrefix` on the
|
||||
* instance's anchoring `'initial'`/`'resume'` header snapshot, and reused
|
||||
* verbatim for every subsequent request — never recomputed mid-session,
|
||||
* so the provider prefix cache holds by construction (a process restart
|
||||
* or `ctx.agents.resume()` is a new instance: it recomposes, and any
|
||||
* drift lands attributably on the `'resume'` snapshot).
|
||||
*
|
||||
* The seed is frozen and empty; a contributing listener returns a NEW
|
||||
* {@link RequestAdvice} 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 RequestAdvice} without it to short-circuit.
|
||||
* This is the home for session-stable openers the model must always see
|
||||
* but that must NOT become durable history — a skills catalog, an
|
||||
* AGENTS.md digest, a workspace baseline: `Session.deriveMessages()`
|
||||
* never returns the prefix, and the header events are its only durable
|
||||
* record, so the request stays reconstructable from the log. Content
|
||||
* that CHANGES mid-session belongs in the append-only history channels
|
||||
* instead — `agent.inject()`, a `tools/post-execute` decision's
|
||||
* `additionalContext`, prompt-submit `additionalContext` — each a
|
||||
* durable `context/message` paid once and prefix-cached thereafter.
|
||||
*
|
||||
* 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 advice - the frozen empty seed; return an extended replacement to contribute.
|
||||
* @param context - read-only request facts ({@link RequestAdviceContext}).
|
||||
* The seed is a frozen empty list; a contributing listener returns a NEW
|
||||
* array extending `await next()` (`[...prefix, mine]` — never an in-place
|
||||
* push), so contributions compose across plugins in registration order
|
||||
* and compose deterministically for a fixed plugin set. Call `next()` to
|
||||
* delegate, or return a list without it to short-circuit.
|
||||
* @param agent - the agent whose session prefix is being composed.
|
||||
* @param prefix - the frozen empty seed; return an extended replacement to contribute.
|
||||
* @param signal - aborts in-flight listener work (e.g. a discovery scan) when the step is torn down.
|
||||
* @mode waterfall
|
||||
*/
|
||||
'agent/request-advice'(agent: Agent, turn: number, step: number, advice: RequestAdvice, context: RequestAdviceContext, next: () => Promise<RequestAdvice>): Promise<RequestAdvice>
|
||||
'agent/session-prefix'(agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
|
||||
/**
|
||||
* Waterfall: post-process the assembled assistant {@link Message} before
|
||||
* tool dispatch (validation, content rewriting, …).
|
||||
|
||||
Reference in New Issue
Block a user