Skip to content

fix(agents): a turn that failed closes on a failed rather than on nothing - #39

Open
LukeLIN-web wants to merge 1 commit into
humanfia:mainfrom
LukeLIN-web:fix/a-failed-turn-says-so
Open

fix(agents): a turn that failed closes on a failed rather than on nothing#39
LukeLIN-web wants to merge 1 commit into
humanfia:mainfrom
LukeLIN-web:fix/a-failed-turn-says-so

Conversation

@LukeLIN-web

@LukeLIN-web LukeLIN-web commented Sep 3, 2026

Copy link
Copy Markdown

Rebased onto main at 25c9bd3. d410372 ("a suppressed failed turn leaves its reason on
stderr") landed the half of this that a run nothing is watching needed. This is the other
half: the run something is watching.

What happens now

Event says a turn closes on exactly one of two things:

result is the answer the turn ends on — exactly one of which closes a turn. failed closes
it the other way, carrying what went wrong in place of an answer.

docs/reference/agents.md says the same. dsh is the only backend it is actually true of.
Everywhere else a turn fails by raising out of _stream, and _turning lets that through to its
finally — so a watcher gets begins, then ends, and nothing between them.

That is byte-for-byte what a watcher sees when a turn answers with nothing. A round that failed
and a round with nothing to say are shown as the same thing, and suppress=True — which every
loop is written with — hands the flow the same "" or None for both, so the flow cannot tell
them apart either.

Everything that reads a run reads the turns rather than stderr: the interface, the status column,
anything hung on agent.watch(). d410372 reaches none of those, because it fires only when
_watchers is empty.

Why it matters

Nothing else records it either. A turn that failed is never asked for a session id, so the
session is never opened, so the epic writes no opened for that agent and hmz trace collect
has nothing of it to gather.

An agent whose every turn failed is therefore invisible in every direction at once: the flow is
handed nothing, the watcher is shown nothing, the run's record names nothing, the trace has
nothing.

Found on official/rlar, whose stopping condition is the reviewer's judgement rather than a
budget:

review = agents.reviewer(..., suppress=True, schema=Review)
if review is not None and review.done:
    return                      # the only way out
if review is not None and review.notes:
    prompt = notes = review.notes

The reviewer's CLI was not signed in. Every review turn failed, every round got None, the
prompt went round unchanged, and the run's own journal declared two agents and carried opened
for one of them. The actor kept working, unreviewed, until somebody stopped it by hand.

The failure the adapter raised on, reproduced — it reads this correctly; the signal was lost
above it:

$ agy --output-format stream-json --model … --dangerously-skip-permissions --print "say hi" </dev/null
{"event":"result","result":{"conversation_id":"","status":"ERROR","response":"",
 "error":"authentication failed or timed out", …}}

The change

SessionBase._turning says the failure before it raises it — a failed event carrying
Failed.__str__, which already carries the CLI's own sentence alongside the exit status:

  • inside the begins/ends bracket, in place of the result a turn that landed ends on;
  • for every backend, rather than only the one that reads its failure out of a protocol;
  • for an unsuppressed failure too — it raises as before, and now also says so on the way past;
  • said once. A backend that already closed the turn on a failed of its own is not made to say
    it twice; tests/agents/test_dsh.py pins that for dsh and still passes.

No stderr write is added — d410372 has that, and duplicating it would print the reason twice.
Nothing about what suppress answers with changes, so every flow written against it goes on
working. Stopped is not a CalledProcessError and is not caught here; an Unrecoverable is
one, and is now said on its way past.

Docs: the suppress paragraph in docs/reference/agents.md, the one in docs/user/stopping.md,
and a new docs/user/troubleshooting.md entry — One of my agents is not in the trace at all
since "two agents declared, one in the trace" reads as a broken trace and is not one.

Not in this change

  • No budget for official/rlar. That flow lives in humanfia/flowverse. A loop held to a
    judgement rather than to a ceiling is still a loop with no ceiling; this only makes the silence
    audible.
  • The other way a turn answers None under suppress — an answer that was not the shape it
    was asked for — still says nothing to a watcher. That one is a turn that landed: it closes on
    result, and the shape is read outside the bracket, so saying it belongs where the answer is
    read rather than here. Happy to fold it in if you would rather have both.

Checks

uv run pre-commit run --all-files and uv run pytest both pass. No flow-facing API changed, so
humanfia/flowverse needs no companion change.


🤖 Generated with Claude Code

https://claude.ai/code/session_01Qm4L3SmRKKPB5WaX8GX62f

@LukeLIN-web
LukeLIN-web force-pushed the fix/a-failed-turn-says-so branch from c259a86 to a4e2846 Compare September 3, 2026 15:40
…othing

A turn ends on exactly one event -- `result` where it landed, `failed` where it
did not. That is what `Event` says and what the reference documents, and dsh is
the only backend it was true of: everywhere else a turn that failed by raising
closed on neither, so whatever was watching the agent saw `begins`, and then
`ends`, with nothing between them.

Which is precisely what it sees when a turn answers with nothing. So a round
that failed and a round with nothing to say were shown as the same thing -- and
`suppress=True`, which every loop is written with, hands the flow the same `""`
or `None` for both, so the flow cannot tell them apart either.

An agent whose every turn failed then leaves no mark on anything that reads the
turns: a turn that failed is never asked for a session id, so the run's record
names no session for it and a trace of that run has nothing of it to collect.
Two agents declared and one of them in the trace, and a reviewer whose CLI was
never signed in reading exactly like a reviewer that agreed -- for as long as
the loop is left going.

d410372 put the reason on stderr for a run nothing is watching. This is the same
reason for a run something is watching, which reads the turns rather than
stderr: the interface, the status column, and anything else hung on the agent.
Said once, since a backend that closed the turn on a `failed` of its own must
not be made to say it twice.

What `suppress` answers with does not change, so every flow written against it
goes on working.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qm4L3SmRKKPB5WaX8GX62f
@LukeLIN-web
LukeLIN-web force-pushed the fix/a-failed-turn-says-so branch from a4e2846 to 3b1e356 Compare September 3, 2026 15:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant