Skip to content

fix(core): explicit lifecycle contract — plain text never ends a run; respond_to_user / wait_for_agents split - #954

Merged
0xallam merged 7 commits into
mainfrom
devin/1785611814-interactive-lifecycle-contract
Aug 1, 2026
Merged

fix(core): explicit lifecycle contract — plain text never ends a run; respond_to_user / wait_for_agents split#954
0xallam merged 7 commits into
mainfrom
devin/1785611814-interactive-lifecycle-contract

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

In interactive mode, a model turn that ended with plain text and no tool call silently killed the run. The Agents SDK treats a no-tool message as a final output, and _settle_run_result then flipped the still-running agent to waiting, so the outer loop blocked on coordinator.wait_for_message() — for the root agent with timeout=None, i.e. forever. An autonomous scan was stranded mid-work because the model forgot to call a tool, not because it was done.

Non-interactive mode already handled this exact slip (_run_noninteractive_until_lifecycle: detect status still running, inject a "call a tool" nudge, retry, bounded). This makes that the contract for both modes: status transitions only happen via an explicit lifecycle tool call, and plain text is inert for lifecycle purposes.

Plain text is still streamed to the user exactly as before — this changes what text means, not whether it's shown.

signal tool result
terminal success finish_scan (root) / agent_finish (subagent) completed
yield to the user respond_to_user waiting, indefinitely
wait on another agent wait_for_agents waiting, bounded
wait on a command exec_command / write_stdin not a lifecycle event
plain text, no tool inert → nudge and continue

Loop (strix/core/execution.py)

_run_noninteractive_until_lifecycle becomes _run_until_lifecycle(..., interactive), and both the first cycle and every post-user-message cycle route through it:

# before                                  # after
if interactive:                           result = await _run_until_lifecycle(
    result = await _run_cycle_parked(...)     ..., interactive=interactive)
else:
    result = await _run_noninteractive_until_lifecycle(...)
while True:
    result = await (_run_cycle_parked if interactive else _run_cycle)(...)
    if await _agent_status(...) != "running":
        await coordinator.reset_recovery(agent_id)
        return result          # an explicit lifecycle tool settled it
    recoveries = await coordinator.record_recovery(agent_id)   # text-only turn: inert
    if recoveries >= recovery_limit:
        return await _exhausted_recovery(..., interactive=interactive)
    input_data = await _append_tool_required_message(..., interactive=interactive)

Two deliberate asymmetries remain, both about who can resume:

  • Recovery limit. Interactive uses a small fixed _INTERACTIVE_TOOL_RECOVERY_LIMIT = 3; autonomous keeps max(1, max_turns).
  • Exhaustion. Interactive parks in waiting with a logged warning — a human is present, so the scan stays resumable by messaging the parked agent. Autonomous still raises MaxTurnsExceeded and marks crashed, since nobody is there to resume it.

A parked subagent additionally notifies its parent, mirroring _notify_parent_on_terminal on the autonomous path:

await coordinator.park_waiting(agent_id, wait_kind="stalled")
await _notify_parent_on_stall(coordinator, agent_id)   # no-op when parent is None

The human can revive any parked agent from the TUI, but the parent is an agent, not a watcher: nothing else tells it the child stopped, so it burns its whole wait_for_agents timeout on a completion report that is never coming. This is a separate notice from _TERMINAL_NOTICE because the child is not terminal — a message can still revive it — so the parent is told to either send a concrete next step or stop waiting, rather than the terminal notices' flat "stop waiting on this child".

_settle_run_result is deleted outright: it was the only path that produced waiting without an explicit yield tool, which is exactly the bug.

One wait tool split into two, and a wait_kind to drive timeouts

wait_for_message was doing three jobs — wait on the user, wait on other agents, and (silently wrongly) wait for a long-running command. Since one tool call could mean any of them, the driver had to guess which, and it used parent_id as the proxy: the root waits for a human forever, everyone else gets a 300s re-check. That proxy is false, because StrixTUI._send_user_message sends to self.selected_agent_id — the user can message any agent in the tree, including a parked child.

So the intent now rides on tool identity, which the model cannot get wrong the way it can get a parameter wrong, and the coordinator records it:

WaitKind = Literal["user", "agents", "stalled"]

respond_to_user  -> park_waiting(me, wait_kind="user")     # never auto-resumed, root or not
wait_for_agents  -> park_waiting(me, wait_kind="agents")   # 300s auto-resume
_exhausted_recovery -> park_waiting(me, wait_kind="stalled")

_plain_waiting_timeout reads that instead of the tree:

-if context.get("parent_id") is None:
-    return None                      # "the root is the one waiting on a human"
+if wait_kind != "agents" or idle_resumes >= _MAX_IDLE_AUTO_RESUMES:
+    return None
 return _WAITING_AUTO_RESUME_TIMEOUT_S

wait_kinds and idle_resume_counts go into the agents.json snapshot, so a resumed scan still knows what each parked agent was waiting on.

respond_to_user (new, strix/tools/respond/tool.py, registered only when interactive=True) fuses the message and the yield into a single call. The old contract asked the model to do two things — emit the answer, then remember to call the wait tool — and the model's job feels finished after the first; gpt-4o-mini skipped step two 2/2 in the live run below. With one call there is no way to answer and forget to stop, and no way to stop without having answered. Plain text still renders as it always did, so narrating mid-task costs nothing; respond_to_user is specifically the act of waiting.

wait_for_agents (renamed from wait_for_message, no alias) keeps the old peer/child semantics and now documents what it is not: it never watches a process, so using it to wait out a long-running command burns the full timeout even if the command exited a second later. The right answer is the shell tool's own polling — exec_command returns a session id and write_stdin(chars="") returns the moment there is new output or an exit.

Bounded auto-resume. An agent that re-parks after every timeout used to burn one model turn every 300s for the rest of the scan; with the persisted recovery counter it re-parks after a single cycle each time, and with the new stall notice it also re-spammed its parent's inbox on that cadence. After _MAX_IDLE_AUTO_RESUMES consecutive resumes with no message, it is left parked (wait_kind="stalled") until something real arrives. A genuine message resets the budget.

Durability: the counter is coordinator state, not loop-local

A loop-local counter resets whenever the driver is re-entered — including the _plain_waiting_timeout auto-resume for subagents. A wedged agent would then nudge → park → auto-resume → get a fresh budget → nudge again, forever (bounded only by scan budget), and would also forget its progress across --resume.

So the count lives on AgentCoordinator (recovery_counts) and is included in the agents.json snapshot. It resets only on genuine progress:

if woke:                                    # a real message arrived
    await coordinator.reset_recovery(agent_id)
else:                                       # bare auto-resume: no fresh budget
    ...send auto_resume...

An agent restored at its cap therefore parks again after one further text-only cycle rather than replaying the whole budget. Existing resume semantics needed no change — start_parked=bool(interactive and is_resume and root_status != "running") in runner.py already continues a restored running agent and leaves a deliberately waiting one parked, which is exactly the behavior the new contract wants.

Wait ceiling halved to 300s

Two agents waiting on each other resolve only when both hit their cap, so the ceiling is the worst-case idle burn — 10 minutes was too generous. _WAIT_DEFAULT_TIMEOUT_S = 300 with _WAIT_HARD_CEILING_S = 301 enforced by the SDK around the whole tool call (so it also caps an oversized timeout_seconds the model asks for), and _WAITING_AUTO_RESUME_TIMEOUT_S drops to match. Named constants instead of the repeated literal.

Prompt / tool contract, and the UI

The interactive prompt previously asserted the opposite of the new runtime behavior ("A message WITHOUT a tool call IMMEDIATELY STOPS your entire execution", "reply in plain text and stop"), so it had to be inverted in the same change or prompt and runtime would disagree. It now states that plain text never ends a turn, that respond_to_user is the only way to yield to the user, and that wait_for_agents is for peers and children only. The autonomous section drops its yield-to-user language entirely, since no user is attached there.

Tool names are hardcoded in the renderers, so both surfaces are updated: a TUI RespondToUserRenderer and a viewer RespondRenderer render the reply as the agent's own markdown prose rather than as a tool call, and the viewer's prebuilt bundle is rebuilt (npm run build) so the rename doesn't silently fall back to the generic renderer.

Testing

make check-all (ruff + format + mypy) and all pre-commit hooks pass; npx tsc --noEmit clean for the viewer. Full suite: 651 passed, 1 failed — test_finish_scan_bypasses_active_agent_guard_after_reserve, which fails identically on a clean main (ReportState has no attribute run_record, pre-existing and unrelated).

New coverage on top of the nine lifecycle tests: respond_to_user parks with wait_kind="user", is rejected in an autonomous run, and takes an already-arrived message instead of parking; it is registered only in interactive mode while wait_for_agents is registered in both; an agent awaiting a human is never auto-resumed whether or not it is the root; an agent awaiting agents is; idle auto-resumes stop after their budget and a real message restores it; and wait_kind + the idle counter survive a snapshot round-trip.

One real regression was caught by tests/test_e2e_budget_lifecycle.py rather than by a new test: its fake runner never calls a tool, so under the new contract every turn is a forgotten-tool-call turn and the agents burned 3 turns per wake, blowing past the 90% sub-agent reserve straight into the 100% cap. The fixture now parks explicitly after each turn, standing in for the yield tool a real turn ends with — the budget lifecycle it actually tests is unchanged.

Beyond unit tests the earlier phases were verified two ways, both in PR comments below:

  • Harness + negative controls. The same script driving the real run_agent_loop/_run_until_lifecycle was run against older commits: pre-fix main strands the run (1 cycle, waiting, still blocked after 15s) where this branch reaches completed; and pre-Phase-3 hands out a fresh nudge budget on every auto-resume (3.00 cycles per auto-resume vs 1.00 here).
  • Live end-to-end scan — real Kali sandbox, real model, real TUI, real --resume. The bug was provoked live: a plain question produced a bare-text turn, the nudge fired, and the model yielded properly 1.6s later instead of stranding the scan. 20 nudges across the run, none exceeding the cap, one clean exhaustion park, zero tracebacks, clean finish_scan.

Two caveats worth stating plainly. In that live run gpt-4o-mini answered with bare text 2/2 times, i.e. it did not obey the prompt's instruction to yield via a tool — the loop guardrail is what kept the run alive, so the safety net is load-bearing rather than redundant. And the tool split landed after that run: respond_to_user is designed to remove exactly that two-step failure, but it has not itself been exercised against a live model.

Link to Devin session: https://app.devin.ai/sessions/fd12121ff02b4056a72a1e284b31c1d3
Requested by: @0xallam

Interactive turns ended by plain text left the agent parked in 'waiting'
forever. Require an explicit lifecycle tool in both modes and nudge a
text-only turn back into a tool call, bounded by a recovery limit.
@0xallam 0xallam self-assigned this Aug 1, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@greptile-apps

greptile-apps Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR makes agent lifecycle transitions explicit: plain-text turns are retried rather than ending runs, while separate tools now represent waiting for users and waiting for agents.

  • Adds respond_to_user for interactive user handoff and renames the agent-waiting tool to wait_for_agents.
  • Persists wait intent and bounded recovery counters across resumed sessions.
  • Notifies parents when interactive children exhaust recovery and become stalled.
  • Updates prompts, tests, TUI rendering, and the prebuilt viewer bundle for the new lifecycle contract.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; the previously reported parent-stranding issue is addressed because the stalled child now queues a notice and wakes its waiting parent.

Important Files Changed

Filename Overview
strix/core/execution.py Unifies interactive and autonomous lifecycle enforcement, bounds recovery and idle resumes, and adds parent notification when a child stalls.
strix/core/agents.py Adds persisted wait-kind, recovery-count, and idle-resume state to the coordinator.
strix/agents/factory.py Registers the split waiting tools and recognizes both explicit parking tools as lifecycle boundaries.
strix/tools/agents_graph/tools.py Replaces the ambiguous wait tool with agent-specific waiting semantics and bounded timeouts.
strix/tools/respond/tool.py Adds the interactive-only response-and-park lifecycle operation.
strix/agents/prompts/system_prompt.jinja Aligns model instructions with the explicit lifecycle contract and new tool names.
strix/interface/viewer/frontend/src/components/live/tool-renderers/RespondRenderer.tsx Renders user-directed lifecycle responses as agent prose in the viewer.
tests/test_execution.py Expands coverage for recovery, wait intent, auto-resume limits, persistence, and child-stall notification.

Reviews (4): Last reviewed commit: "docs(prompts): text-only turns no longer..." | Re-trigger Greptile

Comment thread strix/core/execution.py Outdated
An exhausted agent parked in 'waiting' got a fresh nudge budget on every
600s auto-resume, so a wedged agent could nudge-park-nudge indefinitely.
Track the count on the coordinator, snapshot it, and reset it only on
real input or an explicit lifecycle tool.
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Runtime verification — lifecycle contract + Phase 3 durability

Harness-driven runtime testing of the real run_agent_loop / _run_until_lifecycle against a real AgentCoordinator. No live scan (a live model can't be reliably coerced into a text-only turn, which is the exact condition under test), so turn sequences were scripted and real statuses, recovery_counts and WARNING logs observed. Validated against two negative controls on older commits so the passes actually mean something.

Phase 3: auto-resume no longer hands out a fresh nudge budget (the key result)

Same unmodified test, pre-Phase-3 vs this branch:

commit cycles per auto-resume
69a60f3 (pre-Phase-3) 3.00 — fresh budget every timeout, nudges forever
5726c2d (this branch) 1.00 — one cycle, then re-parks
PASS :: S6 auto-resume does NOT refresh budget; genuine message DOES
   round1_fresh_budget=3 | auto_resume_events=5 cycles_from_auto_resumes=5
   RATIO=1.00 cycles/auto-resume | cycles_from_genuine_message=3

A genuine user message still restores a full budget of 3 — so this doesn't over-correct into "a real user can no longer un-wedge the agent".

Original regression: text-only turn no longer strands the run

Pre-fix main (5602bc2), one text-only turn:

RESULT on pre-fix main: hung=True cycles_run=1 status=waiting

This branch, identical script:

PASS :: cycles=2 status=completed nudge_warning_fired=True
Durability, yielding, bounds, autonomous mode, resume semantics
S2 parks on wait_for_message, one cycle per user message ......... PASS
S3 bounded to exactly 3 cycles then parks waiting ................ PASS
S4 autonomous raises MaxTurnsExceeded, 2 cycles, crashed ......... PASS
S5 recovery_counts persists to real agents.json {'root': 3};
   resumed agent runs 1 cycle, NOT a fresh 3 ..................... PASS
S7 start_parked=True -> 0 cycles; False -> 1 cycle ............... PASS

runner.py confirmed unchanged vs main, and both start_parked branches verified behaviourally. _settle_run_result confirmed removed. pytest tests/test_execution.py → 45 passed.

Caveat: the system_prompt.jinja and wait_for_message docstring changes are model-facing text and were not verified at runtime — this proves the driver handles a text-only turn correctly, not that the new prompt makes them rarer.

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Live E2E verification — real sandbox, real model, real TUI

Follow-up to the earlier harness testing: a live interactive scan against https://example.com (ghcr.io/usestrix/strix-sandbox:1.1.0, gpt-4o-mini, -m quick, ~$0.40), driving the actual TUI and a real --resume.

The original bug was provoked live — and the recovery path caught it

Asking the root agent a plain conversational question produced a bare-text turn, exactly the condition that used to strand a scan forever. The nudge fired and the model yielded properly 1.6 s later:

21:07:27.554 WARNING agent 4e3a8737 ended a turn without a lifecycle tool call
             (interactive=True); forcing tool continuation (1/3):
             I'm currently awaiting updates from the Port Scanning Agent...
21:07:29.142 DEBUG   Invoking tool wait_for_message
21:07:29.142 INFO    agent.status 4e3a8737=waiting

The agent woke, answered in the TUI, and re-parked — scan stayed alive:

Answered and re-parked

Notable finding: gpt-4o-mini answered with bare text 2/2 times, i.e. it does not obey the rewritten system_prompt.jinja instruction to call wait_for_message in the same turn. The driver's nudge is what kept the run alive — the safety net is load-bearing, not redundant.

Bounded recovery, whole-run counts
Metric Count
Nudge WARNINGs (all interactive=True) 20
attempt 1/3 17
attempt 2/3 2
attempt 3/3 1
attempt 4/3 0
Exhaustion parks 1
wait_for_message 25
MaxTurnsExceeded / tracebacks 0 / 0

One agent exhausted its budget and parked rather than looping or crashing:
agent 98a64e6f exhausted tool-call recovery attempts; parking until a message arrives

Golden path and --resume

The run reached a clean finish_scan with root status=completed:

finish_scan completed

After killing the scan mid-flight, --resume restored the full 26-agent tree and prior conversation rather than restarting, and agents.json carries the Phase 3 recovery_counts key:

Resume restored context

Caveat: recovery_counts read {} at every on-disk sample (correct — it resets on lifecycle progress within seconds), so the live run proves the field is persisted, not that a non-zero value survives a restart. That was proven separately in the harness run ({'root': 3} on disk; resumed agent ran 1 further cycle, not a fresh 3).

No product defects found.

0xallam added 2 commits August 1, 2026 21:25
A mutual wait between two agents resolves only when both hit their cap,
so the ceiling is the worst-case idle burn. Name the constants instead of
repeating the literal, and align the interactive auto-resume timeout.
Parking is self-service only for the root, which the user is watching.
A parked child owes its parent a report it can no longer send, so the
parent would wait out its full timeout for nothing.
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptile

The user can message any agent from the TUI, not only the root, so the
justification is that the parent is an agent with no other way to learn
the child parked - not that the child has no human resumer.
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptile

…or_agents

One tool was doing three jobs (wait on the user, wait on other agents, and
- wrongly - wait for a long-running command), so the driver had to guess which
one an agent meant and used parent_id as the proxy: the root waits for a human,
everyone else waits for agents. That proxy is wrong, since the user can message
any agent from the TUI's agent tree.

Tool identity now carries the intent, and the coordinator records it as a
wait_kind that survives snapshot/restore:

  respond_to_user  -> wait_kind="user",   never auto-resumed (root or not)
  wait_for_agents  -> wait_kind="agents", auto-resumed on a 300s timer
  recovery exhaust -> wait_kind="stalled"

respond_to_user fuses the message and the yield into one call, so there is no
way to answer and then forget to stop - the two-step that gpt-4o-mini skipped
2/2 in live testing. Plain text still renders as before.

Auto-resume is also bounded now: an agent that re-parks after every timeout
burned a model turn every 300s for the rest of the scan (and, since parked
children notify their parent, spammed the parent's inbox on the same cycle).
After _MAX_IDLE_AUTO_RESUMES it stays parked until a real message arrives.
@devin-ai-integration devin-ai-integration Bot changed the title fix(core): stop interactive runs stalling when the model forgets a tool call fix(core): explicit lifecycle contract — plain text never ends a run; respond_to_user / wait_for_agents split Aug 1, 2026
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

Live E2E verification — wait_for_message split into respond_to_user + wait_for_agents

A real interactive scan (ghcr.io/usestrix/strix-sandbox:1.1.0, gpt-4o-mini, -m quick, https://example.com), driving the actual TUI and a real --resume. All six checks passed; no product defects. The 24 ERROR lines in the log are all from the unrelated Caido proxy tool — zero from strix.core.

The live model actually uses the new fused respond_to_user (the key untested claim)

Asking the root agent a question produced a real respond_to_user call (×8 over the run). Its reply renders as prose plus the respond_renderer.py signature line, and it parks with wait_kind="user":

22:23:42.111 Invoking tool respond_to_user
22:23:42.111 agent.status 24808c39=waiting     # wait_kinds={"24808c39":"user"}

respond_to_user rendered

A second message woke it (wait_kinds cleared, status→running) — mark_running clears the kind.

Non-root wake, wait_kind gating, bounded nudge, resume

Messaging a NON-root agent routed correctly and woke it:

22:36:48.619 TUI: user message -> f22192b2 (len=44)   # non-root, not root
22:36:51.619 agent.status f22192b2=completed           # woke from wait_kind=user, cleared

Non-root woke and replied

Metric Count
respond_to_user / wait_for_agents / wait_for_message 8 / 28 / 0
Nudge WARNINGs (1/3, 2/3, 3/3, 4/3) 20, 5, 1, 0
Exhaustion→stalled parks 1
Idle auto-resume (agents kind) 1 (idle_resume_counts={'d35fe68b':1})
finish_scan / MaxTurnsExceeded / strix.core errors 4 / 0 / 0

No wait_kind="user" agent was ever auto-resumed (the regression this design prevents).

--resume restored the 25-agent tree + full conversation, with both new fields persisted:

Resume: restored coordinator with 25 agent(s); root=24808c39
wait_kinds={"24808c39":"user","f22192b2":"user"}  idle_resume_counts={"d35fe68b":1}

Resume restored context

Caveats: the idle-resume cap→stalled (3× auto-resume) wasn't reached live (needs ~15 min idle; covered by unit tests), and the nudge's respond_to_user wording is verified from source since injected model input isn't logged.

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai review

@0xallam
0xallam merged commit f77805e into main Aug 1, 2026
2 checks passed
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