2026-07-06 02:42:51 +08:00
|
|
|
/**
|
2026-07-13 23:27:00 +08:00
|
|
|
* Request-header reconstruction utilities over `request/header` snapshots and
|
|
|
|
|
* `request/header-delta` events. Writers round-trip each proposed delta and use
|
|
|
|
|
* a full snapshot when the encoding cannot represent the change.
|
2026-07-06 02:42:51 +08:00
|
|
|
* @module dsh-session/request-header
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { callConfigEquals } from '@deepseek-ai/dsh-llm'
|
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.
2026-07-07 19:42:30 +08:00
|
|
|
import type { LlmCallConfig, Message, ToolSchema } from '@deepseek-ai/dsh-llm'
|
2026-07-06 02:42:51 +08:00
|
|
|
import type { EpochHeader, SessionEvent, SystemDelta, ToolsDelta } from './types.ts'
|
|
|
|
|
|
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.
2026-07-07 19:42:30 +08:00
|
|
|
/** The `request/header-delta` payload shape: each present field amends the folded header. */
|
|
|
|
|
type HeaderDelta = {
|
|
|
|
|
system?: SystemDelta
|
|
|
|
|
tools?: ToolsDelta
|
|
|
|
|
config?: LlmCallConfig
|
|
|
|
|
messagePrefix?: Message[]
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 02:42:51 +08:00
|
|
|
/**
|
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.
2026-07-07 19:42:30 +08:00
|
|
|
* Normalize a header to canonical form: an empty system prompt, an empty
|
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.
2026-07-08 15:44:30 +08:00
|
|
|
* tool list, and an empty session prefix 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 session prefix") has exactly one representation.
|
2026-07-06 02:42:51 +08:00
|
|
|
* @param header - the header to normalize (not mutated).
|
|
|
|
|
* @returns the canonical header.
|
|
|
|
|
*/
|
|
|
|
|
export function canonicalHeader(header: EpochHeader): EpochHeader {
|
|
|
|
|
return {
|
|
|
|
|
config: header.config,
|
|
|
|
|
...header.system !== undefined && header.system.length > 0 ? { system: header.system } : {},
|
|
|
|
|
...header.tools !== undefined && header.tools.length > 0 ? { tools: header.tools } : {},
|
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.
2026-07-07 19:42:30 +08:00
|
|
|
...header.messagePrefix !== undefined && header.messagePrefix.length > 0 ? { messagePrefix: header.messagePrefix } : {},
|
2026-07-06 02:42:51 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Split a canonical (possibly absent) system prompt into lines; absence is zero lines. */
|
|
|
|
|
function systemLines(system: string | undefined): string[] {
|
|
|
|
|
return system === undefined ? [] : system.split('\n')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Join lines back into a canonical system value; zero lines is absence. */
|
|
|
|
|
function joinSystem(lines: string[]): string | undefined {
|
|
|
|
|
return lines.length === 0 ? undefined : lines.join('\n')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Compute the line-level {@link SystemDelta} between two canonical system
|
|
|
|
|
* prompts: trim the common prefix and (non-overlapping) common suffix, and
|
|
|
|
|
* carry the replacement lines between them. Deterministic and library-free;
|
|
|
|
|
* with nothing shared it degenerates to a full replacement.
|
|
|
|
|
*/
|
|
|
|
|
function diffSystem(prev: string | undefined, next: string | undefined): SystemDelta {
|
|
|
|
|
const a = systemLines(prev)
|
|
|
|
|
const b = systemLines(next)
|
|
|
|
|
let keepStart = 0
|
|
|
|
|
while (keepStart < a.length && keepStart < b.length && a[keepStart] === b[keepStart]) keepStart += 1
|
|
|
|
|
let keepEnd = 0
|
|
|
|
|
while (
|
|
|
|
|
keepEnd < a.length - keepStart &&
|
|
|
|
|
keepEnd < b.length - keepStart &&
|
|
|
|
|
a[a.length - 1 - keepEnd] === b[b.length - 1 - keepEnd]
|
|
|
|
|
) keepEnd += 1
|
|
|
|
|
return { keepStart, keepEnd, insert: b.slice(keepStart, b.length - keepEnd) }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Apply a {@link SystemDelta} to a canonical system prompt. */
|
|
|
|
|
function applySystem(prev: string | undefined, delta: SystemDelta): string | undefined {
|
|
|
|
|
const a = systemLines(prev)
|
|
|
|
|
return joinSystem([...a.slice(0, delta.keepStart), ...delta.insert, ...a.slice(a.length - delta.keepEnd)])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Canonical JSON equality for tool schemas — sound because schemas are
|
|
|
|
|
* JSON-serializable by construction and both sides come from the same
|
|
|
|
|
* assembly path, so key insertion order matches when the values do. */
|
|
|
|
|
function sameSchema(a: ToolSchema, b: ToolSchema): boolean {
|
|
|
|
|
return JSON.stringify(a) === JSON.stringify(b)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Compute the name-keyed {@link ToolsDelta} between two canonical tool lists.
|
|
|
|
|
* A pure reordering produces an empty delta — the writer's round-trip guard
|
|
|
|
|
* catches that case and records a snapshot instead.
|
|
|
|
|
*/
|
|
|
|
|
function diffTools(prev: readonly ToolSchema[], next: readonly ToolSchema[]): ToolsDelta {
|
|
|
|
|
const prevByName = new Map(prev.map(tool => [tool.name, tool]))
|
|
|
|
|
const nextNames = new Set(next.map(tool => tool.name))
|
|
|
|
|
return {
|
|
|
|
|
added: next.filter(tool => !prevByName.has(tool.name)),
|
|
|
|
|
removed: prev.filter(tool => !nextNames.has(tool.name)).map(tool => tool.name),
|
|
|
|
|
changed: next.filter((tool) => {
|
|
|
|
|
const before = prevByName.get(tool.name)
|
|
|
|
|
return before !== undefined && !sameSchema(before, tool)
|
|
|
|
|
}),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Apply a {@link ToolsDelta} to a canonical tool list: drop removed, replace changed in place, append added. */
|
|
|
|
|
function applyTools(prev: readonly ToolSchema[], delta: ToolsDelta): ToolSchema[] {
|
|
|
|
|
const removed = new Set(delta.removed)
|
|
|
|
|
const changedByName = new Map(delta.changed.map(tool => [tool.name, tool]))
|
|
|
|
|
const kept = prev
|
|
|
|
|
.filter(tool => !removed.has(tool.name))
|
|
|
|
|
.map(tool => changedByName.get(tool.name) ?? tool)
|
|
|
|
|
return [...kept, ...delta.added]
|
|
|
|
|
}
|
|
|
|
|
|
loop: every request is built from the log — boundary snapshot, header events, config-only waterfall
The loop is now transmission-stateless; a request is a pure function of
(session log, this step's rendered assembly, current AgentOptions):
- The reconstruction boundary is step/start: the messages snapshot is
taken in the same synchronous frame immediately before the step/start
append, so the request's messages are exactly the derivation over
events[0..stepStartSeq) — an inject() from an agent/request listener
(or any concurrent task) lands after the boundary and joins the NEXT
request. This changes behavior for a synchronous step/start
session/event listener that appends content (master derived after the
append, so such a listener could reach the current request):
agent/pre-step is the sanctioned seam for current-request content.
- agent/request is re-typed to config-only: (agent, turn, step,
config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes
from AgentOptions on a loop instance's first request (explicit options
beat the logged baseline — fork overrides and resume reconfiguration
stay correct) and from the log's folded header afterwards; listeners
return a replacement to switch. Content shaping through the request is
no longer expressible — model-visible content flows through the log
channels.
- recordRequestHeader appends whatever header event the request owes the
log before dispatch: an 'initial'/'resume' snapshot anchoring each
loop instance, a round-trip-verified delta on change, a 'fallback'
snapshot when the encoding cannot express it. Session.requestHeader()
is the log's incrementally-folded baseline.
- Requests are deep-frozen before dispatch (deepFreeze exempts the
AbortSignal — freezing one breaks AbortController.abort() outright);
frozen + sessionId is the loop-built marker the dev invariant keys on.
Ported from #162 and re-anchored on the log: the append-extension /
frozen-end-to-end / compaction-resend / prompt-change property tests,
plus new specs for the boundary semantics, resume anchoring, and the
end-to-end theorem (every recorded request rebuilds byte-equal from the
log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against
the real DeepSeek API. Snapshot goldens intentionally stale until the
single re-record after the compact/summary envelope lands.
2026-07-06 03:07:34 +08:00
|
|
|
/**
|
2026-07-12 03:36:43 +08:00
|
|
|
* Field-wise equality over canonical headers — the cheap comparison the writer's round-trip
|
|
|
|
|
* guard runs (`applyHeaderDelta(prev, delta)` must equal the intended header) and the loop
|
|
|
|
|
* runs to skip logging an unchanged header.
|
|
|
|
|
*
|
loop: every request is built from the log — boundary snapshot, header events, config-only waterfall
The loop is now transmission-stateless; a request is a pure function of
(session log, this step's rendered assembly, current AgentOptions):
- The reconstruction boundary is step/start: the messages snapshot is
taken in the same synchronous frame immediately before the step/start
append, so the request's messages are exactly the derivation over
events[0..stepStartSeq) — an inject() from an agent/request listener
(or any concurrent task) lands after the boundary and joins the NEXT
request. This changes behavior for a synchronous step/start
session/event listener that appends content (master derived after the
append, so such a listener could reach the current request):
agent/pre-step is the sanctioned seam for current-request content.
- agent/request is re-typed to config-only: (agent, turn, step,
config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes
from AgentOptions on a loop instance's first request (explicit options
beat the logged baseline — fork overrides and resume reconfiguration
stay correct) and from the log's folded header afterwards; listeners
return a replacement to switch. Content shaping through the request is
no longer expressible — model-visible content flows through the log
channels.
- recordRequestHeader appends whatever header event the request owes the
log before dispatch: an 'initial'/'resume' snapshot anchoring each
loop instance, a round-trip-verified delta on change, a 'fallback'
snapshot when the encoding cannot express it. Session.requestHeader()
is the log's incrementally-folded baseline.
- Requests are deep-frozen before dispatch (deepFreeze exempts the
AbortSignal — freezing one breaks AbortController.abort() outright);
frozen + sessionId is the loop-built marker the dev invariant keys on.
Ported from #162 and re-anchored on the log: the append-extension /
frozen-end-to-end / compaction-resend / prompt-change property tests,
plus new specs for the boundary semantics, resume anchoring, and the
end-to-end theorem (every recorded request rebuilds byte-equal from the
log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against
the real DeepSeek API. Snapshot goldens intentionally stale until the
single re-record after the compact/summary envelope lands.
2026-07-06 03:07:34 +08:00
|
|
|
* @param a - one canonical header.
|
|
|
|
|
* @param b - the other.
|
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.
2026-07-08 15:44:30 +08:00
|
|
|
* @returns whether config, system, tools (in order), and the session prefix all match.
|
loop: every request is built from the log — boundary snapshot, header events, config-only waterfall
The loop is now transmission-stateless; a request is a pure function of
(session log, this step's rendered assembly, current AgentOptions):
- The reconstruction boundary is step/start: the messages snapshot is
taken in the same synchronous frame immediately before the step/start
append, so the request's messages are exactly the derivation over
events[0..stepStartSeq) — an inject() from an agent/request listener
(or any concurrent task) lands after the boundary and joins the NEXT
request. This changes behavior for a synchronous step/start
session/event listener that appends content (master derived after the
append, so such a listener could reach the current request):
agent/pre-step is the sanctioned seam for current-request content.
- agent/request is re-typed to config-only: (agent, turn, step,
config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes
from AgentOptions on a loop instance's first request (explicit options
beat the logged baseline — fork overrides and resume reconfiguration
stay correct) and from the log's folded header afterwards; listeners
return a replacement to switch. Content shaping through the request is
no longer expressible — model-visible content flows through the log
channels.
- recordRequestHeader appends whatever header event the request owes the
log before dispatch: an 'initial'/'resume' snapshot anchoring each
loop instance, a round-trip-verified delta on change, a 'fallback'
snapshot when the encoding cannot express it. Session.requestHeader()
is the log's incrementally-folded baseline.
- Requests are deep-frozen before dispatch (deepFreeze exempts the
AbortSignal — freezing one breaks AbortController.abort() outright);
frozen + sessionId is the loop-built marker the dev invariant keys on.
Ported from #162 and re-anchored on the log: the append-extension /
frozen-end-to-end / compaction-resend / prompt-change property tests,
plus new specs for the boundary semantics, resume anchoring, and the
end-to-end theorem (every recorded request rebuilds byte-equal from the
log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against
the real DeepSeek API. Snapshot goldens intentionally stale until the
single re-record after the compact/summary envelope lands.
2026-07-06 03:07:34 +08:00
|
|
|
*/
|
|
|
|
|
export function headerEquals(a: EpochHeader, b: EpochHeader): boolean {
|
|
|
|
|
if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false
|
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.
2026-07-08 15:44:30 +08:00
|
|
|
if (!sameMessages(a.messagePrefix, b.messagePrefix)) return false
|
loop: every request is built from the log — boundary snapshot, header events, config-only waterfall
The loop is now transmission-stateless; a request is a pure function of
(session log, this step's rendered assembly, current AgentOptions):
- The reconstruction boundary is step/start: the messages snapshot is
taken in the same synchronous frame immediately before the step/start
append, so the request's messages are exactly the derivation over
events[0..stepStartSeq) — an inject() from an agent/request listener
(or any concurrent task) lands after the boundary and joins the NEXT
request. This changes behavior for a synchronous step/start
session/event listener that appends content (master derived after the
append, so such a listener could reach the current request):
agent/pre-step is the sanctioned seam for current-request content.
- agent/request is re-typed to config-only: (agent, turn, step,
config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes
from AgentOptions on a loop instance's first request (explicit options
beat the logged baseline — fork overrides and resume reconfiguration
stay correct) and from the log's folded header afterwards; listeners
return a replacement to switch. Content shaping through the request is
no longer expressible — model-visible content flows through the log
channels.
- recordRequestHeader appends whatever header event the request owes the
log before dispatch: an 'initial'/'resume' snapshot anchoring each
loop instance, a round-trip-verified delta on change, a 'fallback'
snapshot when the encoding cannot express it. Session.requestHeader()
is the log's incrementally-folded baseline.
- Requests are deep-frozen before dispatch (deepFreeze exempts the
AbortSignal — freezing one breaks AbortController.abort() outright);
frozen + sessionId is the loop-built marker the dev invariant keys on.
Ported from #162 and re-anchored on the log: the append-extension /
frozen-end-to-end / compaction-resend / prompt-change property tests,
plus new specs for the boundary semantics, resume anchoring, and the
end-to-end theorem (every recorded request rebuilds byte-equal from the
log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against
the real DeepSeek API. Snapshot goldens intentionally stale until the
single re-record after the compact/summary envelope lands.
2026-07-06 03:07:34 +08:00
|
|
|
const at = a.tools ?? []
|
|
|
|
|
const bt = b.tools ?? []
|
|
|
|
|
return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema))
|
|
|
|
|
}
|
|
|
|
|
|
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.
2026-07-08 15:44:30 +08:00
|
|
|
/** Canonical JSON equality over session-prefix arrays; absence equals the empty array. */
|
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.
2026-07-07 19:42:30 +08:00
|
|
|
function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] | undefined): boolean {
|
|
|
|
|
return JSON.stringify(a ?? []) === JSON.stringify(b ?? [])
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 02:42:51 +08:00
|
|
|
/**
|
2026-07-13 23:27:00 +08:00
|
|
|
* Compute the `request/header-delta` payload between two canonical headers, or
|
|
|
|
|
* `undefined` when they are equal. The encoding cannot represent every change,
|
|
|
|
|
* including pure tool reordering, so callers must apply and compare the result
|
|
|
|
|
* before logging it and fall back to a full snapshot on mismatch. The session
|
|
|
|
|
* prefix is replaced whole; an empty array removes it.
|
2026-07-12 03:36:43 +08:00
|
|
|
*
|
2026-07-06 02:42:51 +08:00
|
|
|
* @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.
|
|
|
|
|
*/
|
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.
2026-07-07 19:42:30 +08:00
|
|
|
export function diffHeader(prev: EpochHeader, next: EpochHeader): HeaderDelta | undefined {
|
|
|
|
|
const delta: HeaderDelta = {}
|
2026-07-06 02:42:51 +08:00
|
|
|
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
|
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.
2026-07-07 19:42:30 +08:00
|
|
|
if (!sameMessages(prev.messagePrefix, next.messagePrefix)) delta.messagePrefix = next.messagePrefix ?? []
|
2026-07-06 02:42:51 +08:00
|
|
|
return Object.keys(delta).length > 0 ? delta : undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Apply a `request/header-delta` payload to a canonical header, producing the
|
|
|
|
|
* canonical header it encodes. Total for well-formed logs (the writer only
|
|
|
|
|
* appends round-trip-verified deltas).
|
|
|
|
|
* @param prev - the folded header before the delta.
|
|
|
|
|
* @param delta - the logged delta payload.
|
|
|
|
|
* @returns the canonical header after the delta.
|
|
|
|
|
*/
|
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.
2026-07-07 19:42:30 +08:00
|
|
|
export function applyHeaderDelta(prev: EpochHeader, delta: HeaderDelta): EpochHeader {
|
2026-07-06 02:42:51 +08:00
|
|
|
const system = delta.system !== undefined ? applySystem(prev.system, delta.system) : prev.system
|
|
|
|
|
const tools = delta.tools !== undefined ? applyTools(prev.tools ?? [], delta.tools) : prev.tools
|
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.
2026-07-07 19:42:30 +08:00
|
|
|
const messagePrefix = delta.messagePrefix ?? prev.messagePrefix
|
2026-07-06 02:42:51 +08:00
|
|
|
return canonicalHeader({
|
|
|
|
|
config: delta.config ?? prev.config,
|
|
|
|
|
...system !== undefined ? { system } : {},
|
|
|
|
|
...tools !== undefined ? { tools } : {},
|
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.
2026-07-07 19:42:30 +08:00
|
|
|
...messagePrefix !== undefined ? { messagePrefix } : {},
|
2026-07-06 02:42:51 +08:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-12 03:36:43 +08:00
|
|
|
* Fold the header events of a log (or any prefix of one) into the {@link EpochHeader} in
|
|
|
|
|
* force after the last of them: each `request/header` snapshot replaces the state, each
|
|
|
|
|
* `request/header-delta` amends it.
|
|
|
|
|
*
|
2026-07-06 02:42:51 +08:00
|
|
|
* @param events - session events in log order (non-header events are skipped).
|
2026-07-12 03:36:43 +08:00
|
|
|
* @param from - a previously folded state to continue from (the live session's incremental
|
|
|
|
|
* cursor); omit to fold from nothing.
|
2026-07-06 02:42:51 +08:00
|
|
|
* @returns the folded header, or undefined when no header event exists yet.
|
|
|
|
|
*/
|
loop: every request is built from the log — boundary snapshot, header events, config-only waterfall
The loop is now transmission-stateless; a request is a pure function of
(session log, this step's rendered assembly, current AgentOptions):
- The reconstruction boundary is step/start: the messages snapshot is
taken in the same synchronous frame immediately before the step/start
append, so the request's messages are exactly the derivation over
events[0..stepStartSeq) — an inject() from an agent/request listener
(or any concurrent task) lands after the boundary and joins the NEXT
request. This changes behavior for a synchronous step/start
session/event listener that appends content (master derived after the
append, so such a listener could reach the current request):
agent/pre-step is the sanctioned seam for current-request content.
- agent/request is re-typed to config-only: (agent, turn, step,
config: LlmCallConfig, next) → LlmCallConfig. The frozen seed comes
from AgentOptions on a loop instance's first request (explicit options
beat the logged baseline — fork overrides and resume reconfiguration
stay correct) and from the log's folded header afterwards; listeners
return a replacement to switch. Content shaping through the request is
no longer expressible — model-visible content flows through the log
channels.
- recordRequestHeader appends whatever header event the request owes the
log before dispatch: an 'initial'/'resume' snapshot anchoring each
loop instance, a round-trip-verified delta on change, a 'fallback'
snapshot when the encoding cannot express it. Session.requestHeader()
is the log's incrementally-folded baseline.
- Requests are deep-frozen before dispatch (deepFreeze exempts the
AbortSignal — freezing one breaks AbortController.abort() outright);
frozen + sessionId is the loop-built marker the dev invariant keys on.
Ported from #162 and re-anchored on the log: the append-extension /
frozen-end-to-end / compaction-resend / prompt-change property tests,
plus new specs for the boundary semantics, resume anchoring, and the
end-to-end theorem (every recorded request rebuilds byte-equal from the
log alone). Live cache-hit e2e (request-cache.e2e.ts) verified against
the real DeepSeek API. Snapshot goldens intentionally stale until the
single re-record after the compact/summary envelope lands.
2026-07-06 03:07:34 +08:00
|
|
|
export function foldRequestHeader(events: readonly SessionEvent[], from?: EpochHeader): EpochHeader | undefined {
|
|
|
|
|
let state: EpochHeader | undefined = from
|
2026-07-06 02:42:51 +08:00
|
|
|
for (const event of events) {
|
|
|
|
|
if (event.type === 'request/header') {
|
|
|
|
|
state = canonicalHeader(event.data.header)
|
|
|
|
|
} else if (event.type === 'request/header-delta') {
|
|
|
|
|
if (state === undefined) {
|
|
|
|
|
throw new Error(`request/header-delta at seq ${event.seq} before any request/header snapshot: corrupt log`)
|
|
|
|
|
}
|
|
|
|
|
state = applyHeaderDelta(state, event.data)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return state
|
|
|
|
|
}
|