Codex diff review, round 1, two (A) findings:
- The agent/request fallback resolved the RAW seed object — on later
steps the session's cached header fold — so a delegating listener
(await next(), mutate, return) could rewrite the fold in place and
the change would compare as already-baseline: no delta logged, the
persisted log unable to reconstruct the request (the dev invariant
would fire on the divergence, but the log would still lie). One
structuredClone'd, deep-frozen seed now serves both the listener
chain and the fallback — in-place shaping after delegation throws —
and Session.requestHeader() freezes its fold on update, so the leak
class is unrepresentable from either side. Pinned by a loop-level
delegating-mutator test.
- Doc sweep for the old contract: agent README's event row (mutate
GenerateOptions / tool filtering → frozen config seed, replacement
out, logged header), compact-basic's module JSDoc (summarize routed
through agent/request → direct one-shot at llm/stream), and
architecture.md's event-domain line (request mutation → call-config
shaping).
surface.replaceGeneration gets its direct-access test (the getter folds
a pending delta itself, not only via a nodes read); the reconstruction
theorem drives maxTokens and stop through the config waterfall; the
compact-basic envelope drops a dead conditional — config.maxTokens is
required and validated positive, so this backend's envelope always
carries the cap (the return type's optionality exists for overriding
subclasses).
Beyond the mechanical conflicts (provider capability lines vs master's new
inheritsParentContext field; generated catalogs regenerated rather than
hand-merged; knip/lockfile), three master-side reworks required semantic
adaptation of this branch:
- The persona rework removed AgentOptions.systemPrompt, which was the
structured-output instruction's channel. The instruction now rides the
SAME final-request enforcement listener that injects the schema'd tool:
appended per request to final.system (per-request wire state, not agent
prompt state). Tests assert the wire request (adapter.requests) instead
of child.options; the bare-direct-dispatch test pins the no-system arm.
- Tool guidance moved out of deployment prompts into per-tool prompt
sections; the examples' workflow paragraph became a tool:<toolName>
section contributed by dsh-tool-workflow (explicit-ask-only policy),
and both example personas resolve to master's minimal identity+behavior
form. tool-workflow gains inject: systemPrompt (+ peer dep, tsconfig
ref); the export-shape guard updated.
- The uniform-RFC-format gate: the dynamic-workflows RFC restructured to
the implemented/ skeleton (bare Status line; Proposal -> Decision;
What-was-rejected -> Alternatives considered; new Consequences), and
the overall-run-timeout deferral is now recorded in the RFC's Deferred
list. The doc-graphs atlas classification gains the workflows seam
(workflow-vm implementation, tool-workflow consumer).
Master's harness-identity section made "empty assembled prompt" states
unreachable through the loop, so the instruction-append is a plain
undefined-ternary and the structured tests assert append-not-replace.
All snapshot goldens (including workflow-run) replay unchanged. Full
local CI-equivalent gate sequence green on the merged tree.
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.
deriveMessages() no longer re-derives and re-clones the whole surface
per call: each node is projected exactly once, when first seen (O(new
nodes) per access), through the now-public deriveEventMessage — the
per-node pure function external reconstructors and the dev invariant
fold over a log prefix, so no two paths can disagree about a request's
messages. A surface rewrite (replace, invalidate) rebuilds, signalled
by SurfaceManager.replaceGeneration — monotonic, bumped by every folded
replace and by invalidate(), never reset. Callers get a fresh array
snapshot per call over SHARED deep-frozen messages: isolation of the
append-only log moves from per-call cloning to unrepresentability
(mutation throws), which the two isolation tests now pin directly.
Every conversation request's non-content half (system prompt, tool
schemas, call config — the EpochHeader) is now recorded in the session
log: a 'request/header' full snapshot (reason 'initial' | 'resume' |
'fallback') anchors the fold at conversation birth and process
boundaries, and 'request/header-delta' events (system line-trim,
name-keyed tools delta, whole config) encode mid-run changes. The pure
trio — foldRequestHeader / diffHeader / applyHeaderDelta — reconstructs
the header any request was built under from the log alone; the writer
contract round-trip-verifies every delta with a 'fallback' snapshot
when the encoding cannot express a change (pure tool reordering), so a
well-formed log always folds cleanly. Canonical absence: empty system
and empty tools normalize to absent fields, matching request builds.
Persistence and cordis catalogs regenerated; SessionEventMap paste and
EpochHeader added to the core-data-structures session page.
ds-review-bot round 2, both warnings:
- subagent/provider-removed now routes through emitLifecycle (per-listener
containment, the subagent/start|end precedent) instead of raw ctx.emit,
whose dispatch halts on the first throw: a throwing subscriber can no
longer starve a later mirror into keeping a stale tool, nor disrupt the
backend fiber's teardown mid-disposer. provider-added deliberately keeps
propagation (register-time rollback semantics, like the system-prompt
registries); the asymmetry is documented on emitLifecycle, the event
JSDoc, and the provider-lifecycle RFC.
- The documented model-via-agent/request fallback composes with a
{{model}} persona via the ownership rule itself: the plugin supplying
the model late states it early on the system-prompt/assemble waterfall.
Declined re-ordering render after agent/request — it would break the
agent/pre-step contract (compaction must measure the prompt the model
sees). New loop test pins the supply path end-to-end; the RFC's
{{model}} consequence bullet now covers supply as well as switch.
Review round 2 (tianyicui inline comments):
- dsh-system-prompt itself registers the harness:identity (-100) and
deployment:persona (0) sections — they must survive a swapped loop
plugin, so they leave dsh-agent-loop; the persona text is the plugin's
own validated 'persona' config. The model/cwd variables STAY on the
loop: runtime facts of the agents it drives.
- AgentOptions.systemPrompt is deleted with all its forwarding plumbing:
the app configs' systemPrompt keys become 'persona' routed through
dsh-agent-core (schema = z.intersect of the owners'), the ACP bridge
and tool-subagent stop carrying persona configuration, and subagent
children now render the deployment persona like every other agent.
- Example personas drop transport/interface trivia (ACP, CLI) — facts
irrelevant to the model.
- Root CONTEXT.md removed (not idiomatic); its persona definition was
wrong under the new ownership anyway.
- Docs, READMEs, the prompt-variables RFC, and generated catalogs
updated; new loop test pins the assemble-waterfall escape valve
(an emptied assembly sends NO system field).
Reconciliations beyond textual conflicts:
- product rename (DeepSeek Code -> DeepSeek Harness SDK) applied to the
PR-added assertion in system-prompt.spec.ts that master's rename
commit could not reach
- architecture.md: master's rewrite kept; this PR's prompt-assembly
semantics re-added in the new doc's voice (Turn Flow footnote +
service-spine row), within the 1630-word ceiling
- cordis catalog regenerated into master's split events.md/services.md
(events-and-services.md deleted); module graph and doc graphs
regenerated to pick up this PR's new events and dependency edges
Add the implemented skill-system RFC, a core data-structures page, and JSDoc for the skill public vocabulary so the generated catalogs and review-facing docs describe the new service/tool contract.
A new capability family at packages/workflow/ in the bash seam shape,
modeled on Claude Code's dynamic workflows: the model writes a JavaScript
orchestration script (export const meta = {...} + plain-JS body), a runtime
executes it, and the script — not the conversation — holds the loop, the
branching, and the intermediate results.
- dsh-workflow (ctx.workflows): abstract WorkflowService + run vocabulary
(WorkflowRun whose result NEVER rejects) + observe-only workflow/* events
carrying data snapshots (id + meta, never the live run), per-listener
contained like subagent/*.
- dsh-workflow-vm: in-process node:vm engine. Meta extraction via a
string/comment-aware scanner (template interpolation rejected; literal
evaluated alone in an empty timed context; statement blanked line-
preservingly so stacks keep script line numbers). Hooks: agent(prompt,
{label, phase, schema, model}) over ctx.subagents, parallel(), pipeline()
(no cross-stage barrier), phase(), log(), args. Fatal-vs-null discipline:
hook misuse (unknown/deferred options, bad arguments, unsupported
schemas, tripped caps, seam start failures, cancellation) throws fatal
WorkflowErrors the combinators RE-THROW — never dissolved into the
per-item null reserved for child failures. Realm boundary: inbound values
materialized by descriptor walks that never invoke accessors (defineProperty
copies, __proto__-safe); outbound values rebuilt in-realm via the
context's own JSON.parse. Determinism bans (Date.now/Math.random/argless
new Date) kept so future resume support cannot break scripts. Caps and
timeouts are validated Config. Every hook promise carries a no-op
rejection consumer (app-boot exits on unhandled rejections).
- dsh-tool-workflow: the model-facing workflow tool, synchronous like
dsh-tool-subagent (start → await → try/finally dispose; abort bridged;
non-completed → isError). Generic render card titled by a textual
meta.name sniff. The tool description carries the authoring contract.
Wired into examples/{coding-agent,acp-agent} with explicit-ask-only
guidance. Coverage at every tier: unit (meta scanner, materializer incl.
counting-getter and __proto__ regressions, combinator semantics,
concurrency ceiling, caps, cancellation, no-unhandled-rejection abandon),
integration over the real spawn stack, with-key e2e (real two-phase run +
the tool through the registry pipeline), and a recorded ACP snapshot
scenario (workflow-run, 1 child session). RFC:
docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md (deferred
work explicitly listed). AGENTS.md budget 1575 → 1590 for the new group's
layout line.
A static harness:identity section at order -100 — the first occupant of
the documented negative band — states that the agent is powered by the
DeepSeek Harness SDK before the deployment's persona renders. Harness
attribution is a harness fact: it lives on the loop plugin, not in each
deployment's persona, so every agent (subagents included) carries it and
no YAML can forget it. A deployment that must drop it can remove the
section in the system-prompt/assemble waterfall.
Order-band docs updated in all five homes (PromptSection JSDoc, the
system-prompt and agent-loop READMEs, architecture.md, the RFC).
The seam vocabulary (SubagentStartRequest.outputSchema, SubagentResult
.structured) existed but no in-process backend honored it — spawn/fork
advertised outputSchema: false. This lands the missing half:
- dsh-tools gains a structured-output JSON Schema subset (json-schema.ts):
StructuredOutputSchema, assertSupportedOutputSchema (rejects loud outside
the enforced subset, every violation listed), validateStructuredValue
(path-qualified issues, total). outputSchema's seam type becomes this raw
JSON-Schema subset instead of the author-facing SchemaSpec DSL — the schema
travels verbatim to the model as a forced tool's parameters.
- dsh-subagent-inprocess gains the shared structured runtime: one global
structured_output capture tool (placeholder parameters) + a prepend:true
agent/request listener doing FINAL-REQUEST enforcement (strip for plain
agents, per-run schema for structured children — survives downstream
request-replacing listeners) + an agent/turn-continuation veto that stops
a child's turn once captured (no wasted extra model step). Lifetime is
refcounted by backends (plugin lifetime) AND live runs (start→settle).
- startInProcessRun drives the capture: subset asserted before the child
exists, instruction appended to the child's system prompt, clean-finish
nudge loop (structuredNudgeRetries, backend Config, default 1), captured
value on result.structured; a clean finish without a capture settles
'error' (never a silent success with a missing field).
- spawn + fork flip outputSchema: true and inject 'tools'.
The AgentOptions.systemPrompt doc said the persona 'may reference'
variables without saying that every complete {{...}} group IS interpreted,
strictly, and that literal {{...}} prose has no escape syntax yet (the
RFC's recorded deferral). A persona author reads this line first; it now
carries the contract. (ds-review-bot inline finding: the escape mechanism
itself stays deferred per the RFC — pre-release, no external consumers,
and the failure is loud with the fix in the message.)
Codex round-1 findings, both confirmed:
- renderPrompt: variable lookup now uses Object.hasOwn (an unregistered
{{constructor}} previously resolved through Object.prototype and spliced
function source into the prompt), and a {{ that opens no complete group
while a }} still follows ({{{model}}}, {{a{b}}) now throws instead of
passing or partially interpolating. A lone {{ with no }} after it stays
verbatim; substituted values are never re-scanned.
- tool-subagent: the apply-time provider lookup assumed a load order the
cordis Loader does not guarantee (siblings start concurrently). The seam
now announces subagent/provider-added/-removed and the tool mirrors the
provider's lifecycle: registers when the provider is (or becomes)
available, unregisters when it goes away, re-derives wording on reload.
No load-order requirement remains.
- loop.spec containment test now proves live continuation: after the
contained render failure, a waterfall listener rescues {{cwd}} and the
same agent completes a real model turn.
RFC/READMEs updated to the shipped contract; cordis catalog regenerated.
One principle: every fact in the assembled prompt has exactly one owner.
- dsh-system-prompt: merge-extensible AssembleContext on assemble();
a variable(name, provider) registry; {{name}} interpolation in
renderPrompt, strict (unknown/valueless/malformed references throw);
duplicate section and variable names rejected; assembly carries
resolved section text + variables through the assemble waterfall.
- dsh-agent declares AssembleContext.agent; dsh-agent-loop registers
the agent:persona section (order 0 - identity renders before tool
guidance) and the model/cwd variables, and drops its string join:
renderPrompt(assembly) IS the full prompt.
- Tool guidance moves to its owners: descriptions carry per-tool
semantics; sections only cross-call habits (tool:bash exit-code
habit at order 105; read's not-shell nudge). todo/subagent need no
section - their descriptions already carry the contract.
- SubagentProvider.inheritsParentContext (spawn/acp false, fork true);
dsh-tool-subagent derives truthful per-provider wording and resolves
the provider at load (backend must be listed first).
- Example personas shrink to identity + behavior with {{model}} (and
{{cwd}} in the ACP tree); the welcome banner stops enumerating tools.
RFC: docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md
Merging master brought the producerless-vocabulary prune that removed
TurnTriggerMap's continuation variant: the source pointers above
SessionEventMap shifted by one line (the staleness CI caught on the
merge tree), and the new turn/start JSDoc still named the pruned
variant.
- The owning top-level interface SessionEventMap must now be the SINGLE
EXPORTED declaration in @deepseek-ai/dsh-session: a non-exported local
interface (even inside the owning package) and a second exported copy
are hard errors, so a same-named helper can no longer be catalogued as
the on-disk vocabulary.
- Any SessionEventMap declaration carrying an extends clause is a hard
error: inherited keys join keyof SessionEventMap but have no catalog
row, so heritage is a silent-skip path the gate must reject.
Three new spec cases; RFC and module doc updated to match.
- A SessionEventMap member that is not a property signature with an
explicit payload type is now a hard error instead of silently skipped —
a method-form or type-less member joins keyof SessionEventMap and must
not escape the catalog.
- A top-level interface SessionEventMap outside @deepseek-ai/dsh-session
(ownership read from the package manifest) is now a hard error — an
unrelated same-named local interface was previously catalogued as the
on-disk vocabulary.
- JSDoc tag detection runs on the trimmed line, so an extra-indented
'* @mode' can no longer bypass the forbidden-tag check and leak into
prose.
Four new spec cases cover these; RFC and module doc updated to describe
the enforced (not just assumed) invariants.
docs/persistence-catalog/log-events.md enumerates every SessionEventMap
member — the owning dsh-session vocabulary plus the dsh-compact and
dsh-hook-protocol declaration merges — with payload, surface/log-only badge,
JSDoc prose, and declaration site. scripts/gen-persistence-catalog.ts is a
pure AST pass in the gen-cordis-catalog mold: verify-persistence-catalog
(--check) joins doc-sync, so a stale committed catalog fails pre-push and CI.
The walk enforces JSDoc completeness (every member needs description prose;
@mode is rejected as a category error — log events do not dispatch on the
cordis bus), derives the surface badge from the SurfaceEventType union with a
stale-member cross-check, and hard-errors on duplicate declarations. Payloads
render through the TypeScript printer so newline-separated multi-line type
literals still emit valid one-line fragments.
Documented the five previously JSDoc-less core events (turn/step boundaries,
tool/call), removed the two stray @mode tags on the hook/* merges, and
replaced the hand-restated event enumerations (session.md hook/* table,
compact README table, hook-protocol README bullets, session README name-list
— whose merge note had already drifted) with links to the catalog. RFC:
docs/rfc/implemented/process/2026-07-04-persistence-log-catalog.md.
Codex review: the RFC claims the fixtures prove each guard fires, but the
binding-pattern guards (events + services), the service no-prose branch,
and the empty-@param/@returns-description branches had no focused tests.
Add the five missing cases; every violation branch in the generator now
has a matching fixture.
gen-cordis-catalog now hard-errors (aggregated, not fail-fast) when an
event lacks description prose or a payload @param, or a public service
method lacks JSDoc, a @param per parameter, a @returns on a non-void
result, or an explicit return type annotation. The this receiver and the
trailing waterfall next are exempt on events (mode machinery owned by
@mode); a stale @param naming no real parameter errors, mirroring the
@mode contradiction check. parseJsDoc now ends prose at the first block
tag (standard JSDoc semantics), so the tags never change the rendered
catalog — only Source: line pointers moved.
Fills the ~139 gaps found across the 15 surface files, extends the spec
with negative-path fixtures for every new guard plus the exemptions,
records the decision as an implemented process RFC, and extends the
AGENTS.md typed-events bullet with the authoring rule. Runs inside
verify-cordis-catalog -> doc-sync, so CI and pre-push enforce it with
zero new wiring.
GenerateOptions.prefill had no production setter and both adapters
rejected it with LlmError('UNSUPPORTED') — its entire observable
behavior was two throws, each pinned by one adapter test. DeepSeek's
chat-prefix completion is a Beta feature on a base URL neither adapter
targets. ToolSchema.strict was threaded through defineTool, the
registry's schemas() allowlist, the deepseek wire mapping, a per-tool
payload-patching pass in the pi-ai adapter, and a tool-catalog render
row, yet no shipped tool set it and the internal endpoint story for
strict mode was never built.
Remove both fields end-to-end: the vocabulary in dsh-llm, the adapter
guards and wire branches, the dsh-tools threading, the tool-catalog
Strict row, the pinning tests, the core.md pastes, the adapter README
rows, and the cookbook line that used prefill as the UNSUPPORTED
example (now stated generically). The pi-ai payload fixup keeps the
half with a job: pi-ai stamps strict:false on every serialized tool,
so the fixup scrubs it unconditionally for wire parity with the
hand-rolled twin (per-tool set/delete machinery gone). temperature/
stop/maxTokens are untouched — honored end-to-end by both adapters.
Each knob returns with its first real producer: prefill with an
adapter that implements chat-prefix completion, strict with a tool
that wants it and a beta-endpoint story.
RFC: docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md
(moved from proposed/, amended to shipped reality); the content-block
vocabulary RFC's consequence line now records prefill as producer-gated.
The vocabulary maps grow by declaration merging, and the admission policy
stated on TurnEndReasonMap is that a variant lands with its first emitter.
Three declared items had no producer and no consumer:
- CacheHint and the cache?: CacheHint fields on TextBlock/ToolResultBlock:
nothing constructs a block with cache:, and neither adapter reads .cache —
DeepSeek prompt caching is automatic (hints map OUT of responses, never IN).
- MessageSourceMap.agent: zero constructors; the subagent backends send the
parent prompt with no source (logs as user), and the envelope renderer
interpolates source.kind without routing on it.
- TurnTriggerMap.continuation: the loop structurally cannot emit it —
continuation is further steps within a turn, never a new turn — and its only
writer was an llm-replay test fixture that needed any non-message trigger
(now an injection trigger).
Each variant returns the day it gains a real producer, via the same
merge-extensible maps. Docs updated in the same change: the MessageSourceMap
paste in core.md, the TurnTriggerMap paste in session.md (manifest untouched —
both symbols survive), the content-block vocabulary RFC's cache-hints
consequence line, and the RFC moved to implemented/ and amended to shipped
reality (the image block's own cache field had already left with the
drop-image RFC).
agent/steering was the last transient mirror of a durable session event:
drainSteering appended the durable steering/message {turn, content, source}
and emitted the identical fact one line later. Zero production listeners
existed — every steering consumer (hook bridges, goldens, deriveMessages)
reads the durable event — and the one regression test subscriber asserted
a fact the log already records.
Remove the declaration (dsh-agent types + JSDoc list + README row), the
emit in drainSteering (its ctx parameter goes too), and the emit line in
the loop-pseudocode blocks (loop.ts module doc, architecture.md); the
cordis catalog is regenerated. The regression test now pins source
preservation on the durable steering/message event. Live-notification
needs keep their surviving homes: agent/queued at enqueue time,
session/event at drain time.
RFC: docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md
(moved from proposed/, amended to shipped reality). The three implemented
RFCs that stated the retention — the boundary-mirror removal, the
stream-chunk removal, and event-domain-semantics — are amended to point
at that RFC as the record of the removal, per implemented/AGENTS.md.
The rejected retire-mid-turn-steering RFC keeps its frozen text (it
records the declined proposal); the steering capability itself —
steer(), the durable event, continuation forcing — is untouched.