Commit Graph
27 Commits
Author SHA1 Message Date
Tianyi Cui 4a1c64663b docs: retire completed tagged-envelope review TODO 2026-06-19 09:28:23 +08:00
Tianyi Cui 0334b4ad2e chore: trim stale comments and duplicate strings 2026-06-19 01:49:02 +08:00
Tianyi Cui 7fa113be0e Merge remote-tracking branch 'origin/master' into codex/pr48-repo-hardening-rfcs
# Conflicts:
#	docs/adr/README.md
#	docs/rfc/009-session-persistence-and-resumability.md
#	docs/rfc/README.md
#	docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md
#	docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md
#	examples/acp-agent/tests/acp.e2e.ts
#	packages/acp/README.md
#	packages/acp/src/index.ts
#	packages/acp/tests/stream-update.spec.ts
#	packages/agent-loop/src/loop.ts
#	packages/tools/src/index.ts
2026-06-18 23:41:14 +08:00
Tianyi Cui 7c400e9c02 docs: unify ADR/RFC trees into one lifecycle-organized RFC tree
Collapse docs/adr/ and docs/rfc/ into a single docs/rfc/ with proposed/,
implemented/, and rejected/ subfolders. Every file is renamed to
yyyy-mm-dd-topic-title.md, where the date is when the topic was first
proposed (from git history). ADRs and RFCs that covered exactly the same
topic are merged (property-based testing, session persistence); the
umbrella RFC 005 stays split across its three implemented decisions, and
RFC 006's deferred part-3 (API extractor reports) splits into its own
proposed RFC. All cross-references become machine-checkable relative
links instead of bare "ADR NNNN" / "RFC NNN" prose.

Add a verify-md-links doc-sync gate (scripts/verify-md-links.ts) that
checks every relative Markdown cross-link resolves, wired into doc-sync
alongside verify-md-wrap. This makes the reorganization self-verifying:
the same change that rewrote ~forty inter-doc links adds the check that
proves none dangle. Document the cross-link convention in a new
docs/AGENTS.md and record the gate as an implemented RFC.

doc-sync, typecheck, lint, and the full test suite (667) all pass.
2026-06-18 02:18:24 +08:00
Tianyi Cui 2b36620e55 fix(persistence): tighten crash repair and dispose semantics 2026-06-17 21:26:21 +08:00
Tianyi Cui 977c23d31f Merge remote-tracking branch 'origin/master' into feat/acp-1-max-tokens-turn-end
# Conflicts:
#	AGENTS.md
2026-06-17 15:16:26 +08:00
Tianyi Cui 2bae18f811 fix(session): synthesize tool results for interrupted tool calls on crash recovery (review #33)
The loop logs the assistant/message (carrying tool-call blocks) BEFORE running
the tools, so a crash mid-tool leaves durable tool calls with no matching
tool/result. interruptedTurnClosers only added step/end + turn/end, so a resumed
session's deriveMessages() replayed a dangling assistant tool-call — which every
provider rejects as an invalid transcript on the next request.

interruptedTurnClosers now scans the interrupted turn for tool-call blocks
without a matching tool/result and synthesizes an error tool/result for each
(before the step/end), so the rehydrated history is a valid transcript. Adds a
dedicated repair.spec.ts and a shared-contract case proving both backends pair
every orphaned call with a result. Docs (ADR 0018, both persistence READMEs,
load() JSDoc) updated.

Also fixes the echo-agent README session-cleanup path: demo:echo runs from the
repo root, so sessions land in <repo-root>/.sessions/_no-cwd/, not
examples/echo-agent/.sessions/ (review #33).
2026-06-16 23:50:52 +08:00
Tianyi Cui ddf121320b Merge remote-tracking branch 'origin/split/session-persistence-sqlite' into feat/acp-1-max-tokens-turn-end
# Conflicts:
#	packages/session/src/types.ts
2026-06-16 23:31:14 +08:00
Tianyi Cui efee449cfe feat(session-persistence): preserve interrupted turns on crash; don't truncate (review #33)
A crash can leave a durable log whose final turn never closed. The old
behavior truncated everything after the last turn/end as a "crash tail".
But a single turn can be HUGE in a long-horizon task (many steps, large
tool output), so truncating it silently destroys real, durably-written
work — truncating a turn is wrong.

New crash recovery (ADR 0018): load() PRESERVES the interrupted turn's
events and CLOSES the orphaned turn by durably appending synthetic
boundary events — a step/end if a step was open, then a turn/end carrying
the new merge-extensible TurnEndReason {kind:'interrupted'}. load()
returns the balanced log, so a resumed session is immediately usable. Only
a never-fully-written TORN tail fragment is discarded; corruption in the
committed region is still unloadable.

- dsh-session: TurnEndReason {kind:'interrupted'} + shared
  interruptedTurnClosers() repair helper.
- JSONL backend: scanLog preserves the longest contiguous prefix
  (including a partial final turn); loadCore truncates a torn fragment and
  durably writes the closers, returning the balanced log.
- runPersistenceContract gains a crash-recovery test (both backends + mock).
- Docs: ADR 0018/0017, architecture.md, package READMEs.

Also (review #33): RFC 013 records the "move event vocabulary to Zod"
question (merge-extensible maps → runtime schema registry) + blast radius;
deferred, not done here.
2026-06-16 21:27:50 +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 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 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 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 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 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
Tianyi Cui 11a29fdefe feat(invariants): dev-mode event-contract assertions + session-log freeze (RFC 005 pt 3, RFC 008)
New @deepseek-ai/dsh-invariants plugin (pure listeners, off in prod) asserts
the event taxonomy at runtime — seq monotonicity, turn/step nesting, a
tool/result needs a prior tool/call (NOT the converse), legal agent/status
transitions — and deep-freezes logged event data so mutating history throws.
Seeded sessions are checked + frozen on session/created.

The real RFC 008 fix is always-on: deriveMessages now structured-clones the
content it emits, so the loop's sanctioned request/adapter mutation can no
longer reach back and rewrite the append-only log. The pervasive
DeepReadonly<T> type flip is rejected (compile-only, high-noise, castable) —
recorded in ADR 0012, which folds in RFC 008. Wired into both demos.
2026-06-13 23:25:12 +08:00
Tianyi Cui 066f94c7e0 docs: unwrap hard-wrapped Markdown to one line per paragraph
Hard line breaks mid-paragraph make docs harder to edit and diff — a
one-word change reflows and re-diffs the whole paragraph. Reflow all
tracked non-vendor Markdown (plus vendor/AGENTS.md) so each prose
paragraph is a single line; soft-wrapping is the editor's job. Fenced
code, tables, and list structure are preserved (wrapped list items fold
to one line per bullet). Documents the convention in AGENTS.md.
2026-06-13 20:27:04 +08:00
Tianyi Cui 225ed051b1 Add branded ID types: CallId, SessionId, AgentId
Nominal string types via a unique-symbol brand (zero runtime cost):
an AgentId can no longer be passed where a CallId is expected. Each
core package brands the IDs it owns — CallId in dsh-llm (tool-call
correlation across blocks, chunks, session events, and execution
results), SessionId in dsh-session, AgentId in dsh-agent. Construction
goes through same-named factory functions; public string-in APIs
(sessions.create, agentLoop.create) keep accepting plain strings and
brand internally. Policy note in the brand module: brand IDs that
cross package boundaries, not every string.
2026-06-11 15:17:56 +08:00
Tianyi Cui cb6bee3d03 Add ESLint: typescript-eslint strict-type-checked + stylistic formatting
Flat config with two layers. Correctness (type-checked): the headline
rules for this codebase are no-floating-promises / no-misused-promises
(a lost promise in the agent loop is our primary bug class),
switch-exhaustiveness-check (we switch over merge-extensible unions
everywhere), no-unnecessary-condition, require-await, and
no-explicit-any. Style (@stylistic): 2-space, no semicolons, single
quotes, trailing commas, max-len 140 — the existing house style, now
enforced instead of drifting between agents. vendor/ is excluded
(vendored source keeps upstream style); tests relax the rules that
fight test ergonomics (non-null assertions after expects, async mock
signatures, non-Error throws).

Code adjusted to pass: registry disposers wrap ctx.effect's
promise-returning disposer behind a sync () => void (our public API),
BlockAssembler gains an invariant-checking mustGet instead of non-null
assertions, lastTurnNumber uses findLast, waterfall tails return
Promise.resolve instead of async-without-await arrows, and the two
deliberate suppressions (non-exhaustive derivation switch, unbound
execute pass-through) carry justification comments.

yarn lint / yarn lint:fix added.
2026-06-11 14:17:58 +08:00
Tianyi Cui d2fb352f3e Enable maximum-strict TypeScript across our packages
tsconfig.base.json adds noUncheckedIndexedAccess,
exactOptionalPropertyTypes, noImplicitOverride,
noFallthroughCasesInSwitch, noUnusedLocals, and noUnusedParameters on
top of strict. Vendored packages opt out of the new flags locally
(their tsconfigs are ours to regenerate; their source is not), keeping
upstream-sync friendliness.

Our code fixed accordingly: index accesses acknowledge undefined
(assembler flush cursors, lastTurnNumber); optional properties are
omitted instead of set-to-undefined (GenerateResult.usage,
ToolDefinition.strict, GenerateOptions.system/tools, error payloads
via an errorData helper); Session.onAppend is explicitly
`(…) => void | undefined`; tests and examples updated for unused
parameters and indexed access.
2026-06-11 14:02:47 +08:00
Tianyi Cui 7f024a1a9d Document the codebase thoroughly and tighten type safety
Docs: per-folder README.md for packages/ (family overview + one per
package: service, events, API, extension points, TODOs), examples/,
and examples/echo-agent/; folder-level AGENTS.md (+ CLAUDE.md
symlinks) for packages/ and vendor/; module-level doc comments in
every packages/*/src file; richer JSDoc on all exported API
(event side effects, disposal contracts, error behavior). Root
AGENTS.md gains a "Type Safety and Documentation" policy section:
the codebase aims to be very type-safe and well documented; type
gymnastics are acceptable in core packages when they improve
plugin-author DX; verbose docs are fine as long as they stay strictly
in sync with the code.

Type safety: removed the upstream-inherited "noImplicitAny": false
from tsconfig.base.json — packages/* now compile under full strict
mode; vendor/loader and vendor/include set it locally (vendor/cordis
already did). Eliminated every `: any` / `as any` from packages and
examples (catch clauses use unknown + a CodedError narrowing type;
event data access uses discriminated-union narrowing).

Typed tool schemas: new @deepseek-ai/dsh-tools schema DSL —
SchemaSpec with per-property `required: true` booleans, type-level
InferArgs<S>, a runtime SchemaSpec → JSON Schema converter, and
defineTool() so first-party tools get typed execute(args) with zero
casts (raw JSON Schema still accepted for MCP interop; chosen over
schemastery because it targets JSON Schema generation directly).
echo-tool and all test tools migrated; +7 tests.
2026-06-11 13:01:00 +08:00
Tianyi Cui 217b8ec0e2 Fix architecture-review findings in the loop and service packages
High (loop pipeline): agent/step-result now runs before the
assistant/message append so the session log records what tool dispatch
actually uses; abort is honored between tool calls, not just
mid-stream; steering drains at step start, pending steering overrides
a negative turn-continuation decision (/goal pattern), and leftover
steering is re-enqueued as queued messages so it is never stranded;
exceptions from turn-continuation listeners and session/flush are
contained to the turn (error event + agent/error) instead of killing
the driver loop.

Medium: disposal emits agent/status('disposed') and mid-turn disposal
records reason 'disposed'; duplicate LLM adapter registration throws
(all-or-nothing); SessionEvent is a real discriminated union (casts
removed); model-less agents fail with a clear actionable error unless
agent/request supplies a model.

Low: agent/queued and agent/steering carry the resolved MessageSource;
streamBlocks() yields strictly in stream order and flushes delta-only
blocks (matches generate()); BlockAssembler freezes blocks on
block-end and ignores stragglers from malformed streams; turn
numbering is a counter seeded from the log (fork-safe); LoopAgent's
stop disposer is infallible (a throwing status listener cannot skip
registry cleanup); AgentLoop.create uses a generator effect so stop
and unregister are independent disposables; SessionStore wires
onAppend inside its effect.

21 regression tests added (review-fixes.spec.ts), organized by
finding. Docs updated: loop pseudocode (status emissions, ordering,
error containment, steering guarantees) and waterfall composition
caveat in docs/architecture.md; AGENTS.md notes that excessive tests
are welcome.
2026-06-11 12:19:16 +08:00
Tianyi Cui d5a1d9bb75 Add abstract service interface packages
@deepseek-ai/dsh-llm: provider-neutral content-block vocabulary
(merge-extensible maps), raw StreamChunk protocol, ToolSchema,
abstract LlmAdapter, LlmService adapter registry, BlockAssembler.

@deepseek-ai/dsh-session: event-sourced Session (append-only log,
deriveMessages; context/steering render as tagged envelopes),
SessionStore, session/event + awaited session/flush durability seam.

@deepseek-ai/dsh-system-prompt: ordered sections + tool-schema
providers; assemble() through the system-prompt/assemble waterfall.
Tool schemas are part of the assembly by design.

@deepseek-ai/dsh-tools: tool registry feeding schemas into the
assembly; execute() through the tools/execute waterfall (the single
sandbox/permission/hook seam).

@deepseek-ai/dsh-agent: Agent interface (send/steer/inject/abort,
spawn/fork TODO seams), AgentRegistry, and the full agent/* event
taxonomy so plugins never depend on the concrete loop.
2026-06-11 10:54:06 +08:00