Add scripts/gen-module-graph.ts, which derives the inter-package
dependency graph from each package's @deepseek-ai/dsh-* peerDependencies
and renders docs/module-graph.md (a GitHub-native Mermaid graph plus a
dependency table). Output is deterministic so a regenerate-and-diff
check is stable.
Wire a freshness gate the same way doc-sync is wired (ADR 0007: hooks
and CI run the same package.json scripts): verify-module-graph runs in
pre-push (lefthook) and as a CI step. It fails if the committed file
drifts from what the generator would produce.
master's pnpm migration claimed ADR 0016 (0016-pnpm-over-yarn), which
collides with this stack's session-persistence ADR. Renumbering the
session-persistence ADR to 0018 (turn-enclosure stays 0017); update the
json.ts module-doc reference accordingly.
@google/genai and protobufjs are pulled in transitively by the
dsh-llm-pi-ai adapter and consumed prebuilt; their lifecycle scripts are
no-ops we don't need. Set them to false in allowBuilds (was true) so no
unnecessary install-time code runs, and document why each entry is the
way it is.
Records the why behind the Yarn 4 → pnpm move (PR #39): ecosystem
alignment, strict-linker phantom-dependency safety, build-script
allowlisting, and the package-manager-independent constraints script.
Captures migration-time install benchmarks and notes the gate suite
passes unchanged on pnpm.
The source-level JSON-serializability invariant was only a preflight: the
Session constructor copied the seed array but shared every event/data
object with the caller, and append() stored the caller's `data` reference
verbatim. A post-create/post-append mutation could rewrite the durable
log or reintroduce a non-JSON-serializable value AFTER validation, so
session.events could diverge from what was validated / what a backend
can persist.
- ctor deep-clones each seed event after validation (not just the array).
- append() stores structuredClone(data) (serializability already checked,
so the clone is safe); the returned event carries the same snapshot.
Regression tests: mutating the original seed / the passed append object
after the call leaves session.events unchanged. Adapted the dev-freeze
invariants test to assert on the logged clone (append no longer freezes
the caller's input). Documented isJsonValue's exact scope (own enumerable
string keys, matching JSON.stringify) and synced the README create()
signature with meta.createdAt.
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
closeStep() previously caught and silently swallowed a throw from
agent/step-end emit. In the normal no-tool/no-steering path, this
caused runTurn to reach closeTurn(true) with reason still
{kind:completed}, so the session recorded a completed turn with
zero error events — even though a plugin had failed at a loop
boundary. This violates the contract that a throwing plugin is
contained as a turn error, not a silent success.
Now the catch calls failTurn(toError(error)), which appends the
single error event and sets reason={kind:error,…}. failTurn is
idempotent (errorReported guard), so existing error paths that
call failTurn after closeStep are unaffected.
Add regression test: a throwing agent/step-end listener during a
successful step now produces exactly one error event, a turn/end
with reason error, balanced boundaries (step/end before turn/end),
and a surviving loop.
Clarify that the CodeRuntime seam can host backends differing by
language/runtime, not just trust level — e.g. an AssemblyScript/WASM
backend (naturally sandboxed) and a Python backend over CPython or a
more controllable/embeddable interpreter. Note the execution contract
is language-agnostic while SDK codegen/prompt presentation is per-
language, and add these backends to the deferred follow-up list.
- Add an Alternatives section comparing Code Mode against the narrower
result-elision/summarization route over native tool-calling (solves
context-bloat but not composition/round-trips) and against parallel native
dispatch (a core-loop change that still lacks composition); states why
Code Mode is chosen and why the new code-execution surface is the price.
- Fix the Problem-section framing: it said the model "can run independent
calls concurrently," which contradicted the serialize-by-default decision.
Reworded to "express fan-out, initially serialized until concurrency-safety
metadata exists" — early win is composition + fewer round-trips, not parallelism.
- Concurrency: change from "may serialize" to mandatory serialize-by-default
via a per-run dispatch queue in the SDK bindings, with a non-overlap test as
a hard acceptance criterion (the binding shape otherwise makes Promise.all
dispatch concurrently before the tool contract has concurrency-safety metadata).
- node:vm guard: make it enforceable, not a README warning — CodeRuntime exposes
safe:boolean, the VM stub throws unless constructed { unsafe:true }, and
code-mode refuses to register run_code over an unsafe runtime unless separately
acknowledged (allowUnsafeRuntime); refusal path is tested.
- Prompt budget: drop the "zero prompt tokens" claim (the SDK .d.ts is injected
into the system prompt, so types do consume context) and add the explicit
budget/caching tradeoff — Code Mode's saving is on output/round-trips, not the
input-side tool description.
Proposes an optional Code Mode where the model writes a TypeScript program
against a generated SDK wrapping every registered tool, instead of emitting
one native tool-call per step. Implemented Cordis-style as a capability-seam
trio (code-runtime interface / code-runtime-vm node:vm reference stub /
code-mode consumer plugin) with zero core-package changes; the hardened
execution substrate is deferred to a follow-up RFC.
llm.registerAdapter, agents.register, sessions.create, systemPrompt.section,
systemPrompt.tools, and tools.register each mutated state, emitted a change
event, then returned the disposer. In Cordis a synchronous throw before the
effect returns its disposer leaves nothing for the fiber to collect, so a
throwing change-listener leaked the registry entry permanently — HMR/dispose
could not clean it, and the duplicate-name/already-exists check stayed wedged
until restart.
Convert each to the generator-effect pattern already proven in
AgentLoop.create: mutate state, `yield` the disposer that undoes it (collected
before the next step runs, so it is torn down if a later step throws), THEN
emit the change event. The existing duplicate-name throws are unchanged — they
fire before any mutation, so they correctly leak nothing. No public API change:
generator effects are still synchronous SyncEffects and register() keeps
returning its fire-and-forget disposer wrapper.
Tests: a listener-throw rollback test for all six methods — register with a
change-listener that throws, assert the call throws AND the registry is clean
(entry absent; a subsequent listener-free register of the same name succeeds
and contributes exactly once). For systemPrompt (no duplicate-name check) the
two tests assert assembly is clean. Verified each fails against the pre-fix
emit-before-return-disposer form.
The loop passed the authoritative call.id into ctx.tools.execute() but then
appended tool/result using result.callId — the value a tools/execute waterfall
listener returns — with no check. A listener returning a mismatched id silently
recorded the result under the wrong call. callId is the model-transcript
correlation id: deriveMessages() turns it into the tool-result block's
toolCallId, which must pair with the assistant tool-call block; a wrong id
orphans that pairing in the next model request.
Append tool/result with callId: call.id (the loop's authoritative id). A
listener-internal id, if ever worth keeping, belongs in a separate diagnostic
field — never overloaded onto callId.
Test: a tools/execute listener returns a wrong callId; assert the logged
tool/result.callId equals call.id AND deriveMessages() yields a tool-result
block whose toolCallId equals call.id (not the wrong returned id). Verified the
test fails on the pre-fix result.callId behavior.
After turn/start was appended, nothing guaranteed a matching turn/end: a throw
from a boundary emit (agent/turn-start, agent/step-start, the normal-path
agent/turn-end) escaped runTurn, and the outer runLoop backstop logged an
error but never appended turn/end — leaving an unbalanced turn that replay,
telemetry, and the invariants plugin all assume is impossible.
runTurn is restructured around idempotent finalizers that satisfy the four
traps a naive finally would hit:
- closeStep()/closeTurn(emit) are guarded (stepOpen/turnEnded) so they run at
most once; the agent/step-end and agent/error emits are contained so a
throwing listener can't strand the turn open.
- failTurn() records the single error event + reason and emits agent/error
exactly once (errorReported guard) — no double-logging when the outer catch
also runs (e.g. a step error followed by a throwing turn-end listener).
- the catch closes an open step BEFORE turn/end (invariants reject turn/end
while a step is open), and rethrows ONLY pre-turn throws (turnStarted false),
where no turn/end is owed, so the backstop still nets them.
- disposal precedence: reason stays disposed only when disposed AND no error
was reported; otherwise the error reason wins.
Tests (with the invariants plugin loaded as a balance oracle): throwing
turn-start (one error, one turn/end, no step), throwing step-start (step/end
before turn/end), throwing agent/error on a step-error path (balanced, loop
survives), disposal mid-turn (reason disposed, no error event), a pre-turn
turn/start-append throw (rethrown to the backstop, no turn/end owed), and a
step error + throwing turn-end listener (error logged exactly once). Verified
all six fail against a simulated finalizer bypass. dsh-invariants added as an
agent-loop devDependency (test-only oracle; no package cycle).
Every loop boundary appends the session event before emitting the Cordis
event (ADR 0003's append-before-emit rule) — except step/start, which was
inverted. A listener on agent/step-start that inspected session.events could
not see the step it was just told had started.
- Swap the two lines so session.append('step/start') precedes the emit.
- Fix the two stale pseudo-code copies (the runLoop JSDoc STEP-loop block and
docs/architecture.md) so neither shows step-start emitted before the append.
- Regression test: a step-start listener observes the matching step/start
event already at the tail of session.events. Verified the test fails on the
pre-fix (emit-first) order.
Codex review of PR1 found a semantically-identical stale claim outside the
three files first touched: .agents/skills/dsh-code-review/SKILL.md said "the
doc-sync rule has no gate", which is the same P1-16 drift. doc-sync DOES gate
compilable ts blocks and the event-taxonomy table; only prose drift (config
keys, defaults, error codes, wire fields) is ungated. Reworded to say exactly
that.
(ADR 0014 was also checked and is correct as-is — it is the decision record
that establishes the gate and already describes it as existing.)
The `yarn doc-sync` gate (doc-typecheck + verify-event-taxonomy) and the
@deepseek-ai/dsh-invariants package both exist now, but the instruction docs
never caught up and the gate's markdown scope (README.md, docs/**/*.md,
packages/*/README.md) does not cover AGENTS.md / packages/AGENTS.md, so they
drifted silently.
- AGENTS.md: add invariants/ to the Repository Layout; add doc-typecheck /
verify-event-taxonomy / doc-sync to Commands; rewrite the false "CI has no
doc-sync gate" sentence to describe the gate's actual coverage and what
remains outside it (AGENTS.md, packages/README.md, prose drift).
- packages/AGENTS.md: fix the same stale "no doc-sync gate" line.
- packages/README.md: add dsh-invariants to the dependency graph and the
package table.
Verification: `yarn doc-sync` green; `grep -rn "no doc-sync gate"` returns
nothing; the three command names + dsh-invariants are present.
- 009: the crash-tail "overwrite" contradicted the append-only contract.
Name it explicitly as a one-time truncation-repair (ftruncate+fsync to
the last complete turn/end byte offset) that removes only the
never-committed crash tail; committed events are never rewritten.
Qualify the append/impl/ADR wording to match.
- 010: remove the remaining concrete-loop references — the session/new
and session/load table rows now point at the dsh-agent create/resume
factory, and the Risks disposal line uses the interface-level settle
signal (agent/status) instead of LoopAgent-only agent.done.
Resolve the inline review feedback on PR #18 (all verified against the
codebase, the published @agentclientprotocol/sdk@0.25.1 tarball, and
Cordis fiber semantics):
- 009: dsh-session owns SessionMeta (persistence re-exports) to avoid a
package cycle; split mutable summary into a sidecar so the event log
stays append-only and list/load can return it; pick one load-repair
rule (resume from the last complete turn/end, overwrite the orphan).
- 010: SDK has a zod peer dep + runtime zod/v4 import (drop "zero runtime
deps"); session/new needs a create seam taking {sessionId, meta};
propose an abstract create/resume factory on dsh-agent so the bridge
depends on the interface not the loop, and observe agent/status for
quiescence since agent.done is LoopAgent-only; add the explicit
TurnEndReason -> ACP StopReason wire mapping + test; reject non-empty
additionalDirectories for the MVP; remove the EOF blank line.
- 011: ctx.extend() does not create a disposable fiber — use a real
per-session disposer scope.
Three proposal documents, numbered in dependency order:
- RFC 009: an abstract, append-only, event-based SessionPersistence
service over the existing SessionEvent log (no parallel persisted
type), a JSONL impl, a SessionMeta header seam, and an async
AgentLoop.resume path. Design informed by Codex/Claude Code/
opencode/pi. Core design point; unblocks resume + ACP session/load.
- RFC 010: ACP (Agent Client Protocol) support as a dsh-acp
client-driver plugin on @agentclientprotocol/sdk, mapping ACP onto
the agent/* events and the tools/execute permission seam. Builds on
009 for session/load; single active session.
- RFC 011: multiplex concurrent ACP sessions over one connection
(bridge-layer change; downstream of 010).
The registry's unknown-tool branch returned isError text with no { name, code },
so a model-requested unknown tool logged an unroutable tool/result — a gap in
exactly the taxonomy this PR adds. Introduce ToolNotFoundError (HarnessError,
code UNKNOWN_TOOL) and route the unknown-tool case through the same catch as a
tool-thrown error, so both failure classes surface structured error metadata
from one path. Addresses PR review finding.
The doc-sync gates were CI-only, so the AGENTS.md doc-sync promise could be
missed locally until after push. Add a shared `doc-sync` package.json script
(doc-typecheck + verify-event-taxonomy) wired into the lefthook pre-push job,
and point the CI step at the same script — one source of truth per ADR 0007.
Addresses PR review finding.