Merge origin/master: scope-aware fusion of the tools/execute seam, session-prefix, and tool-cordis

Master brought 50 commits (the tool-cordis group, dsh-code-runtime + worker,
the tools/execute around-dispatch seam + timeout-policy, repeat-tool-guard,
agent/session-prefix, the ui reorganization). Beyond the ten textual
conflicts, the merge reconciles master's new seams with this branch's
scoped-registration world:

- tools/execute (new waterfall around core dispatch): dispatched with the
  SAME exec.agent carrier as the pre/post waterfalls — an agent.ctx wrapper
  times/retries only its own agent's calls — and its base thunk resolves the
  tool through the caller's visible view (get(exec.name, exec.agent)), so a
  scoped/shadowed tool dispatches and a restricted-away global stays
  UNKNOWN_TOOL. Declared this: Scoped<ToolRegistry> with the scope-filtered
  doc sentence; invariants table + verify-scoped-dispatch pin it (21 events).
- agent/session-prefix (new waterfall, once per loop instance): composed via
  the fused agentEvents dispatcher (scope-filtered like every agent-subject
  event), declared this: Scoped<Agent>, table-pinned. agent/pre-step keeps
  master's new sessionPrefix parameter with this branch's Scoped this.
- timeout-policy reads the budget through the caller's visible view
  (get(exec.name, exec.agent)): a scoped tool's own timeoutMs governs its
  calls; a global name-twin's budget is never misapplied to a shadowing
  per-agent variant.
- tool-cordis: cordis_inspect's tools section lists the CALLING agent's view
  (its description promises "what you can call"); the sandbox tool façade's
  reads resolve through the mount's own scope, mirroring where its register
  lands writes; sandboxRegisterTool's return type carries the exact-disposer
  union honestly. dsh-scope declared as peer+dev with the project reference.
- doc-sync chain unions master's verify-cordis-api with this branch's
  verify-scoped-dispatch; the generated catalogs, event matrix (the
  zero-dispatcher guard passes over master's new events), module graph, and
  the cordis api-catalog are regenerated on the merged surface.

Full gate sequence green on the merged tree: typecheck, lint, per-file 100%
coverage (2668 tests), snapshots (38), doc-sync, module graph, build,
hygiene, demo smoke.
This commit is contained in:
Tianyi Cui
2026-07-09 23:24:42 +08:00
193 changed files with 12546 additions and 605 deletions

View File

@@ -13,8 +13,9 @@
* (deterministic — `seq = log.length`, part of the event-log contract).
*
* A separate, composable normalizer — {@link scrubRequestHeaders} — replaces
* the bulky request-header CONTENT (the composed system prompt and the tool
* schema list) with `{{system}}`/`{{tools}}` tokens. It is deliberately NOT
* the bulky request-header CONTENT (the composed system prompt, the tool
* schema list, and the session prefix) with
* `{{system}}`/`{{tools}}`/`{{messagePrefix}}` tokens. It is deliberately NOT
* folded into {@link normalizeSessionLog}: each suite's one header-pinning
* scenario compares that content verbatim, every other scenario composes the
* scrub in (the `pinsHeader` flag on the scenario table, consumed by the suite
@@ -30,6 +31,7 @@ const SESSION_ID = '{{sessionId}}'
const CWD = '{{cwd}}'
const SYSTEM = '{{system}}'
const TOOLS = '{{tools}}'
const MESSAGE_PREFIX = '{{messagePrefix}}'
/** A UUID v4 string, the shape `randomUUID()` produces for session ids. */
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
@@ -135,15 +137,22 @@ export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): stri
/**
* Replace request-header CONTENT in a session JSONL with stable tokens,
* keeping its structure: a `request/header` event's `data.header.system` →
* `{{system}}` and `data.header.tools` → `{{tools}}`; a
* `{{system}}`, `data.header.tools` → `{{tools}}`, and
* `data.header.messagePrefix` → one `{{messagePrefix}}` token per message
* (the session prefix is model-visible bulk — an AGENTS digest, a skills
* catalog — so its COUNT stays a structural fact while its text never lands
* in a fixture); a
* `request/header-delta` event keeps every structural fact — the system
* delta's `keepStart`/`keepEnd` line positions and inserted-line COUNT (one
* `{{system}}` token per inserted line), the tools delta's
* added/removed/changed tool NAMES — and tokenizes only the bulk (prompt
* text; each added/changed schema's fields other than `name` → `{{tools}}`),
* added/removed/changed tool NAMES, the prefix replacement's message COUNT —
* and tokenizes only the bulk (prompt
* text; each added/changed schema's fields other than `name` → `{{tools}}`;
* each replacement prefix message → `{{messagePrefix}}`),
* so two different deltas still compare different.
* Absent fields stay absent — WHETHER a header carried a system prompt or
* tools is behavior and stays visible; `config` and `reason` are small and
* Absent fields stay absent — WHETHER a header carried a system prompt,
* tools, or a prefix is behavior and stays visible; `config` and `reason`
* are small and
* stable, so they stay verbatim (a model swap churns every fixture by design
* — it invalidates the recorded responses; a prompt/schema edit churns none —
* replay never reads this content, see dsh-llm-replay).
@@ -166,9 +175,10 @@ export function scrubRequestHeaders(rawLog: string): string {
if (record.type === 'request/header') {
const header = data.header as Record<string, unknown> | null | undefined
if (header === null || typeof header !== 'object') return line
if (!('system' in header) && !('tools' in header)) return line
if (!('system' in header) && !('tools' in header) && !('messagePrefix' in header)) return line
if ('system' in header) header.system = SYSTEM
if ('tools' in header) header.tools = TOOLS
if (Array.isArray(header.messagePrefix)) header.messagePrefix = header.messagePrefix.map(() => MESSAGE_PREFIX)
return JSON.stringify(record)
}
if (record.type === 'request/header-delta') {
@@ -183,6 +193,10 @@ export function scrubRequestHeaders(rawLog: string): string {
if (Array.isArray(tools.added)) { tools.added = tools.added.map(scrubToolSchema); touched = true }
if (Array.isArray(tools.changed)) { tools.changed = tools.changed.map(scrubToolSchema); touched = true }
}
if (Array.isArray(data.messagePrefix)) {
data.messagePrefix = data.messagePrefix.map(() => MESSAGE_PREFIX)
touched = true
}
return touched ? JSON.stringify(record) : line
}
return line

View File

@@ -52,12 +52,22 @@ export interface Scenario {
/**
* Whether `test:snapshot:record` regenerates this scenario's `session.jsonl`
* from the LIVE API. `recorded` scenarios are model-driven and reproducible;
* `authored` scenarios (a hand-written `replay.override.json` sidecar drives
* replay — e.g. a provider error or a cancel, which the live API can't be
* coaxed into deterministically — or a deterministic hook scenario whose
* derived empty script needs no sidecar) are NEVER re-recorded.
* `authored` scenarios (fixtures hand-written or hand-harvested — e.g. a
* provider error or a cancel the live API can't be coaxed into
* deterministically, a deterministic hook scenario, or a scripted repetition
* a live model won't reproduce) are NEVER re-recorded.
*/
recorded: boolean
/**
* Whether replay is driven by a hand-written `replay.override.json` sidecar
* (a `ReplayEntry[]` that REPLACES the script derived from `session.jsonl`)
* — the throw/hang cases chunks cannot express. The fixture guard requires
* the sidecar exactly when this is set: the harness forwards the file purely
* on existence, so an unregistered stray sidecar would silently replace the
* derived script — the guard fails loud on either mismatch. Defaults to
* false (replay derives from the fixture's `assistant/chunk` events).
*/
overridden?: boolean
/**
* How many SUBAGENT child sessions this scenario records beyond the top-level
* one (0 for a single-session scenario). Each child rides in a sibling fixture
@@ -317,17 +327,18 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
// throws "fixture not found" when it is absent and no override replaces it.
// A no-model scenario ships a header-only `session.jsonl` (it derives to an
// empty script — no model call is made); a model scenario's fixture also
// doubles as the expected-log artifact the run is diffed against. An authored
// (non-`recorded`) model scenario additionally ships a `replay.override.json`
// sidecar for the throw/hang cases a derived script cannot express.
for (const { name, hasModelTurn, recorded, childSessions } of scenarios) {
// doubles as the expected-log artifact the run is diffed against. The
// `replay.override.json` sidecar is matched BOTH ways against the table's
// `overridden` flag: required when set, forbidden when not — the harness
// forwards the file purely on existence, so an unregistered stray sidecar
// would silently replace the derived script.
for (const { name, overridden, childSessions } of scenarios) {
const dir = join(snapshotsDir, name)
expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true)
expect(existsSync(join(dir, 'stdout.golden.jsonl')), `${name}/stdout.golden.jsonl`).toBe(true)
expect(existsSync(join(dir, 'session.jsonl')), `${name}/session.jsonl`).toBe(true)
if (hasModelTurn && !recorded) {
expect(existsSync(join(dir, 'replay.override.json')), `${name}/replay.override.json`).toBe(true)
}
expect(existsSync(join(dir, 'replay.override.json')), `${name}/replay.override.json presence must match \`overridden\``)
.toBe(overridden === true)
// A nested-agent scenario ships one child fixture per recorded subagent
// session (`session.1.jsonl` …), the replay source for that child session.
for (const childFixture of childFixturePaths(dir, childSessions ?? 0)) {

View File

@@ -155,6 +155,38 @@ describe('scrubRequestHeaders', () => {
expect(toolsOnly).not.toContain('{{system}}')
})
it('scrubs the header session prefix to one token per message, keeping the count', () => {
const ev = headerEvent({
config: { model: 'm' },
messagePrefix: [
{ role: 'user', content: [{ type: 'text', text: 'workspace AGENTS digest' }] },
{ role: 'user', content: [{ type: 'text', text: 'skills catalog' }] },
],
})
const out = scrubRequestHeaders(`${headerLine}\n${ev}\n`)
expect(out).toContain('"messagePrefix":["{{messagePrefix}}","{{messagePrefix}}"]')
expect(out).not.toContain('AGENTS digest')
expect(out).not.toContain('skills catalog')
// Absence stays absent — a prefix-less header gains no token…
expect(scrubRequestHeaders(`${headerLine}\n${headerEvent({ system: 's' })}\n`)).not.toContain('{{messagePrefix}}')
// …and a non-array shape passes through untouched.
const odd = JSON.stringify({ type: 'request/header', seq: 4, time: 9, data: { header: { config: { model: 'm' }, messagePrefix: 'weird' }, reason: 'initial' } })
expect(scrubRequestHeaders(`${headerLine}\n${odd}\n`)).toContain('"messagePrefix":"weird"')
})
it('scrubs a header-delta prefix replacement to one token per message', () => {
const delta = JSON.stringify({
type: 'request/header-delta', seq: 8, time: 9,
data: { messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'leaked opener' }] }] },
})
const out = scrubRequestHeaders(`${headerLine}\n${delta}\n`)
expect(out).toContain('"messagePrefix":["{{messagePrefix}}"]')
expect(out).not.toContain('leaked opener')
// The empty-array transition-to-absence stays a structural fact.
const toNone = JSON.stringify({ type: 'request/header-delta', seq: 9, time: 9, data: { messagePrefix: [] } })
expect(scrubRequestHeaders(`${headerLine}\n${toNone}\n`)).toContain('"messagePrefix":[]')
})
it('leaves a delta with no scrubbable payload byte-identical (config-only, or non-array shapes)', () => {
const configOnly = JSON.stringify({ type: 'request/header-delta', seq: 8, time: 9, data: { config: { model: 'm2' } } })
const oddShapes = JSON.stringify({ type: 'request/header-delta', seq: 9, time: 9, data: { system: { insert: 'not-an-array' }, tools: null } })

View File

@@ -39,14 +39,14 @@ const REPLAY_SCENARIOS: Scenario[] = [
{ name: 'plain-turn', hasModelTurn: true, recorded: true, childSessions: 1 },
{ name: 'no-model', hasModelTurn: false, recorded: false },
{ name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false },
{ name: 'authored-error', hasModelTurn: true, recorded: false },
{ name: 'authored-error', hasModelTurn: true, recorded: false, overridden: true },
]
const RECORD_SCENARIOS: Scenario[] = [
{ name: 'rec-pin', hasModelTurn: true, recorded: true, pinsHeader: true },
{ name: 'rec-child', hasModelTurn: true, recorded: true, childSessions: 1 },
// recorded:false in record mode → registered but skipped (never re-recorded).
{ name: 'rec-skip', hasModelTurn: true, recorded: false },
{ name: 'rec-skip', hasModelTurn: true, recorded: false, overridden: true },
]
// Record mode mutates its snapshots dir, so run it on a throwaway copy —

View File

@@ -387,10 +387,12 @@ export function apply(ctx: Context, config: Config = {}): void {
'agent/pre-step': args => args[0],
'agent/prompt-submit': args => args[0],
'agent/request': args => args[0],
'agent/session-prefix': args => args[0],
'agent/step-result': args => args[0],
'agent/turn-continuation': args => args[0],
'agent/error': args => args[0],
'tools/pre-execute': args => (args[0] as ToolExecution).agent,
'tools/execute': args => (args[0] as ToolExecution).agent,
'tools/post-execute': args => (args[0] as ToolExecution).agent,
'system-prompt/assemble': args => (args[1] as AssembleContext).scope,
'session/created': null,
@@ -457,8 +459,11 @@ export function apply(ctx: Context, config: Config = {}): void {
// hand-built one-shot (compaction summarize) is unfrozen and skipped — must
// be EXACTLY what the session log reconstructs:
//
// - messages: the derivation over the log prefix strictly before the
// in-flight step's `step/start` (the reconstruction boundary). Compared
// - messages: the folded header's session prefix (messagePrefix — the
// `agent/session-prefix` product, logged on the header because no
// session event carries it) followed by the
// derivation over the log prefix strictly before the in-flight step's
// `step/start` (the reconstruction boundary). The derivation is compared
// against a FRESH Session built over that prefix — the same projection
// code with zero shared state, so the live cache under test cannot vouch
// for itself. Boundary-correct by construction: content appended after
@@ -498,18 +503,22 @@ export function apply(ctx: Context, config: Config = {}): void {
if (boundary === -1) {
throw new InvariantError('a loop-built request with no step/start in its session log')
}
const rebuilt = new Session(SessionId(`${String(session.id)}-invariant-rebuild`), structuredClone(events.slice(0, boundary)))
// JSON equality is sound here: both sides are structuredClones produced by
// the same projection code path, so key insertion order matches when the
// values do.
if (JSON.stringify(options.messages) !== JSON.stringify(rebuilt.deriveMessages())) {
throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`)
}
const header = foldRequestHeader(events)
if (header === undefined) {
throw new InvariantError('a loop-built request with no request/header event in its session log')
}
const rebuilt = new Session(SessionId(`${String(session.id)}-invariant-rebuild`), structuredClone(events.slice(0, boundary)))
// The reconstruction equation: the folded header's session prefix, then
// the boundary derivation — the loop
// logs the header event BEFORE dispatch, so the fold already covers this
// request's prefix. JSON equality is sound here: both sides are
// structuredClones produced by the same projection/build code path, so key
// insertion order matches when the values do.
const expected = [...header.messagePrefix ?? [], ...rebuilt.deriveMessages()]
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
throw new InvariantError(`llm request for session "${String(session.id)}" diverges from the boundary derivation (log-reconstruction desync)`)
}
const headerMatches = options.model === header.config.model
&& options.system === header.system
&& options.temperature === header.config.temperature

View File

@@ -708,6 +708,21 @@ describe('request-reconstruction cross-check (llm/stream)', () => {
expect(() => { dispatch(ctx, options) }).not.toThrow()
})
it('expects the folded header\'s session prefix ahead of the derivation (prefix + derived)', async () => {
const { ctx, session, boundary } = await requestSetup()
const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: '<system-reminder>catalog</system-reminder>' }] }
session.append('request/header-delta', { messagePrefix: [prefix] })
// The prefixed request matches the fold…
const prefixed = Object.freeze({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })
expect(() => { dispatch(ctx, prefixed) }).not.toThrow()
// …a request that DROPPED the logged prefix diverges…
const bare = Object.freeze({ model: 'm', messages: Object.freeze([...boundary]), sessionId: session.id })
expect(() => { dispatch(ctx, bare) }).toThrow(/diverges from the boundary derivation/)
// …and so does one that misplaced it (prefix sent after the history).
const misplaced = Object.freeze({ model: 'm', messages: Object.freeze([...boundary, prefix]), sessionId: session.id })
expect(() => { dispatch(ctx, misplaced) }).toThrow(/diverges from the boundary derivation/)
})
it('rejects a frozen request whose messages diverge from the boundary derivation', async () => {
const { ctx, session, boundary } = await requestSetup()
const messages = [...boundary, { role: 'user', content: [{ type: 'text', text: 'phantom' }] }]