fix(loop): record a durable prompt/blocked for every vetoed prompt

Address review on the interception-seams PR: PromptDecision.reason is
documented as the durable record of why a prompt was blocked, but the loop
only surfaced it via the fully-blocked batch's `rejected` turn/end. In a MIXED
batch — one queued prompt blocked, another allowed — the turn does not end
`rejected`, so the blocked prompt and its reason vanished from the session log
entirely.

Add a `prompt/blocked` SessionEventMap variant (content + source + reason),
appended in the open turn at the veto point in place of the user/message the
prompt would have become. It is a non-surface, turn-enclosed event (like
todo/write): it never reaches deriveMessages(). The fully-blocked batch still
also ends `rejected` for boundary balance + ACP settlement. Regression test
drives a mixed batch and asserts the blocked prompt is recorded while the
allowed one runs — proven red without the append.
This commit is contained in:
Tianyi Cui
2026-07-02 17:03:53 +08:00
parent d3be934a17
commit abe80cec68
10 changed files with 101 additions and 22 deletions

View File

@@ -16,7 +16,7 @@ Add/reshape the interception seams so every one returns a small, seam-specifi
**New `agent/*` events** (`dsh-agent`):
- `agent/session-start(agent, source)` — emit, once before turn 1, carrying a `SessionStartSource` (`startup` for a fresh/forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it CANNOT block startup (a deliberate gap: a bridge logs/injects, it does not gate startup). A listener seeds context via `agent.inject()`.
- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block`.
- `agent/prompt-submit(agent, content, source, next) → PromptDecision` — waterfall, fired per drained queued message inside the open turn, before the `user/message` append. `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (dropping the prompt; the loop appends a durable `prompt/blocked` in its place — see the dispatch note below).
**Reshaped** `agent/turn-continuation` from `(…, defaultDecision: boolean) → boolean` to `(…, defaultDecision: ContinuationDecision) → ContinuationDecision`. A `{action:'continue', reason?}` may carry model-facing context recorded as next-step steering in the same turn — the typed twin of the existing `/goal` step-end-steer pattern.
@@ -26,7 +26,7 @@ Add/reshape the interception seams so every one returns a small, seam-specifi
### Three load-bearing loop decisions
1. **Always open the turn first; a fully-blocked batch is a zero-step `rejected` turn.** `prompt-submit` fires AFTER `turn/start`, per message. A batch whose every prompt is blocked does NOT skip the turn — it opens a zero-step turn that closes with `rejected`. This one move resolves three problems at once: (1) turn-enclosure holds (every event has an open turn to live in); (2) the durable `turn/end` is appended and the ACP bridge settles normally off it (mapping `rejected``cancelled`) instead of hanging; (3) the block reason is a durable in-turn fact. An `allow`'s `additionalContext` is `inject()`ed into this now-open turn.
1. **Always open the turn first; a fully-blocked batch is a zero-step `rejected` turn; every veto is recorded as `prompt/blocked`.** `prompt-submit` fires AFTER `turn/start`, per message. A batch whose every prompt is blocked does NOT skip the turn — it opens a zero-step turn that closes with `rejected`. This one move resolves three problems at once: (1) turn-enclosure holds (every event has an open turn to live in); (2) the durable `turn/end` is appended and the ACP bridge settles normally off it (mapping `rejected``cancelled`) instead of hanging; (3) the block reason is a durable in-turn fact. Independently, each individual veto appends a `prompt/blocked` session event (the original `content`, `source`, and `reason`) in place of the `user/message` the prompt would have become — necessary because a MIXED batch (one prompt blocked, another allowed) does NOT end `rejected`, so the boundary reason alone would silently lose the blocked prompt on replay. An `allow`'s `additionalContext` is `inject()`ed into this now-open turn.
2. **Post-tool `additionalContext` is buffered and appended AFTER all `tool/result`s.** `content`/`feedback` shape the result `execute()` returns, but `additionalContext` is a SEPARATE `context/message`, and a single step can carry multiple tool calls. Appending context right after each result would interleave `result(c1) → context → result(c2)` and break tool-call/result adjacency. So `execute()` surfaces `additionalContext` on its `ToolExecutionResult`, and the loop buffers every per-call context for the step and appends them as `context/message`(s) only after every `tool/result` is appended.