Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions devlog/_plan/260831_kiro_pause_path_and_answer_shape/000_research.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# Kiro post-answer tool calls, round two: why the prompt fix failed

The user reported the same defect a second time, with a hard pair of
requirements: never recur, and do not regress anything.

## The first fix is live and did not work

PR #3012 (`f5a625cf3`) appended terminal semantics to both injected surfaces.
That code was serving when the defect recurred:

| Fact | Value |
| --- | --- |
| Serving proxy | PID 55727, port 10100, version 2.37.0 |
| Proxy start | 09:23:37 |
| Adapter file mtime | 01:28 (same day, before the start) |
| `f5a625cf3` ancestry | ancestor of the serving HEAD |
| Failure | 09:27 |

So prompt wording is not a sufficient mechanism for this defect. That is the
finding that governs this unit.

## What actually happened

A 1454-char answer-shaped message and the next `exec` call came out of ONE
inference, 4ms apart (message `00:27:23.114`, call `00:27:23.118`), with
`sendCount: 1` and no second upstream request. This was never "the model keeps
working after the turn ended" — there was no second turn to keep working in.

The message text is the proof. It ends:

> ...이 호출 경계 동작은 문서에 아직 정확히 안 들어가 있습니다. 넣어둘까요.레퍼런스에
> 있는 "scope persists" 서술이 실측과 어긋나니 그 부분부터 고치겠습니다.

A permission question, glued with no separator to a sentence that overrides the
question and proceeds. Two emissions merged into one message: the model asked
the user something and then answered itself, inside one inference.

The adapter behaved exactly as designed. `defer()` holds staged text, a real
tool call proves the turn continues, and the staged prose is released as
`commentary`.

## Root cause: there is no sanctioned way to pause

In `required` mode the model has three expressible moves and none of them is
"I have a question, hold for the user":

- Ordinary prose does not end the turn, by explicit contract.
- The completion tool means "the task is fully complete and no more work or tool
calls are needed" — which a pending question is not.
- A real tool call continues the turn.

`KIRO_COMPLETION_RETRY_MESSAGE` closes the last door explicitly: *"Do not ask
the user for another task or emit another progress-only message."*

So a model that wants to ask has no explicitly endorsed blocking-question move.
Pausing is mechanically expressible — the ask tool is advertised and works — but
nothing in the injected contract endorses it, and the one instruction the model
sees at the moment it fails to complete reads as a ban on asking. Continuing to
work is the only move the contract actively endorses, which is what it did.

## The regression boundary, measured

Across 644 Kiro rollouts, same-inference prose (>=600 chars) followed by a real
tool call occurs 26 times. Only 4 have a tail that asks the user something. The
other 22 are ordinary progress narration and must keep working unchanged.

Prose length cannot separate them:

- 4 question-tailed: 1329, 1454, 1697, 1938 chars.
- 22 legitimate: 608 ... 3141 chars.

The ranges overlap completely. Any length or volume threshold that catches the
four also catches legitimate narration, and any threshold that spares the 22
also spares the defect. There is no structural discriminator for the general
prose-plus-tool shape, which an independent read-only design audit confirmed
from the code: at `flushOpen` the adapter knows only that a non-completion tool
was emitted, its restored identity, and its arguments. `stopReason` cannot help
either — Kiro emits `END_TURN` for progress prose, and the adapter deliberately
ignores it when a real tool exists.

## Rejected candidates

**Gate the answer-shaped prose.** Rejected: it requires a text heuristic to tell
the 4 from the 22, and the measurement above shows no non-heuristic signal
exists. A regex would decide whether the user sees their agent's work.

**Discard prose when a real tool follows** (what `consumeSupersededByCompletion`
does on the completion path). Rejected: on the real-tool path no replacement
answer ever arrives, so all 22 legitimate progress messages would silently
vanish rather than arrive later.

**Isolate the ask tool** (relay `request_user_input`, fail closed on any later
real tool from the same inference). Rejected as the primary mechanism: measured
8 `request_user_input` calls across 644 rollouts, and the
ask-then-another-tool shape occurs **0** times. It guards a case that does not
happen. Shipping a guard for an unreachable path with a test that cannot fail is
the exact trap this unit's predecessor already fell into once.

## What the evidence does support

`request_user_input` already terminates a Kiro turn cleanly. Measured three
times in the failing session itself: staged prose, then the ask call, then the
turn yields and the human answers. The pause path exists and works — the model
simply did not take it, because the contract never told it to and the retry
message reads as a ban on asking.

The completion channel is also content-agnostic: a completion answer yields
`final_answer` and `done(endTurn: true)` regardless of what the answer says. A
question delivered there ends the turn correctly today.

So the gap is not enforcement, it is expressibility. The contract describes two
states (still working, fully done) for a model that has three (still working,
done, blocked on the user). The fix is to make the third state expressible and
to stop forbidding it.

## Honest statement of limits

This unit cannot make the defect impossible without model compliance, and it
will not claim otherwise. Deterministic prevention would need a structural
upstream signal that does not exist: Kiro accepts only automatic or no tool
choice, so the proxy cannot force a typed progress/pause/complete protocol. The
bad and good event streams are observationally equivalent at the adapter.

What this unit can do is remove the contradiction that made the defect the
model's *only* endorsed move: make the blocked-on-user state expressible, and
stop forbidding it. That is one change and it is testable. It introduces no
adapter-side regression for the 22 — the non-regression test guards their
structural output byte for byte — but it is a prompt change, so it can still
shift what the model chooses to emit. Adapter behavior is pinned; model
behavior is influenced, not fixed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
# wp2 — make the blocked-on-user state expressible

One change, in the injected contract, carried on every surface that describes
when to complete. It removes the contradiction that made continuing to work the
model's only endorsed move. No adapter enforcement is added, because no
non-heuristic signal exists to enforce on.

## A blocking question is a valid final answer

The pause state rides the channel that already terminates correctly. A
completion answer yields `final_answer` and `done(endTurn: true)`
**regardless of what the answer says** — verified in `kiro.ts` at the
completion terminal. So a question delivered there ends the turn today, with no
new event type, no new phase, and no change to `endTurn` semantics for real
tools.

`KIRO_COMPLETION_INSTRUCTIONS` therefore gains one clause: when the model needs
a decision from the user before it can continue, that question IS its final
answer — deliver it through the completion tool and stop, instead of writing the
question as prose and then answering itself.

The completion tool's own schema carries the same clause. That description is the
surface the model reads while CHOOSING a tool, and it admitted only "fully
complete"; a model holding a blocking question reads the tool as unavailable and
keeps working, which is the defect. Round one changed only the terminality half of
this description and left the eligibility half narrow, which is part of why prose
alone did not move the outcome. The `answer` property description is widened the
same way, since it is what tells the model what may go in the field.

This is deliberately NOT conditioned on an ask tool being present. An earlier
draft keyed it on `request_user_input` appearing in the emitted catalog, which
would have named a tool that is absent from most turns and contradicted the
nudge's rule that instruction-only names are not callable (the failure mode
`0325a5afd` fixed for the catalog nudge). The completion tool is always present
when this instruction is emitted — that is the condition under which the
instruction exists at all — so the clause can never name something uncallable.

`KIRO_COMPLETION_RETRY_MESSAGE` currently ends with *"Do not ask the user for
another task or emit another progress-only message."* In context that forbids
soliciting NEW work, but it reads as a blanket ban on asking, and it is the one
instruction the model sees at exactly the moment it failed to complete. It is
narrowed to keep forbidding a request for another task while making a blocking
question an explicitly acceptable final answer.

Both sites are compared by constant, never by inline literal
(`currentUim.content !== KIRO_COMPLETION_RETRY_MESSAGE`), and both pinning
tests assert against the constant, so editing the text cannot desynchronize the
equality checks that keep the bounded retry from stacking.

This depends on model compliance and is labeled as such. It is the reason this
unit does not promise impossibility: the tests prove the contract is delivered,
not that the model obeys it.

## Ask-tool isolation: considered and dropped

An earlier draft added a `flushOpen` invariant: once a turn emitted the ask
tool, a later real tool call from the same inference would fail closed. It is not
being implemented, for three independent reasons.

It is unreachable. Measured across 644 Kiro rollouts: 8 `request_user_input`
calls, and the ask-then-another-real-tool shape occurs **0** times.

It cannot be keyed safely. `nameMap` aliases every REQUESTED tool before
admission, and admission can omit tools, so `nameMap` membership does not prove
a tool was emitted. Doing it correctly means threading an explicit emitted-ask
identity through `buildRequest`, the fallback state, and `parseKiroStream`.

It can false-positive. `OcxTool` carries no "asks the user" marker, and the
parser reduces ordinary function tools to the same generic shape, so an unrelated
bare function named `request_user_input` is indistinguishable from Codex's ask
tool. Claiming it "cannot affect any ordinary tool" was an overclaim.

Three costs and a measured zero benefit. The previous unit already shipped and
then reverted a guard for an unreachable path; this one declines it before the
commit instead of after.

## What is NOT changed, and why

No prose-shape gate. No length threshold. No discard of staged commentary. The
measurement in `000_research.md` shows the 4 defect cases (1329-1938 chars) and
the 22 legitimate ones (608-3141) overlap completely, so every such rule is a
coin flip on whether the user sees their agent's work.

No change to `src/router.ts`, `src/server/lifecycle.ts`, or
`src/server/responses/core.ts`: the Lab core boundary is unrelated and
`tests/core-lab-boundary.test.ts` guards it. No change to the shared
`tool-catalog-nudge` sentence or its pinned test.

## Tests

**Red-first, contract shape:** every emitted completion contract carries the
pause clause, and the retry message permits a blocking question as a final
answer. Driven red against the current strings before the edit.

**Non-regression, the 22:** staged progress prose followed by `exec` still
yields the prose as `commentary` and `done(endTurn: false)` with the tool call
relayed, byte-identical to today, parameterized across the measured lengths
608 / 1329 / 1454 / 1938 / 3141. This is the test that protects the user's
"회귀없도록" requirement, and it must pass unchanged both before and after the
diff.

## Budget truncation: measured unreachable, pinned by test

The completion contract is charged last against
`MAX_KIRO_INJECTED_INSTRUCTION_CHARS` (16384), so in principle a large enough
injected context could slice the pause clause mid-sentence. Measured against a
hostile catalog (80 requested tools, 64-character names, 6000-character
descriptions):

| Charged item | Chars |
| --- | --- |
| Omission notice | 922 |
| Catalog nudge | 2432 |
| Total before the contract | 3354 |
| Headroom | 13030 |
| Contract | 696 |

Both charged inputs are structurally capped — the notice names at most 12 tools,
the nudge is bounded by the 48-tool and 64-character limits — and the caller's
system prompt is not charged to this budget at all. So no reachable input
approaches the slice boundary.

A reservation guard is therefore dead code, and the previous unit already proved
its test would be vacuous: it passed with the guard removed. Instead this unit
pins the property that makes truncation unreachable, at BOTH hostile extremes,
asserting the contract arrives COMPLETE including its closing pause clause:

- **Largest nudge:** `MAX_KIRO_TOOL_COUNT` admitted tools with unique 64-character
names and one-character descriptions. Every admitted tool is named in the nudge,
so this maximizes the charged nudge. Nothing is omitted, which the test asserts
so it cannot pass while silently charging less than it claims.
- **Notice plus nudge:** oversized descriptions on twice the tool limit, which
forces admission to omit and charges the omission notice on top of the nudge.
The test asserts the notice is actually present for the same reason.

The 6000-character-description shape alone would not have been enough: it reduces
the admitted count and therefore shrinks the nudge, so it never probes the
largest charged input. Both tests fail if a future change lets the notice or the
nudge grow without bound. A reservation's test could not detect its own removal.

## Verification

`bun run typecheck`, the focused Kiro suites, and `bun run privacy:scan`. The
full local suite is excluded by explicit user instruction; CI covers it.
Comment on lines +141 to +144

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Move the completed work unit out of _plan

This work package records its implementation and terminal verification in the same commit, but it is added under devlog/_plan, which advertises it as still open. Move the unit to devlog/_fin now that the outcome and code are present in public history so the repository’s planning state remains accurate.

AGENTS.md reference: AGENTS.md:L83-L86

Useful? React with 👍 / 👎.

2 changes: 1 addition & 1 deletion docs-site/src/content/docs/fr/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ Le texte de l’assistant Kiro ne comporte pas, à lui seul, de phase fiable ind

`END_TURN`, `STOP_SEQUENCE` ou l’absence de motif d’arrêt peuvent emprunter le chemin de compatibilité. Les autres motifs explicites ont déjà interrompu l’inférence en amont ; l’adaptateur les signale donc au lieu de consommer une nouvelle requête au modèle. Une limite de jetons de sortie est présentée comme une sortie incomplète que le client peut poursuivre, tandis qu’un épuisement de la fenêtre de contexte devient une erreur de longueur de contexte non renouvelable plutôt qu’une sortie tronquée. Les arrêts dus au filtrage et aux garde-fous sont présentés comme des sorties filtrées incomplètes. Un arrêt `TOOL_USE` sans appel d’outil réel est signalé comme une contradiction, et non comme une progression.

Lorsqu’un outil client ordinaire est disponible, opencodex ajoute un outil privé `codex_kiro_final_answer` à la requête en amont. Le texte de progression est diffusé comme commentaire et ne peut pas clore le tour. L’adaptateur consomme l’appel privé, émet sa réponse comme texte final et n’expose jamais cet outil privé à Codex ou Claude Code. Le motif d’arrêt n’arrivant qu’à la fin du flux, le texte de l’assistant est retenu pendant un tour avec outils jusqu’au début d’un véritable appel d’outil ou jusqu’à la fin du flux. Il est alors libéré comme commentaire, sauf si l’outil privé a fourni la réponse finale. Lorsque le service auxiliaire de recherche Web est actif, les commentaires libérés continuent d’être diffusés avant l’événement terminal ; seuls les événements nécessaires pour déterminer si le modèle a demandé une recherche synthétique restent en mémoire tampon.
Lorsqu’un outil client ordinaire est disponible, opencodex ajoute un outil privé `codex_kiro_final_answer` à la requête en amont. Le texte de progression est diffusé comme commentaire et ne peut pas clore le tour. L’adaptateur consomme l’appel privé, émet sa réponse comme texte final et n’expose jamais cet outil privé à Codex ou Claude Code. Le motif d’arrêt n’arrivant qu’à la fin du flux, le texte de l’assistant est retenu pendant un tour avec outils jusqu’au début d’un véritable appel d’outil ou jusqu’à la fin du flux. Il est alors libéré comme commentaire, sauf si l’outil privé a fourni la réponse finale. Lorsque le service auxiliaire de recherche Web est actif, les commentaires libérés continuent d’être diffusés avant l’événement terminal ; seuls les événements nécessaires pour déterminer si le modèle a demandé une recherche synthétique restent en mémoire tampon. Lorsque le modèle ne peut pas continuer sans une décision, une information ou une clarification que seul l’utilisateur peut fournir, le contrat lui demande de transmettre cette question via l’outil d’achèvement et de s’arrêter ; un tel tour arrive lui aussi comme `final_answer` avec le tour clos, et non comme un commentaire.

Si Kiro s’arrête sans appeler l’outil d’achèvement, l’adaptateur effectue une continuation. Les nouvelles tentatives ne contenant que du raisonnement conservent le tour utilisateur/résultat d’outil valide d’origine au lieu de fabriquer un message assistant vide ; la progression visible est rejouée avec une instruction non vide appartenant à l’adaptateur. Avant le transport, la conversation générée est contrôlée afin de vérifier l’alternance des rôles, l’absence de tours structurels vides et la correspondance des identifiants d’utilisation et de résultat d’outil. Une sortie d’outil vide reçoit un texte de remplacement neutre et non vide. La nouvelle tentative ne peut pas se répéter récursivement : si elle est vide ou ne contient que du raisonnement, elle est renvoyée comme incomplète mais renouvelable ; un véritable appel d’outil client maintient le tour ouvert. Une réponse de l’outil d’achèvement est toujours émise comme `final_answer`, même si elle répète exactement un commentaire antérieur, car l’exactitude de la phase prime sur la déduplication esthétique. Les requêtes sans outil conservent le comportement normal d’achèvement textuel.

Expand Down
1 change: 1 addition & 0 deletions docs-site/src/content/docs/ja/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ filtered incomplete になります。実際のツール呼び出しを伴わな
ツール有効ターンでは非公開の `codex_kiro_final_answer` を追加します。再試行は空の assistant/user ターンを
生成せず、元の user/tool-result を保持し、送信前にロール交互性、空の構造メッセージ、tool use/result の対応を検証します。
完了ツールの回答は以前の commentary と同じでも `final_answer` として送出します。
ユーザーしか出せない判断・情報・確認が得られず先に進めない場合も、その質問を完了ツールで送って停止するよう契約が指示します。これも commentary ではなくターンを終えた `final_answer` として届きます。

### Reasoning effort

Expand Down
1 change: 1 addition & 0 deletions docs-site/src/content/docs/ko/reference/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ commentary로 유지하고 비공개 완료 툴을 한 번 검증합니다.
툴이 있는 턴에는 비공개 `codex_kiro_final_answer`를 추가합니다. 완료 재시도는 빈 assistant/user 턴을 만들지
않고 원래 user/tool-result를 보존하며, 전송 전에 역할 교대·빈 구조 메시지·tool use/result 짝을 검증합니다.
완료 툴 답변은 이전 commentary와 같더라도 `final_answer`로 내보냅니다.
사용자만 줄 수 있는 결정·정보·설명이 없어 더 진행할 수 없을 때도 그 질문을 완료 툴로 보내고 멈추도록 계약이 지시합니다. 이때도 commentary가 아니라 턴이 끝난 `final_answer`로 도착합니다.

### Reasoning effort

Expand Down
Loading
Loading