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

@@ -159,13 +159,17 @@ export interface LoopHandle {
* assembly = ctx.systemPrompt.assemble(assembleContextFor(agent)) ⟵ waterfall system-prompt/assemble
* (scope-filtered; scoped sections/tools join); renderPrompt
* (persona section + {{variables}}) IS the full prompt
* await events.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step
* prefix ??= waterfall agent/session-prefix ⟵ once per loop instance (first step): frozen
* session prefix; logged on the header, never
* session history (scope-filtered, fused dispatch)
* await events.serial('agent/pre-step', …, prefix) ⟵ surface mutation (compaction) OUTSIDE the step;
* pressure gates see the prefix the request carries
* 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
* 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: prefix+boundary, 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
@@ -478,6 +482,50 @@ async function runTurn(
break
}
// Compose the session prefix ONCE per loop instance, lazily before the
// instance's first pre-step: request-only messages placed in front of
// the ENTIRE derived history on every request this instance sends. It
// MUST precede the pre-step seam so compaction gates on THIS instance's
// prefix — reading a previous instance's logged prefix would let a
// resumed/forked instance whose contributor grew skip compaction and
// ship an over-window first request. The result is deep-cloned
// (decoupled from listener-held references), deep-frozen, and cached on
// the transmission bookkeeping, so reuse is structural — the prefix
// cannot change mid-session and the provider prefix cache holds by
// construction (resume = a new instance = a recompose, anchored by its
// 'resume' snapshot). The prefix is not session history — the header
// event in runStep is its only durable record
// (EpochHeader.messagePrefix). 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. This
// runs OUTSIDE the step, before the boundary snapshot: a composing
// listener's session append lands before the boundary and joins the
// CURRENT request.
if (transmission.sessionPrefix === undefined) {
const emptyPrefix: Message[] = deepFreeze([])
const composed = await events.waterfall(
'agent/session-prefix', emptyPrefix, abort.signal,
() => Promise.resolve(emptyPrefix),
)
// Interruption landing during prefix composition: mirror the assembly
// window above — drop the about-to-start step without running the
// seam, and DISCARD the composition instead of caching it. An
// abort-aware listener may have returned a degraded fallback under
// the firing signal; committing it would ship a prefix no request
// ever used (and no header ever logged) on this instance's next real
// request. The next turn recomposes under a live signal — the cache
// only ever holds a fully composed prefix. The cache-hit path needs
// no such check: nothing awaits between the assembly check above and
// the pre-step seam.
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
break
}
transmission.sessionPrefix = deepFreeze(structuredClone(composed))
}
// Pre-step surface-mutation checkpoint (compaction), fired OUTSIDE the
// step: after `turn/start` (and the prior step's close) but before
// `step/start`, so a compaction's log-only `compact/*` records and its
@@ -488,8 +536,10 @@ async function runTurn(
// concurrent listeners cannot interleave their `session.append`s. A
// throwing listener escapes to the outer catch, which closes the (not-yet-
// open) step as a no-op and ends the turn via failTurn — a broken
// pre-step plugin ends the turn, not the loop.
await events.serial('agent/pre-step', turn, step, fullSystemPrompt, abort.signal)
// pre-step plugin ends the turn, not the loop. The composed session
// prefix rides along so token-pressure listeners count everything the
// request will actually carry.
await events.serial('agent/pre-step', turn, step, fullSystemPrompt, transmission.sessionPrefix, abort.signal)
// Interruption landing during the pre-step seam: do not open an empty step.
if (handle.isCancelled() || handle.isDisposed()) {
@@ -682,11 +732,12 @@ 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 → compose the session prefix if this instance has none yet → 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,
events: AgentEventDispatch,
@@ -727,22 +778,30 @@ async function runStep(
throw new Error(`agent "${agent.id}" has no model: set AgentOptions.model or supply one via the agent/request waterfall`)
}
// The session prefix was composed (once per instance) before this step's
// pre-step seam — the caller guarantees it, so the cache is always set here.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call
const sessionPrefix = transmission.sessionPrefix!
// 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 session prefix, which no other event carries.
const header = canonicalHeader({
config,
...system ? { system } : {},
...assembly.tools.length > 0 ? { tools: assembly.tools } : {},
...sessionPrefix.length > 0 ? { messagePrefix: sessionPrefix } : {},
})
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 — the reconstruction equation the invariant recomputes.
const request: GenerateOptions = deepFreeze({
model: header.config.model,
messages: boundaryMessages,
messages: [...header.messagePrefix ?? [], ...boundaryMessages],
...header.system !== undefined ? { system: header.system } : {},
...header.tools !== undefined ? { tools: header.tools } : {},
...header.config.temperature !== undefined ? { temperature: header.config.temperature } : {},