Oversized plain-text tool results now spill to a session-scoped file and
return a bounded preview plus the spill path, so a verbose result stays
readable via `read` without consuming the next model request in full.
- dsh-spill: minimal SpillFiles seam (saveText → session-scoped SpillPath)
- dsh-spill-local: private 0700 session dirs, traversal-safe names, exclusive
owner-only writes
- dsh-spill-policy: tools/post-execute transformer; no-op unless maxInlineBytes
is set; skips read; best-effort on save failure (never turns a success into
an isError)
web_fetch is the showcase — no tool-specific spill code. The coding-agent
example loads the stack so its keyless Loader smoke guards the namespace-plugin
export shape. Snapshot gap for a transcript-visible web_fetch spill is recorded
in the RFC's Consequences (ACP replay is keyless and cannot hit the web).
The timeout convergence on this branch made timedOut/aborted mutually
exclusive — one fused deadline reports the single cause that first cut the
command short — as the timeout-library RFC documents deliberately. The seam
type's JSDoc still described the old independent latches, so a consumer could
code the wrong contract. State first-cause classification on both fields and
cross-link the RFC. Docs-only; the code already matches.
finish() derived the exact omitted count from the pre-trim prefix/suffix
budget, but the UTF-8 boundary trims drop additional partial-codepoint bytes,
so an "Omitted N bytes" notice overstated what was kept (head maxBytes:2 over
'a€b' returned 'a' but claimed exact 3 when 4 bytes are absent). Derive the
count from the bytes actually returned (total − keptPrefix − keptSuffix) so
exact metadata matches the text and atLeast stays a valid lower bound.
When headTail budgets cover the whole stream, omitted is 0 and the two
retained halves are contiguous — the split is artificial and a multibyte
codepoint can straddle it. finish() now decodes the contiguous buffer as one
in that case; the per-side UTF-8 boundary trims and separate decoding apply
only when a real middle gap exists. Without this, a headTail retainer could
drop a character while reporting truncated:false.
Ship @deepseek-ai/dsh-retention under packages/util/: pure ItemRetainer /
TextRetainer plus neutral notice helpers, so tools that cap model-facing
output share one "what did we keep, what did we omit, may we stop reading"
mechanic while keeping grouping, exit codes, provider errors, and recovery
prose tool-owned. The two retainers are separate names because they differ in
resource model: item-head can stop the upstream on the first over-cap probe
(shouldStop), while text tail/head-tail must read to the end. The library
documents glob/grep/bash/web_fetch/web_search mappings but migrates no tool
yet — glob/grep don't exist, and migration is deliberately separate work.
Flips the RFC to implemented/ and rewrites its skeleton to shipped reality.
Round 2 P2: timeoutOf() accepted ANY TimeoutReason, so under nesting — when
the upstream handed to deadline() is itself a deadline (the RFC's named
tools/execute middleware follow-up) and its outer timer fires first —
AbortSignal.any preserves the outer reason and the inner bash/web would report
the outer timeout as their own (timedOut / WEB_FETCH_TIMEOUT) though their local
timer never expired. Add an optional code to timeoutOf; bash and web pass their
own code, so a foreign timeout falls through to the upstream-cancel path.
Round 1 P3: the "No timeouts on file IO" section left the file ending in a
blank line, which the trailing-newline whitespace gate rejects. Declined P2
(late abort after a timeout is lost): that is the RFC's decided trade-off —
mutually-exclusive first-abort classification — and re-latching aborted would
violate the acceptance criterion.
Timeout timing/classification was re-implemented three ways across the
tool-bearing capabilities, with the fusion of timeout+cancel and the
timeout-vs-cancel reason recovery being the error-prone parts. Extract that
shared half into a zero-dependency @deepseek-ai/dsh-timeout library
(clampTimeout/deadline/timeoutOf/TimeoutReason) and leave the non-shareable
hard-kill in each capability, per the timeout-library RFC.
bash: run() owns the deadline; runBash drops its killTimer and no longer
classifies (SpawnSpec/SpawnOutcome lose timeoutMs/timedOut/aborted), so the
public timedOut/aborted booleans become mutually-exclusive first-abort
classifications. web_fetch: the hand-rolled controller/timer/listener/
signal.reason dance is replaced by provider-owned deadline/timeoutOf, keeping
the WEB_FETCH_TIMEOUT / WEB_ABORTED contract. fs stays timeout-free (README
states why).
Verified against gh: --base filters server-side and exactly, so any
open dependent guarantees a non-zero print, but --jq length counts the
fetched page and gh's default --limit is 30 (--limit 5 prints 5 when
the true count is 20). The printed number is min(actual, limit), so the
skill no longer claims it as the auto-close count; only 0 clears a
delete.
Review comment on the verify step: dumping every open PR under
--limit 1000 wastes agent context and re-truncates past 1000. Ask the
exact question server-side instead: gh pr list --state open
--base <branch> --json number --jq length, whose output is a single
count that is exact at any number of open PRs. Skill, checklist, and
the cookbook dependent-check line all use the per-branch form.
Second review round: 'gh pr merge' can return after merely enabling
auto-merge when required checks are pending or a merge queue is active,
so retargeting the next PR could run against a master that does not yet
contain the parent. The procedure and checklist now require
'gh pr view <n> --json state' to report MERGED after every merge.
Codex review caught three procedure defects: the dependent check used
bare 'gh pr list', whose default pagination can hide an open dependent
and make branch deletion look safe (now --state open --limit 1000); the
refresh step merged local master, which 'gh pr merge' leaves stale (now
fetch and merge origin/master); and the quick checklist omitted pushing
the dependent after the local merge.
Commit the stacked-PR landing procedure as a repo skill so every
collaborator's agent discovers it: merge bottom-up, retarget and refresh
each dependent, and delete no branch until the whole stack has landed
(deleting a base branch auto-closes the open PR that bases on it).
The responding-to-pr-review-on-a-stack cookbook guide's dependent-check
step links the skill as the full landing procedure.
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.
This branch's two RFCs (prompt-variables, provider-lifecycle events)
conform to the new contract in the same merge: 'Status: implemented'
dateless header, 'Rejected alternatives' -> 'Alternatives considered',
'What we give up' folded into '## Consequences' (per the uniform-format
RFC's own rejection of a costs-only closer), and the index regenerated
into the new INDEX.md. docs/rfc/README.md resolved to master's rewrite.
Two findings from the pre-ready review, both verified:
- verify-rfc-format scanned raw lines, so an RFC quoting a Status line,
a banned heading, or the grandfather comment inside a fenced example
would false-positive. The content scans (duplicate Status, H2 headings,
banned headings, grandfather, legacy marker) now ignore fenced blocks;
the positional header-block checks stay raw. Verified: a fenced
'Status: implemented' + '## Plan' + grandfather quote inside a walked
RFC no longer trips the gate.
- The companion RFC's pre-format corpus counts were imprecise: 27
distinct Status spellings (reasons collapsed), not 'some fifteen';
nineteen implemented files carrying thirty proposal-era heading
occurrences, not 'over twenty files'; three English files (plus one zh
counterpart) with no status, not four.
The subagent/provider-added and subagent/provider-removed events carry a
decision of their own — reactive consumer registration instead of a
load-order requirement the cordis Loader never guaranteed — buried in a
section of the prompt-variables RFC. Extract it: the mechanism, the
Loader-concurrency evidence, the rejected alternatives (apply-time
lookup, polling, assemble-time wording, name-keyed wording), and what we
give up (the absent-tool window, the dup-toolName blast radius) move to
2026-07-05-subagent-provider-lifecycle-events.md; the prompt-variables
RFC keeps the context contract and points there; index regenerated.
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).
Define the in-file RFC contract in docs/rfc/README.md § The file format:
the header block (`# RFC: <title>` plus a dateless Status enum
cross-checked against the lifecycle folder), the per-lifecycle body
skeleton (a Problem opener everywhere; Proposal/Alternatives considered/
Acceptance criteria/Risks in proposed/; present-tense Decision/
Consequences with proposal-era headings banned in implemented/; the
frozen proposal shape in rejected/), and a mandatory Alternatives
considered section with a date-fenced grandfather comment for pre-format
RFCs whose alternatives are not reconstructible from the record.
Enforce it with a new doc-sync gate, scripts/verify-rfc-format.ts, and
normalize all 112 RFCs to it: ~15 Status-line spellings collapse to the
enum, 29 Context openers become Problem, the 39 legacy-format XXX debt
markers are resolved and banned from reappearing, proposal-era sections
in implemented RFCs are rewritten to shipped reality (including the
web/fs/subagent seam RFCs' migration plans and test checklists, closing
the doc-tiers deferred-work item on the web seam), every RFC gains an
Alternatives considered section or the grandfather comment, and the
bilingual pair is re-mirrored and re-recorded.
Move the generated index tables out of README.md into a fully generated
docs/rfc/INDEX.md — gen-rfc-index now writes the whole file, and
verify-rfc-classification checks its freshness and rejects index-shaped
rows in the curated README — which makes room for the format contract to
live in the README front door instead of a separate FORMAT.md.
The decision record, and the first RFC written in the new format, is
docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md.
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
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 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-2 findings:
- A committed test now proves the REAL plugin fiber (not a direct apply)
owns the provider-lifecycle listeners: disposing a mounted tool's fiber
unmounts the tool and leaves the provider intact, and a fiber disposed
while WAITING never zombie-mounts when its provider arrives later.
- TODO(subagent-dup-toolname) records the invalid-config blast radius of
two waiting fibers sharing a toolName (the duplicate throw propagates
through subagent/provider-added and rolls back the provider).
- CONTEXT.md drops its creation-history sentence; the RFC's acceptance
checklist becomes present-tense shipped invariants (docs/AGENTS.md
writing rules).
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.
The repo's docs are read by people and agents without access to the
company wiki, so external Feishu links are dead weight: the two design-doc
links (MVP requirements, microkernel implementation notes) leave
AGENTS.md's intro and docs/architecture.md (usages + link definitions),
and the microkernel-event-taxonomy RFC drops its plain-text pointer to the
same design doc. The governing principle each reference decorated is
already stated in place; no content moves.
Master split the cordis catalog (d55d4120) while this branch was in review.
Resolution: keep this branch's Service map restructure, adopt master's split
catalog link targets (events.md / services.md), and regenerate the catalog so
the waterfall-semantics anchor fix lands in the split output.
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