Commit Graph
3368 Commits
Author SHA1 Message Date
Tianyi Cui f40dcb5f7b Merge branch 'split/session-persistence' into split/agent-factory
# Conflicts:
#	docs/adr/README.md
#	packages/agent-loop/package.json
#	yarn.lock
2026-06-16 17:04:54 +08:00
Tianyi Cui 96331432b8 Merge branch 'split/turn-enclosure' into split/session-persistence
# Conflicts:
#	docs/adr/README.md
#	packages/agent-loop/package.json
#	yarn.lock
2026-06-16 17:01:36 +08:00
Tianyi Cui c4fd22f0fa Merge branch 'split/session-meta' into split/turn-enclosure
# Conflicts:
#	docs/adr/README.md
2026-06-16 16:53:37 +08:00
Tianyi Cui 3a6ebd1954 docs(session): renumber session-persistence ADR reference 0016→0018
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.
2026-06-16 16:51:59 +08:00
Tianyi Cui a7fbd93f4f Merge remote-tracking branch 'origin/master' into split/session-meta 2026-06-16 16:45:32 +08:00
07akioni dabc2ff411 feat: migrate to pnpm 2026-06-16 14:55:37 +08:00
Tianyi Cui add59a3336 feat(agent-loop): surface max-tokens as a distinct turn-end reason
Add a `max-tokens` variant to `TurnEndReasonMap` and carry the model
finish reason up from `runStep` to `runTurn`, applying the rule "any
max-tokens step in the turn surfaces as max-tokens" (disposed/aborted/
error still take precedence). This lets consumers distinguish a clean
stop from a truncated one — the contract RFC 010's ACP bridge maps to
the `max_tokens` stop reason.

Also add an AGENTS.md rule: write an ADR when (and only when) a PR makes
a durable, contested, surprising decision.
2026-06-16 11:27:19 +08:00
Tianyi Cui 11e166db71 Merge branch 'split/agent-factory' into split/session-persistence-sqlite 2026-06-16 00:44:19 +08:00
Tianyi Cui b218e027d5 Merge branch 'split/session-persistence' into split/agent-factory 2026-06-16 00:44:10 +08:00
Tianyi Cui 1b1385e4f7 fix(session-persistence-jsonl): never wedge a published log on temp-cleanup failure (review #33)
materialize() removed the temp hard link in a finally that ran BEFORE
syncDir() and before state.materialized/cursor advanced. If link()
succeeded (log published) but the temp rm then threw, materialize()
rejected after publishing — leaving state.materialized false, so the
buffered events stayed unpersisted and every retry wedged on the
"already exists" exists() backstop.

Restructured to the robust shape: track link() success; on link failure
remove the temp (the only reference) before propagating; on success
fsync the directory, mark materialized, THEN best-effort remove the
now-redundant temp link (a leftover *.tmp is harmless and never read).
A temp-rm failure can no longer reject a session whose log published.
2026-06-16 00:43:40 +08:00
Tianyi Cui d76d02b666 Merge branch 'split/session-persistence' into split/agent-factory
# Conflicts:
#	docs/adr/0016-session-persistence.md
2026-06-16 00:40:10 +08:00
Tianyi Cui 091dd12531 Merge branch 'split/turn-enclosure' into split/session-persistence 2026-06-16 00:38:07 +08:00
Tianyi Cui 3e1ca8a425 fix(agent-loop): contain finalizer append-listener throws (review #32 round 2)
The prior fix handled a throwing session/event listener on the turn/start
append, but the SAME push-before-notify hazard remained on the three
FINALIZER appends. Session.append pushes the event before notifying, so a
throwing listener on a finalizer event left the event logged but aborted
the rest of finalization — stranding the turn open.

- failTurn(): set `reason` BEFORE appending the `error` event, and contain
  a throwing session/event listener on it (the event is already logged
  either way). Otherwise reason stayed unset, agent/error was skipped, and
  the caller's closeTurn(false) never ran → open turn.
- closeStep(): the try/catch wrapped only the agent/step-end EMIT, not the
  step/end APPEND. A throwing session/event listener on step/end escaped —
  fatal when closeStep runs from the outer catch during finalization
  (turn/start + step/end but no turn/end). Now both the append and the emit
  are contained and surface as a turn error via failTurn.
- closeTurn(): contain a throwing session/event listener on the turn/end
  append (it would propagate to the runLoop backstop from closeTurn(false),
  or skip the turn-end emit from closeTurn(true)). turn/end is logged
  either way, so the turn stays balanced.

Regressions: a throwing session/event listener on the error event, on
step/end during finalization (driven by a throwing agent/step-start), and
on turn/end — each leaves a balanced turn and the loop survives.
2026-06-16 00:37:18 +08:00
Tianyi Cui f1dac1b1ed fix(session-persistence-sqlite): defer crash-tail repair to append; fix all-tail materialized flag; persist schema version (review #35)
- load() no longer DELETEs the crash tail — it stays non-mutating w.r.t.
  the event log and records a repair point (repairFrom). The next append
  runs the DELETE inside its own transaction before inserting. This makes
  the SQLite backend honor the SAME public contract as JSONL (load returns
  the committed prefix; the subsequent append performs the one-time
  physical truncation-repair), instead of mutating during load.

- All-tail load: when the discarded crash tail was the session's only
  committed content (committed.length === 0), the metadata row still read
  materialized = 1 from the prior append, so has()/list() reported a
  session load() had just emptied. load() now flips the row's materialized
  flag to 0 (metadata only — the orphaned event rows are still removed by
  the deferred repair), so has()/list() are immediately consistent.

- Schema version: openDatabase now stores SCHEMA_VERSION in PRAGMA
  user_version on a fresh database and rejects opening one whose
  user_version is newer than this build supports, protecting against a
  future incompatible layout.

Regression tests: load is non-mutating (tail rows survive until the next
append), all-tail load makes has()/list() false, and a newer-schema
database is rejected on open.
2026-06-16 00:05:25 +08:00
Tianyi Cui ca4e1c3a1c Merge branch 'split/agent-factory' into split/session-persistence-sqlite 2026-06-15 23:55:58 +08:00
Tianyi Cui 5284ed4806 docs(agent): sync inject wording + resume error wording with code (review #34)
- agent/README: the inject() line said "without triggering a turn",
  regressing the #32 turn-enclosure model. Restored the running-vs-idle
  wording (idle inject wraps a one-shot injection turn; ADR 0017) to match
  the interface JSDoc.
- agent-loop resume() JSDoc said "throws a typed error" but the code
  throws a plain Error (consistent with the sibling assertAgentIdFree
  throw). Softened to "rejects with a clear error" — no behavior change;
  plain Error is intentional (no consumer needs a structured code here).
2026-06-15 23:55:40 +08:00
Tianyi Cui 06d5f60bac Merge branch 'split/session-persistence' into split/agent-factory 2026-06-15 23:53:37 +08:00
Tianyi Cui 01621d38b6 fix(session-persistence-jsonl): surface non-ENOENT storage errors; harden sidecar; broaden contract (review #33)
A durable persistence backend must not treat a storage fault as absence.
listCwdDirs() and exists() swallowed EVERY error and reported "no
sessions" / "not found", so EACCES/ENOTDIR/transient I/O could make
list() return nothing, load() report not-found, and collision checks
proceed under a false absence assumption.

- Add an isENOENT() helper; listCwdDirs() and exists() now return the
  empty/absent result ONLY for ENOENT and rethrow every other error.
  Regression tests drive ENOTDIR through both paths.

TODO-level hardening also addressed:
- writeSidecar() now uses an exclusive owner-only temp open ('wx', 0o600)
  like the log-materialization path, instead of a truncating writeFile —
  the sidecar can carry user data (title/firstPrompt), so a predictable/
  pre-existing temp path must never be silently followed.
- The shared runPersistenceContract serializability case now exercises
  EVERY value isJsonValue rejects (BigInt, undefined, Infinity, function,
  symbol, Map, circular), not just BigInt, so a backend cannot pass the
  contract while accepting values that corrupt the round-trip. The mock
  MemoryPersistence now validates via the canonical isJsonValue.
2026-06-15 23:53:03 +08:00
Tianyi Cui b4785e598b Merge branch 'split/turn-enclosure' into split/session-persistence 2026-06-15 23:45:09 +08:00
Tianyi Cui 4535bfab75 fix(agent-loop): decide turn balance + idle-injection flush from the log (review #32)
Session.append pushes the event BEFORE notifying session/event listeners,
so a throwing listener leaves the event in the log while the line after
the append (a boolean flag) never runs. Both turn-balance decisions were
gated on such flags, so a throwing listener could strand an open turn or
skip a durability checkpoint.

- loop.ts: the outer catch decided "turn/end owed" from `turnStarted`.
  A throwing listener on the turn/start append left turn/start logged but
  the flag false → catch rethrew and skipped turn/end → permanently open
  turn (violating ADR 0017). Now decided from the log (this turn's
  turn/start present), so the turn is always balanced; only a genuine
  pre-push failure (non-serializable trigger — turn/start never logged) is
  rethrown to the runLoop backstop. Removed the now-dead `turnStarted`.

- agent.ts inject(): the idle one-shot-turn flush was gated on a
  `turnRecorded` flag set after append('turn/end'); a throwing turn/end
  listener skipped the flush, losing the balanced in-memory injection turn
  on crash. Now the flush decision is read from the log, the synthetic
  turn/end append contains a throwing listener (turn stays balanced), and
  a failing idle flush is reported via agent/error (step 0 convention) AND
  the logger — mirroring the loop's post-turn/end flush path — with a
  throwing agent/error listener contained.

Rewrote the test that encoded the old (buggy) "turn/start listener throw
is rethrown, no turn/end" semantics to assert the balanced-turn contract,
and added regressions for the throwing-turn/end-listener flush and the
agent/error report. Updated Agent.inject JSDoc.
2026-06-15 23:44:54 +08:00
Tianyi Cui da81709a31 Merge branch 'split/session-meta' into split/turn-enclosure 2026-06-15 23:33:25 +08:00
Tianyi Cui 5299e43bed fix(session): snapshot seed + appended data at the boundary (review #31)
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.
2026-06-15 23:33:00 +08:00
Tianyi Cui ccbc4f533f fix(session-persistence-sqlite): JSONL parity on append snapshot + corrupt-tail load
Two parity gaps with the JSONL backend found in review:

- append() now validates serializability and structuredClones the batch
  synchronously at call time, BEFORE waiting behind the per-session
  chain. A caller that mutates the passed array (or an event inside it)
  after the call can no longer corrupt the persisted copy or advance the
  cursor past what was written. Matches the JSONL backend.

- load() now computes the last-turn/end cut from the seq+type COLUMNS
  only (cutAtLastTurnEnd is generic over {seq,type}); event `data` is
  JSON-parsed only for the committed prefix, never for the uncommitted
  tail. A malformed `data` in a crash tail is discarded, not treated as
  unloadable — only a parse error/gap in the COMMITTED region is
  unloadable (the SessionPersistence.load contract). Matches scanLog.

Regression tests for both.
2026-06-15 22:23:36 +08:00
Tianyi Cui bc670e69f3 Merge branch 'split/agent-factory' into split/session-persistence-sqlite 2026-06-15 22:14:02 +08:00
Tianyi Cui 6455a1600d Merge branch 'split/session-persistence' into split/agent-factory 2026-06-15 22:13:51 +08:00
Tianyi Cui 5f3a1e4d60 Merge branch 'split/turn-enclosure' into split/session-persistence 2026-06-15 22:13:40 +08:00
Tianyi Cui 3cc074ba5c Merge branch 'split/session-meta' into split/turn-enclosure
# Conflicts:
#	packages/agent-loop/tests/review-fixes.spec.ts
2026-06-15 22:13:22 +08:00
Tianyi Cui 611791ba7f Merge remote-tracking branch 'origin/master' into split/session-meta 2026-06-15 22:10:21 +08:00
Tianyi Cui 30765bd6d5 Merge pull request #24 from deepseek-ai/fix/registration-atomicity
fix: registration atomicity across the six registration methods
2026-06-15 22:09:50 +08:00
Tianyi Cui 328863e458 Merge pull request #23 from deepseek-ai/fix/agent-loop-tool-result-callid
fix(agent-loop): log tool/result under the originating call.id
2026-06-15 22:09:32 +08:00
Tianyi Cui 9126697d87 feat(session-persistence-sqlite): second backend validating the abstraction
Add a SQLite SessionPersistence backend (node:sqlite), a SECOND
implementation built to prove the abstract seam + the shared
runPersistenceContract suite are genuinely backend-agnostic. Each
SessionEvent maps 1:1 onto an events row (session_id, seq, type, time,
data); append is an INSERT inside a transaction asserting the
contiguous-seq contract; the mutable SessionSummary lives in the
sessions metadata row.

It satisfies the SAME contract semantics as the JSONL backend, expressed
over rows instead of file bytes:
- Lazy materialization: create() records intent in memory; no row until
  the first append (a never-appended session is absent from has()/list()
  via a materialized flag set inside the first append transaction).
- Crash-tail-on-load: load() returns events only through the last
  complete turn/end and deletes the uncommitted tail; a seq gap in the
  committed region makes the session unloadable.
- Transactional append: a mid-batch failure (a UNIQUE seq collision from
  a concurrent writer) rolls back entirely, keeping the cursor truthful.

Like the JSONL backend it is also the write-path plugin (session/event →
buffer → session/flush drain, onCreated seed/adopt/collision handling,
HMR seeding, dispose-to-quiescence). The package runs the shared
runPersistenceContract suite plus SQLite-specific tests (transaction
rollback, crash-tail cut, schema version, HMR adoption).

Docs flip every "SQLite is future/deferred" reference (ADR 0016,
architecture.md, the persistence module doc + README) to "implemented;
the contract holds both backends to identical semantics".
2026-06-15 21:45:21 +08:00
Tianyi Cui 9a4006cb2b feat(agent): create/resume factory seam
Add the agent-creation factory seam on ctx.agents (AgentRegistry):
setFactory/create/resume plus the AgentFactory interface and
CreateAgentOptions/ResumeAgentOptions. AgentLoop implements AgentFactory
and registers itself via ctx.agents.setFactory(this), so plugins
create/resume agents through the interface without depending on the
concrete loop package.

- create({ agentId, sessionId, meta?, agentOptions? }) — programmatic
  create on a caller-supplied session id (e.g. an ACP-generated id).
- resume({ agentId, resumeSessionId, agentOptions? }) — load a persisted
  session via ctx.sessionPersistence (RFC 009) and resume an agent on it;
  the live session id is the resumed id, turn numbering and derived
  history continue from the loaded log. sessionPersistence is NOT
  hard-injected (non-persistent demos still work); resume rejects with a
  typed error when it is absent. assertAgentIdFree runs before any
  session is created (and again after the load await) so a duplicate id
  never leaves an orphaned live session.

Adds the runtime dsh-session-persistence dependency to agent-loop.
2026-06-15 21:12:14 +08:00
Tianyi Cui df4b7d3d9a feat(session-persistence): abstract seam + JSONL backend + wiring
Add the durable session-persistence capability seam (ADR 0016): an
abstract SessionPersistence service (dsh-session-persistence,
ctx.sessionPersistence) defining create/append/load/list/has/delete/
update over the existing SessionEvent — no parallel persisted type — and
a first implementation (dsh-session-persistence-jsonl): an append-only
JSONL log per session with crash-safe atomic writes, truncation-repair
of a never-committed crash tail, and a read/replay path. SessionMeta
(format version, cwd, lineage) travels out-of-log via session.header.

A shared runPersistenceContract suite holds every backend to the same
append-only / contiguous-seq / lazy-materialization / serializability
semantics.

Config-driven create() now uses a per-run ${id}-session-<uuid> session
id so a fixed name no longer collides with an on-disk log once a durable
backend is loaded; each run is a new session (a demo simplification). The
examples drop their hand-rolled session-jsonl.ts and load the JSONL
backend via cordis.yml; CI smoke-loads it too.

The agent-facing create/resume factory that consumes load() is a
separate seam, deferred to a follow-up; this change stops at the load
primitive and does not reach into the loop.
2026-06-15 21:05:46 +08:00
Tianyi Cui b0bc0b5792 feat(agent-loop): turn-enclosure invariant + post-turn error model
Every session event now lives inside a turn (between turn/start and its
turn/end). The loop records queued user/message events AFTER turn/start;
an idle agent.inject() wraps its context/message in a one-shot injection
turn. This makes the turn the single durability/replay boundary so a
persistence backend can treat anything after the last turn/end as a
crash tail without dropping legitimate between-turn context.

A failure once the turn is already closed (rejecting session/flush, a
throwing agent/turn-end listener) has no in-turn position for a session
error event, so it is reported via agent/error + logger only; the turn
stays balanced. failTurn appends an error event only while the turn is
open.

The dsh-invariants plugin enforces turn-enclosure via a default case:
every non-boundary event type — including plugin-added merge-extensible
keys — must sit inside an open turn or it throws.

Documented in ADR 0017 + architecture.md.
2026-06-15 20:56:17 +08:00
Tianyi Cui 0731ed374b feat(session): metadata seam + JSON-serializability invariant
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).
2026-06-15 17:54:55 +08:00
Tianyi Cui 3783f3e178 fix(agent-loop): surface throwing step-end listener as turn error via failTurn
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.
2026-06-15 17:43:33 +08:00
Tianyi Cui 2df41ee1d3 fix: make the six registration methods atomic under a throwing change-listener (P1-1)
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.
2026-06-15 01:06:13 +08:00
Tianyi Cui 37576ade6a fix(agent-loop): log tool/result under the originating call.id (P1-7)
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.
2026-06-15 01:06:13 +08:00
Tianyi Cui 22e9152d8b fix(agent-loop): always close a started turn and any open step on error (P1-5)
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).
2026-06-15 01:06:13 +08:00
Tianyi Cui 9d5b3ab832 fix(agent-loop): append step/start before emitting agent/step-start (P1-6)
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.
2026-06-14 23:18:41 +08:00
Tianyi Cui b8d4790e98 docs: sync high-authority docs with the doc-sync gate and dsh-invariants (P1-16)
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.
2026-06-14 23:07:52 +08:00
Tianyi Cui 58627f075d Merge branch 'feat/rfc006-doc-sync' into feat/rfc005-error-taxonomy 2026-06-14 10:44:29 +08:00
Tianyi Cui 75044c9711 Merge branch 'feat/rfc001-property-tests' into feat/rfc006-doc-sync 2026-06-14 10:44:21 +08:00
Tianyi Cui 1c61efccf8 Merge branch 'feat/rfc005-dev-invariants' into feat/rfc001-property-tests 2026-06-14 10:44:00 +08:00
Tianyi Cui bed91b46d5 fix(tools): give the unknown-tool failure a structured error code
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.
2026-06-14 10:43:23 +08:00
Tianyi Cui a45bc8da67 fix(invariants): deepFreeze walks already-frozen objects' descendants
Session.append accepts event data from arbitrary plugins/tools, so a caller
can pass a SHALLOW-frozen object with mutable descendants. The old
Object.isFrozen early-return skipped such an object entirely, leaving its
descendants mutable in the log — exactly the history mutation ADR 0012 means
to catch. Now always descend, tracking visited objects in a WeakSet for
cycle-termination and idempotence. Addresses PR review finding.
2026-06-14 10:36:16 +08:00
Tianyi Cui 825b57aff9 feat(llm): structured error taxonomy with a shared HarnessError base (RFC 005 pt 2)
Introduce HarnessError in dsh-llm (the leaf package): a stable machine-routable
code distinct from the message, cause chaining, name from the subclass, plus
isHarnessError. LlmError, ToolArgsError, and InvariantError now extend it.

Tool failures carry the structure end-to-end: ToolExecutionResult gains
error: { name, code } (populated from a thrown HarnessError), and the loop
forwards it onto the tool/result session event (which gained the same optional
field) for retry/sandbox plugins and replay. The loop's toError wraps non-Error
throws in a HarnessError(code: UNKNOWN, cause) instead of a bare Error.

Landed last and in isolation so it's a pure upgrade over the plain Error+code
the earlier PRs used — independently revertible. Graduates RFC 005 pt 2 ->
ADR 0015; RFC 005 now fully implemented.
2026-06-14 01:07:28 +08:00
Tianyi Cui 6a528be569 build: doc-sync gates — typecheck doc code blocks + verify event taxonomy (RFC 006 pts 1-2)
Two tsx CI gates make doc/code drift fail fast:
- doc-typecheck extracts every fenced ts block from README/docs/package READMEs,
  compiles them with tsc --noEmit against a temp project (vendor->lib, harness->src
  paths from tsconfig.typecheck.json), and fails on errors. Deliberate sketches opt
  out with ```ts ignore-check; the opt-out ratio is reported and capped.
- verify-event-taxonomy asserts the docs/architecture.md taxonomy table names
  exactly the events declared in the interface Events blocks. This surfaced three
  events the table had been missing (tools/change, llm/adapter-change,
  system-prompt/change), now added.

Doc snippets made compilable with stub imports/declares (1 genuine sketch ignored).
Wired into CI after typecheck. API reports (RFC 006 pt 3) deferred. Graduates RFC
006 pts 1-2 -> ADR 0014.
2026-06-14 00:47:38 +08:00
Tianyi Cui 7b07b70750 test: address Codex review of property tests (PR 3)
- llm: generator now emits finish chunks (the finish-defaults property was
  vacuously green); add a property asserting streaming and one-shot assembly
  agree on usage and finish
- agent-loop: assert the synchronous burst batches into exactly one turn; add
  a mixed-schedule property (send/settle interleavings); recordStatus returns
  its disposer; per-run timeouts so a hang loses no seed
- session: randomize the noise/message interleaving (was a fixed alternation)
- tools: exclude non-finite doubles from generated numeric args (JSON-real)
2026-06-14 00:24:23 +08:00
Tianyi Cui 2f6d3b8539 test: property-based tests for protocol-shaped code (RFC 001)
Adds fast-check + one tests/properties.spec.ts per protocol-shaped package
(llm/BlockAssembler, session, tools/schema DSL, agent-loop scheduling). The
tools suite includes the RFC 001<->005 composition property (generated args
satisfying a spec pass validateArgs), closing the validator/InferArgs drift
risk from ADR 0011. Loop properties are deterministic (settle on agent/status,
no sleeps).

The BlockAssembler suite found a real bug on first run: a duplicate block-end
at the same index overwrote an already-flushed block, so the streamed prefix
disagreed with final blocks(). Fixed (first close wins, matching the existing
straggler rule) + regression test. Graduates RFC 001 -> ADR 0013.
2026-06-14 00:06:25 +08:00