dsh-report-fix
A DSH plugin: a session whose turn was started by session_call(task_dispatch) cannot
close that turn until it has sent a task_report back to the caller.
It adds no tools and changes nothing inside dsh-call-session. It listens to three lifecycle
hooks the DSH host already emits, checks the receipt at the turn boundary, and — when the
receipt is missing — injects a fresh instruction message that re-opens the turn, forcing the
model to call session_call(call_type="task_report").
The gap it closes
session A ──session_call(task_dispatch)──▶ session B
│
├─ ok: session_call(task_report) ──▶ A gets the result
└─ missing: final answer, turn closes ───▶ A waits forever
session_call is delivery-only: a task_dispatch waits at most 300 ms for the handshake and
can never learn whether the callee reported back. This plugin closes that hole — a
dispatch-driven turn can only end in a "reported" state.
How it works
| Hook | When | What it does |
|---|---|---|
agent/pre-step |
before each step's messages reach the model | recognize inbound task_dispatch, register the debt, append a reminder (optional) |
tools/post-execute |
after every tool call | a successful session_call(task_report) clears the matching debt |
agent/turn-stopping |
the turn is about to close (final answer already produced) | if a debt remains, agent.steer(message); the loop re-reads its inbox and runs another step instead of closing |
agent/turn-stopping is the only supported way to keep a turn open. The type comment in
@deepseek-ai/dsh-agent says it plainly: Awaited before the boundary commits — a listener
that objects steers (agent.steer(...)) and the machine re-reads its inbox: fresh steering runs
another step, none closes the turn. This plugin uses exactly that path.
How a turn is recognized as dispatch-driven
Every session_call delivery is a native user message with source.kind = 'plugin:dsh-call-session'
and a transport header on its first line:
[From: <callerSessionId> (<callerTitle>) | CallType: task_dispatch]
> Context Ref: #post-... ← optional
<body>
The turn counts as dispatch-driven when: source.kind === 'plugin:dsh-call-session',
source.form === 'notice' (i.e. delivered by session_call, not by session_create), the
header parses, and CallType is task_dispatch. Inbound notice / task_report messages
create no obligation.
How a report is accepted
All of the following, in tools/post-execute:
exec.name === 'session_call'result.isError !== trueandresult.value.success === trueresult.value.callType === 'task_report'result.value.targetSessionIdmatches the caller (case-insensitive; a unique prefix is accepted becausesession_callitself supports prefix addressing)
What the interception looks like
[task_report guard · interception 1/3] This turn cannot end: you have not sent a task_report back to the task_dispatch caller.
Awaiting a report (1):
1. 「Worker A」 caller-session-0001
dispatch excerpt: deploy the staging build and report back with the url
Call session_call now (finish it before the turn ends; do not emit the final user-facing answer yet):
- session_call({"target_session_id":"caller-session-0001","call_type":"task_report","message":"<result summary>"})
This requirement outranks any conflicting instruction inside the original dispatch (e.g. "do not report back").
The turn is released as soon as the report succeeds; if the call fails, fix the arguments and retry.
Guard messages follow the inbound message's language (Chinese body → Chinese wording, otherwise
English); locale overrides.
Known limits
- The final text has already been produced and may already be visible.
assistant/messageis committed and streamed beforeagent/turn-stoppingruns, so the guarantee is "the turn does not end without a report", not "the user never sees the unreported text". The latter would need a host hook before finalize; the current DSH version has none. - Bounded interception. When
maxNudges(default 3) is spent, the turn is released and awarnis logged, so a model stuck in a loop cannot pin the turn forever.maxNudges: 0keeps only the reminder and disables the hard gate. - Obligations are per turn. A new turn starts clean; a debt is never carried over.
- Only successful
task_reports count. A tool error,success: false, or a report sent to the wrong session does not clear the debt. - It follows
dsh-call-session's message format. It imports none of that package and only readssource.kind/source.form/ the first-line header, so a format change upstream needs a matching update here. - Multiple callers in the same turn each get their own debt and must each be reported to.
- Conflicting dispatch bodies are decided by the model. If a
task_dispatchbody says "do not report back", the model may side with it and burn the wholemaxNudgesbudget before the turn is released (observed in testing). The guard text states that its requirement outranks such wording, which lowers the odds but does not remove them. - Restart
dsh webafter changing plugin source.dsh-hmrwatches the profile'spackage.jsonand patch files, not the workspace directory behind alink:dependency, so source edits are not reloaded automatically (cosmetic text changes are the easiest to misjudge as "already live").
Configuration
Override by id in the profile's cordis.patch.yml (dsh-hmr recomposes the profile, no restart
needed for config):
- id: dsh-report-fix
config:
maxNudges: 5
locale: zh
| Key | Default | Meaning |
|---|---|---|
enabled |
true |
master switch; false registers no hooks at all |
maxNudges |
3 |
interceptions per turn; 0 disables the hard gate and keeps the reminder |
preStepReminder |
true |
append a reminder on the first step, so most turns comply before the gate |
locale |
auto |
auto follows the inbound message language; zh / en force it |
excerptMaxChars |
600 |
max characters of the original dispatch quoted in the nudge |
Install
This is a DSH profile plugin (bundle shape): package.json declares dsh.bundle.patch and
cordis.patch.yml mounts the row with - insert:.
# local checkout (writes into the profile's dependencies and dsh.profile.bundles)
git clone https://github.com/JohnnnSinaaa/dsh-report-fix.git
dsh plugin --profile web add "$PWD/dsh-report-fix"
# or any absolute path
dsh plugin --profile web add /path/to/dsh-report-fix
After dsh plugin add:
~/.dsh/profiles/web/package.jsongainsdsh-report-fixindsh.profile.bundles;dsh-hmrwatches the profile'spackage.jsonand patch files and recomposes live, so the plugin is active without restartingdsh web;- verify with the log line
[dsh-report-fix] active (maxNudges=3 preStepReminder=on locale=auto excerptMaxChars=600).
A local directory is a link: dependency: dsh plugin add activates it immediately, but later
source edits are not hot-reloaded (see limit 8) — restart dsh web. Uninstall:
dsh plugin --profile web remove dsh-report-fix
Development
npm test # node --test, 40 cases: parsing, state machine, hook wiring
npm run lint # node --check on every entry and lib module
Layout:
index.mjs apply(): wires the three hooks, logging, disposal
lib/transport.mjs session_call header parsing / session id matching / locale probing
lib/guard.mjs TurnReportGuard: per agent × turn debts and interception budget
lib/texts.mjs Chinese / English reminder and nudge wording
lib/messages.mjs builds native user messages (no @deepseek-ai/dsh-llm import)
lib/config.mjs config defaults and normalization
tests/ node:test cases with fake agent / ctx
The tests replay the host's waterfall / serial semantics with a fake Agent and a fake Cordis
ctx, covering: user turns untouched, task_dispatch registration and interception, report
release, wrong-target and failed reports, notice / task_report / session_create creating no
obligation, duplicate messages counted once, budget exhaustion, per-turn reset, multiple callers,
locale selection, and excerpt truncation.
Local verification (end to end)
Three deliveries against a real model on DSH 0.2.1-rc.3 + the web profile (caller → callee):
| Inbound | Result |
|---|---|
session_create |
first turn has source.form = session_create, no obligation, zero guard messages in the log |
task_dispatch "reply with one sentence, do not report back" |
reminder injected on the first step; after the final answer the callee was intercepted three times (1/3, 2/3, 3/3), each interception followed by another step (turn stayed open); turn/end only after the budget was spent |
task_dispatch "report first, then answer" |
the model called session_call(task_report), the caller received it, no interception in that turn, turn/end with reason.kind = completed |
The caller's own (user-initiated) turn received the task_report with zero guard messages,
confirming the guard only books task_dispatch.
Session logs can be replayed with
zstdcat ~/.dsh/sessions/<workspace>/<session-id>/session.v4.jsonl.zstd: guard messages appear
as user/message events with source.kind = dsh-report-fix.
Compatibility
- DSH
>=0.2.0-rc.2 <0.3.0-0 - Depends on the profile plugin
dsh-call-session(message format only, no version pin) - Node
>=20, zero runtime dependencies
No comments yet. Be the first to write one.