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
46 changes: 46 additions & 0 deletions infra/agent-image/harness_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -2681,6 +2681,9 @@ def _process_once(
and not tool_activity_since_text
):
response_text = agent_assistant_accum
response_text = self._prefer_terminal_segment(
response_text, agent_assistant_accum
)
got_final = True
break

Expand Down Expand Up @@ -3558,6 +3561,49 @@ def _accumulate_assistant(
return increment
return accum + increment

@staticmethod
def _prefer_terminal_segment(chat_text: str, accum: str) -> str:
"""Undo the gateway's fusion of a turn's assistant text blocks.

The accumulator above already isolates the terminal segment. The chat
channel's `final` payload does not: it carries EVERY assistant text
block the turn produced, concatenated into one string with no
separator, and `state == "final"` assigns it over the accumulator.

Measured 2026-09-06, dev run a33a3f92 — a 54-tool-call turn whose six
assistant messages (five ending stopReason=toolUse) arrived as one
1630-char reply that read
"...fix that written file.Let me just avoid the script...". resp_len
matched the sum of all six blocks exactly.

This was invisible to ReplyIsTheAnswerOnly for two years' worth of
replays because FakeGateway's final carries no message text, so those
tests never reached this assignment at all — see
FusedChatFinalDoesNotOverrideTheTerminalSegment, which scripts the
production shape.

Deliberately narrow: it fires ONLY when the chat text is a strict
superset that ENDS WITH the accumulated terminal segment, which is
what fusion looks like and what nothing else does. An empty
accumulator, an equal string, or any text that merely contains the
segment elsewhere is returned untouched, so a gateway that sends a
clean final — or one whose final is genuinely richer than what we
accumulated — keeps winning.
"""
if not chat_text or not accum:
return chat_text
if chat_text == accum:
return chat_text
if not chat_text.endswith(accum):
return chat_text
logger.info(
"chat final was the turn's fused assistant text (%d chars); "
"delivering the terminal segment only (%d chars)",
len(chat_text),
len(accum),
)
return accum

@staticmethod
def _render_questions(payload: object) -> str:
"""Render a `question.requested` payload's `questions` list for Chat.
Expand Down
41 changes: 38 additions & 3 deletions infra/agent-image/skills/psd-rules/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,29 @@ These rules are **non-negotiable**. They override stylistic guidance in `SOUL.md

## Rule 1 — Think silently; reply with the finished answer only

**The user sees one thing per turn: your final answer.** Reasoning, plans, tool calls, debugging steps, and self-narration are internal scratchpad and **must not appear** in the reply.
**YOU HAVE NO SCRATCHPAD. EVERY LINE OF TEXT YOU WRITE IS DELIVERED TO THE USER.**

Read that literally. It is not a style preference, it is how this runtime
works. Text you write between tool calls is not a private note, not a status
line, and not discarded — the platform concatenates **every** text block you
emit across the whole turn into **one** chat message, in order, **with no
separator between them**. Write four asides and an answer, and the user gets
one message that begins with the four asides and runs them together mid-word:

> `Oops, let me fix that written file.Let me just avoid the script entirely and use jq.Bad literal \n. Let me repair it.None of the 18 items match…`

That is a real reply this agent sent on 2026-09-06. Measured across this
workspace's whole history: **56% of turns ship more than one text block, and on
those turns a median 51% of what the user reads is narration** — half the
message, discarded thought. In the worst case it was 98%.

So there is exactly one safe rule: **do not write a sentence until you are
writing the answer.** Think in reasoning, act with tools, and stay silent
between them. Your first character of visible text should be the first
character of the finished reply.

Reasoning, plans, tool calls, debugging steps, and self-narration **must not
appear** — not at the end, and not anywhere earlier in the turn either.

**Forbidden phrasings in user-facing output:**

Expand All @@ -28,7 +50,20 @@ These rules are **non-negotiable**. They override stylistic guidance in `SOUL.md

**Why:** streaming scratchpad narration to the user is the single most damaging output failure for trust (incident 2026-04-25: 11 lines of "Let me check…" shipped before the answer). The last two bullets were added 2026-08-14 after a Docs turn shipped "Now add the three bullets… Good, endIndex 260 is within bounds… Now run the batchUpdate." — none of which matches a "let me" pattern, which is why the phrasing list alone cannot be the check.

**How to apply:** before sending, re-read the draft and strike every sentence describing what *you* are about to do, are doing, or just did — including any that recount a tool call's existence ("checked the secret", "ran the query"). What remains is the answer; send only that. If nothing remains, you have no answer yet — do the work, then reply.
**How to apply:** the check happens **before you type**, not before you send —
there is no "before sending" for text you already emitted three tool calls ago.
Each time you are about to write prose, ask: *is this the finished answer?* If
it is not, do not write it — call the next tool instead. When you do write,
strike every sentence describing what *you* are about to do, are doing, or just
did, including any that recount a tool call's existence ("checked the secret",
"ran the query"). What remains is the answer. If nothing remains, you have no
answer yet — do the work, then reply.

**Write the answer once.** If you have already stated a conclusion this turn,
do not restate it after another tool call. Two full answers in one turn reach
the user as one doubled message. When a later step adds something — a failure
ID, a corrected number — the answer you have not written yet is the one that
carries it, so hold the conclusion until every step is done.

Scanning for the phrasings above is not enough, because the same narration
survives any rewording. Use the test instead: **would this sentence still make
Expand Down Expand Up @@ -487,4 +522,4 @@ that ends your turn early — a promise, a spawned child, a deferral — breaks

## Self-check before send

Before every reply, confirm: no "Let me…"/scratchpad (R1); every URL is from a skill, and any `url` field is on its own line (R2/R9); no fabricated facts or outcomes (R3); did the work now, not an empty promise (R4); reply length matches information density and memory files updated (R5/R7); for any task a skill covers, called the skill (R9); called `psd-failure-report` if any part failed (R11); user-visible text is non-empty (R12); no non-reversible `gh`/`git push` unless the user authorized it this same turn (R13); long work ran to completion in THIS turn rather than being spawned out, deferred, sampled or shortened — subagents are unavailable, so there is nothing to wait on and nothing coming later (R15); not asking permission to continue work already requested, and not claiming to be paused/stopped unless the CURRENT user message says so (R15). If any is "no," fix the reply first.
Before every reply, confirm: no "Let me…"/scratchpad, and this is the FIRST and ONLY prose you have written this turn — nothing was emitted between tool calls, and no earlier conclusion is about to be restated (R1); every URL is from a skill, and any `url` field is on its own line (R2/R9); no fabricated facts or outcomes (R3); did the work now, not an empty promise (R4); reply length matches information density and memory files updated (R5/R7); for any task a skill covers, called the skill (R9); called `psd-failure-report` if any part failed (R11); user-visible text is non-empty (R12); no non-reversible `gh`/`git push` unless the user authorized it this same turn (R13); long work ran to completion in THIS turn rather than being spawned out, deferred, sampled or shortened — subagents are unavailable, so there is nothing to wait on and nothing coming later (R15); not asking permission to continue work already requested, and not claiming to be paused/stopped unless the CURRENT user message says so (R15). If any is "no," fix the reply first.
108 changes: 108 additions & 0 deletions infra/agent-image/test_reply_replay.py
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,114 @@ def test_thinking_is_never_delivered(self):
self.assertEqual(text, "It's the Q3 planning doc.")


def chat_final(text, run_id=TURN_RUN_ID):
"""A chat `final` that CARRIES message text, the way the live gateway sends it.

Every other test in this file relies on FakeGateway's bare
`{"state": "final"}`, which has no `message` — so the adapter's
`state == "final": if text: response_text = text` assignment is never
reached and the boundary-aware accumulator always supplies the reply.
Production is not like that: dev run a33a3f92 on 2026-09-06 carried 19
`event:chat` frames and a final whose message held the whole turn's
assistant text. This helper is what makes that path testable.
"""
return {
"type": "event",
"event": "chat",
"payload": {
"state": "final",
"runId": run_id,
"stopReason": "stop",
"message": {"content": [{"type": "text", "text": text}]},
},
}


class FusedChatFinalDoesNotOverrideTheTerminalSegment(unittest.TestCase):
"""The gateway fuses a turn's assistant blocks; the reply must not.

2026-09-06, dev run a33a3f92: a 54-tool-call turn produced six assistant
messages, five of them ending stopReason=toolUse. The user received one
1630-char message — the exact sum of all six — that opened with three
lines of scratchpad and stated the same answer three times, running
together mid-word as "...fix that written file.Let me just avoid...".

The accumulator had it right. The chat final overwrote it.
"""

NARRATION = [
"Oops, let me fix that written file.",
"Let me just avoid the script entirely and use jq.",
"Bad literal newline. Let me repair it.",
]
ANSWER = "I couldn't find that artifact in Atrium. Failure ID: 1123"

def _production_shape(self):
events = []
for line in self.NARRATION:
events.append(says(line))
events.extend(uses_tool("exec"))
events.append(says(self.ANSWER))
events.append(chat_final("".join(self.NARRATION) + self.ANSWER))
return events

def test_only_the_terminal_segment_is_delivered(self):
self.assertEqual(replay(self._production_shape()), self.ANSWER)

def test_no_narration_survives(self):
text = replay(self._production_shape())
for line in self.NARRATION:
self.assertNotIn(line, text)

def test_the_fusion_signature_is_gone(self):
# Two sentences run together with no separator is what the user sees.
self.assertNotIn("file.Let me", replay(self._production_shape()))

def test_a_clean_final_still_wins(self):
# The gateway's final is authoritative whenever it is NOT the fused
# superset — including when it says more than we accumulated.
text = replay(
[
says("Partial"),
*uses_tool("exec"),
chat_final("The gateway's own, richer answer."),
]
)
self.assertEqual(text, "The gateway's own, richer answer.")

def test_a_final_matching_the_accumulator_is_unchanged(self):
text = replay([says("Done."), chat_final("Done.")])
self.assertEqual(text, "Done.")


class TerminalSegmentPreferenceIsNarrow(unittest.TestCase):
"""`_prefer_terminal_segment` must fire on fusion and nothing else."""

@staticmethod
def _prefer(chat_text, accum):
return OpenClawAdapter._prefer_terminal_segment(chat_text, accum)

def test_a_strict_superset_ending_in_the_segment_is_trimmed(self):
self.assertEqual(self._prefer("narration.answer", "answer"), "answer")

def test_an_equal_string_is_untouched(self):
self.assertEqual(self._prefer("answer", "answer"), "answer")

def test_a_segment_appearing_mid_string_is_untouched(self):
# Only a SUFFIX means "the terminal block was appended last".
self.assertEqual(
self._prefer("answer then more", "answer"), "answer then more"
)

def test_an_empty_accumulator_never_wins(self):
self.assertEqual(self._prefer("the answer", ""), "the answer")

def test_empty_chat_text_is_returned_as_is(self):
# The empty-final fallback above this call already handled that case;
# this must not resurrect anything.
self.assertEqual(self._prefer("", "accumulated"), "")


class AbortedTurnDoesNotShipScratchpad(unittest.TestCase):
"""A turn that dies mid-tool must not deliver the narration that preceded it.

Expand Down
Loading