diff --git a/scripts/livepass.py b/scripts/livepass.py index 38c4c0f8..35c0871e 100755 --- a/scripts/livepass.py +++ b/scripts/livepass.py @@ -64,9 +64,14 @@ Task-agent harness (/taskagent/livepass.html): the task_agent card — a task tool_pending/tool_result/tool_output_chunk/approve_request -> task_agent tool_result) so the SSE->card routing (_routeAgentItems / _ensureAgentCard, and appendToolOutput finding the nested row by call_id) is exercised, not - just the leaf builders. + &theme=light, &collapsed=1. document.title stamps - TASKAGENT-READY- on success, TASKAGENT-FAILED-... / TASKAGENT-ERROR - when routing breaks, so a broken card can't screenshot green. + just the leaf builders. Query flags: &theme=light; &collapsed=1 (all-auto, + no approval -> the natural collapse-by-default state); ¶llel=1 (card in a + 2-tool batch, for the rail-bleed rules); &recall=1 (the RECALL path — + replayHistory rebuilding the card from a /history `agent_steps` overlay, i.e. + a reload while the ws is in memory); &expand=1 (open every card so a shot + shows the nested steps). document.title stamps TASKAGENT-READY- on + success, TASKAGENT-FAILED-... / TASKAGENT-ERROR when routing breaks, so a + broken card can't screenshot green. Rebuild after ANY markup change: the dialog blocks are embedded at build time. Assets are symlinked, so CSS/JS edits are live on refresh. @@ -846,6 +851,27 @@ TASKAGENT_TEMPLATE = """ pane.setBusy = () => {}; const ev = (e) => pane.handleEvent(e); + // ?recall=1: exercise the RECALL path — replayHistory rebuilding the + // card from the /history `agent_steps` overlay (a reload / reopen while + // the ws is still in memory), as opposed to the live SSE path below. + const recall = q.get("recall") === "1"; + if (recall) { + pane.replayHistory([ + { role: "user", content: "Find all call sites of resolve_alias and summarize them" }, + { role: "assistant", tool_calls: [{ + name: "task_agent", id: "task1", + arguments: JSON.stringify({ prompt: "Find call sites of resolve_alias" }), + agent_steps: [ + { id: "task1::c1", name: "search", arguments: JSON.stringify({ query: "resolve_alias" }), output: "12 matches across 4 files", is_error: false }, + { id: "task1::c2", name: "read_file", arguments: JSON.stringify({ path: "core/registry.py" }), output: "4.1 KB read", is_error: false }, + { id: "task1::c3", name: "bash", arguments: JSON.stringify({ command: "pytest -k registry" }), output: "12 passed in 1.2s", is_error: false }, + { id: "task1::c4", name: "notify", arguments: JSON.stringify({ channel: "#eng", message: "post summary" }), output: "posted to #eng", is_error: false }, + ], + }] }, + { role: "tool", tool_call_id: "task1", content: "resolve_alias has 4 call sites (registry.py:120, session.py:12200, model_registry.py:88, eval.py:54); all pass a validated alias before use." }, + ]); + } else { + // 1. Parent paints the task_agent call (a top-level tool row). const taskItem = { call_id: "task1", func_name: "task_agent", @@ -886,6 +912,17 @@ TASKAGENT_TEMPLATE = """ // 3. The task agent's own synthesis, rendered below the card. ev({ type: "tool_result", call_id: "task1", name: "task_agent", output: "resolve_alias has 4 call sites (registry.py:120, session.py:12200, model_registry.py:88, eval.py:54); all pass a validated alias before use." }); + } + + // ?expand=1: open every card so a screenshot shows the nested steps + // (cards collapse by default; recall has no approval to auto-expand). + if (q.get("expand") === "1") { + document.querySelectorAll(".conv-agent").forEach(function (c) { + c.dataset.collapsed = "false"; + const t = c.querySelector(".conv-agent-toggle"); + if (t) t.setAttribute("aria-expanded", "true"); + }); + } // Loud failure — broken routing must not screenshot green. setTimeout(function () { diff --git a/tests/test_app_js.py b/tests/test_app_js.py index 66fed9c3..1a70ec58 100644 --- a/tests/test_app_js.py +++ b/tests/test_app_js.py @@ -530,15 +530,17 @@ def test_phase8_appendtooloutput_dispatches_mcp_error_before_renderer() -> None: end = _pane_method_offset(body, "sendMessage") fn = body[start:end] parse_idx = fn.find("tryParseMcpError(") - render_idx = fn.find("renderToolOutput(") + # The plain-output render is the shared renderCollapsibleOutput helper; the + # ordering invariant is unchanged — MCP dispatch must precede it. + render_idx = fn.find("renderCollapsibleOutput(") assert parse_idx >= 0, ( "appendToolOutput must call tryParseMcpError on the error path " - "before renderToolOutput, otherwise the consent card never " + "before the plain renderer, otherwise the consent card never " "replaces the plain JSON output." ) - assert render_idx >= 0, "renderToolOutput call must remain present" + assert render_idx >= 0, "renderCollapsibleOutput call must remain present" assert parse_idx < render_idx, ( - "tryParseMcpError must run BEFORE renderToolOutput so the " + "tryParseMcpError must run BEFORE the plain renderer so the " "interactive card path takes precedence over plain rendering." ) diff --git a/tests/test_session.py b/tests/test_session.py index 5cc0e7c9..eb6eec9b 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -1658,6 +1658,310 @@ class TestAgentChildRegistration: session.ui.note_agent_child.assert_called_once_with("task-1::call_1", "task-1") +class TestProjectAgentSteps: + """``_project_agent_steps`` projects a finished sub-agent's trajectory into + recall step items for the task card — one per tool call, matched to its + result by call_id, landmine-safe on a multimodal result.""" + + def test_calls_matched_to_results_in_order(self): + from turnstone.core.trajectory import ToolCall, Turn + + turns = [ + Turn.system("sys"), + Turn.user("go"), + Turn.assistant( + tool_calls=(ToolCall(id="c1", name="search", arguments='{"query":"x"}'),) + ), + Turn.tool("c1", "12 matches"), + Turn.assistant( + tool_calls=(ToolCall(id="c2", name="bash", arguments='{"command":"ls"}'),) + ), + Turn.tool("c2", "boom", is_error=True), + ] + steps = ChatSession._project_agent_steps(turns) + assert [s["id"] for s in steps] == ["c1", "c2"] + assert steps[0] == { + "id": "c1", + "name": "search", + "arguments": '{"query":"x"}', + "output": "12 matches", + "is_error": False, + } + assert steps[1]["is_error"] is True + assert steps[1]["output"] == "boom" + + def test_multimodal_result_placeholdered_not_crashed(self): + # A vision tool result is a list[dict] mis-stored as TextBlock.text; the + # projection must NOT call Turn.text (would TypeError) — it reads the + # payload directly and placeholders a non-str so /history stays text-only. + from turnstone.core.trajectory import ToolCall, Turn + + turns = [ + Turn.assistant( + tool_calls=(ToolCall(id="c1", name="read_file", arguments='{"path":"a.png"}'),) + ), + Turn.tool("c1", [{"type": "image_url"}]), + ] + steps = ChatSession._project_agent_steps(turns) + assert steps[0]["output"] == "[non-text result]" + + def test_output_capped(self): + from turnstone.core.session import _AGENT_STEP_OUTPUT_CAP + from turnstone.core.trajectory import ToolCall, Turn + + big = "a" * (_AGENT_STEP_OUTPUT_CAP + 500) + turns = [ + Turn.assistant(tool_calls=(ToolCall(id="c1", name="bash", arguments="{}"),)), + Turn.tool("c1", big), + ] + steps = ChatSession._project_agent_steps(turns) + assert len(steps[0]["output"]) < len(big) + assert "truncated from 2500 chars" in steps[0]["output"] + + def test_unanswered_call_has_empty_output(self): + # A tool call with no matching result (cancelled mid-flight) recalls + # honestly as empty, not dropped. + from turnstone.core.trajectory import ToolCall, Turn + + turns = [Turn.assistant(tool_calls=(ToolCall(id="c1", name="bash", arguments="{}"),))] + steps = ChatSession._project_agent_steps(turns) + assert steps == [ + {"id": "c1", "name": "bash", "arguments": "{}", "output": "", "is_error": False} + ] + + def test_colliding_ids_paired_fifo_not_last_wins(self): + # A local provider reuses id "call_0" across turns; FIFO pairing gives + # each call its OWN result, not last-wins (which would show out-B twice). + from turnstone.core.trajectory import ToolCall, Turn + + turns = [ + Turn.assistant( + tool_calls=(ToolCall(id="call_0", name="bash", arguments='{"command":"a"}'),) + ), + Turn.tool("call_0", "out-A"), + Turn.assistant( + tool_calls=(ToolCall(id="call_0", name="bash", arguments='{"command":"b"}'),) + ), + Turn.tool("call_0", "out-B"), + ] + steps = ChatSession._project_agent_steps(turns) + assert [s["output"] for s in steps] == ["out-A", "out-B"] + + def test_step_count_capped_with_honest_marker(self): + from turnstone.core.session import _AGENT_STEP_COUNT_CAP + from turnstone.core.trajectory import ToolCall, Turn + + turns = [] + for i in range(_AGENT_STEP_COUNT_CAP + 5): + turns.append( + Turn.assistant(tool_calls=(ToolCall(id=f"c{i}", name="bash", arguments="{}"),)) + ) + turns.append(Turn.tool(f"c{i}", f"out{i}")) + steps = ChatSession._project_agent_steps(turns) + # Capped + one honest LEADING marker, keeping the most RECENT steps (the + # tail) — not the earliest — and naming how many earlier ones fell out. + assert len(steps) == _AGENT_STEP_COUNT_CAP + 1 + assert steps[0]["name"] == "…" + assert "5 earlier steps not retained" in steps[0]["output"] + # c0..c4 dropped; c5 is the first retained, the newest call is last. + assert steps[1]["id"] == "c5" + assert steps[-1]["id"] == f"c{_AGENT_STEP_COUNT_CAP + 4}" + + +class TestAgentTrajectoryStashWiring: + """``_stash_agent_trajectory`` projects + forwards to the UI, getattr-guarded.""" + + def test_projects_and_forwards(self): + from turnstone.core.trajectory import ToolCall, Turn + + session = _make_session() + session.ui = MagicMock() + turns = [ + Turn.assistant(tool_calls=(ToolCall(id="c1", name="bash", arguments="{}"),)), + Turn.tool("c1", "ok"), + ] + session._stash_agent_trajectory("task1", turns) + session.ui.stash_agent_trajectory.assert_called_once() + cid, steps = session.ui.stash_agent_trajectory.call_args[0] + assert cid == "task1" + assert steps == [ + {"id": "c1", "name": "bash", "arguments": "{}", "output": "ok", "is_error": False} + ] + + def test_noop_without_call_id(self): + session = _make_session() + session.ui = MagicMock() + session._stash_agent_trajectory(None, []) + session.ui.stash_agent_trajectory.assert_not_called() + + def test_noop_on_ui_without_support(self): + # NullUI has no stash_agent_trajectory → getattr None → no-op, no raise. + _make_session()._stash_agent_trajectory("task1", []) + + +class TestReadFilesIsolation: + """A task agent's file-read tracking is isolated from the main session and + its pool siblings via ``_active_read_files`` so the blind-overwrite guard + can't be cross-contaminated (a sibling's read suppressing another's guard).""" + + def test_defaults_to_main_set(self): + session = _make_session() + assert session._current_read_files is session._read_files + + def test_active_contextvar_overrides_then_restores(self): + from turnstone.core.session import _active_read_files + + session = _make_session() + sub: set[str] = set() + token = _active_read_files.set(sub) + try: + assert session._current_read_files is sub + finally: + _active_read_files.reset(token) + assert session._current_read_files is session._read_files + + def test_empty_active_set_is_used_not_main(self): + # The resolver guards on `is not None`, not truthiness — an EMPTY + # per-agent set must be used, NOT fall through to the main set, or a + # fresh agent would inherit the main session's reads and mis-suppress + # its own blind-overwrite guard. + from turnstone.core.session import _active_read_files + + session = _make_session() + session._read_files.add("/main/file") + token = _active_read_files.set(set()) + try: + assert session._current_read_files == set() + finally: + _active_read_files.reset(token) + + def test_exec_task_copies_parent_reads_and_merges_back(self): + # Drive the REAL _exec_task wiring (not a hand-rolled contextvar dance): + # it copies the parent's reads into an INDEPENDENT per-agent set (so the + # agent can edit a file the parent read for it, without leaking mid-run + # to a sibling) and merges the agent's own reads back on completion. + session = _make_session() + session._agent_system_messages = [] + session._task_tools = [] + session._read_files.add("/parent/read") + seen = {} + + def fake_run_agent(agent_turns, **_kwargs): + seen["sees_parent"] = "/parent/read" in session._current_read_files + session._current_read_files.add("/child/read") + seen["child_isolated"] = "/child/read" not in session._read_files + return "done" + + with patch.object(session, "_run_agent", side_effect=fake_run_agent): + cid, out = session._exec_task({"call_id": "t1", "prompt": "go"}) + + assert (cid, out) == ("t1", "done") + assert seen["sees_parent"] is True # copy-on-spawn: inherits parent's reads + assert seen["child_isolated"] is True # independent set mid-run (no leak) + assert "/child/read" in session._read_files # merged back on completion + assert session._current_read_files is session._read_files # contextvar reset + + +class TestSubAgentErrorRecall: + """_run_agent stamps is_error on a sub-tool's Turn from the authoritative + _tool_error_flags, so a failed sub-tool recalls styled as an error rather + than a green 'done' step (the most serious review finding).""" + + def test_errored_sub_tool_turn_marked_is_error(self): + from turnstone.core.providers._openai_chat import OpenAIChatCompletionsProvider + from turnstone.core.trajectory import Role + + session = _make_session() + session._provider = OpenAIChatCompletionsProvider() + calls = [0] + + def fake_create(**_kwargs): + calls[0] += 1 + resp = MagicMock() + choice = MagicMock() + if calls[0] == 1: + choice.finish_reason = "tool_calls" + tc = MagicMock() + tc.id = "call_1" + tc.function.name = "bash" + tc.function.arguments = '{"command":"false"}' + choice.message.tool_calls = [tc] + choice.message.content = None + else: + choice.finish_reason = "stop" + choice.message.tool_calls = None + choice.message.content = "done" + resp.choices = [choice] + resp.usage = MagicMock(prompt_tokens=10, completion_tokens=5) + return resp + + session.client.chat.completions.create = fake_create + + def fake_prepare(tc_dict, **_kwargs): + cid = tc_dict["id"] + + def _exec(p): + # Simulate an errored tool: the real exec records is_error via + # _report_tool_result, which sets _tool_error_flags. + session._tool_error_flags[p["call_id"]] = True + return cid, "boom" + + return { + "call_id": cid, + "func_name": "bash", + "needs_approval": False, + "execute": _exec, + } + + turns = [Turn.user("run it")] + with patch.object(session, "_prepare_tool", side_effect=fake_prepare): + session._run_agent( + turns, + tools=[{"type": "function", "function": {"name": "bash"}}], + label="task", + auto_tools={"bash"}, + parent_call_id="t1", + ) + + tool_turns = [t for t in turns if t.role is Role.TOOL] + assert tool_turns, "expected a tool result turn" + assert tool_turns[-1].is_error is True + # And it carries through the projection to the recalled step. + assert ChatSession._project_agent_steps(turns)[-1]["is_error"] is True + + +class TestExecTaskReporting: + """_exec_task self-reports the task_agent's OWN result — the live card's + only completion signal (the parent loop reports error/denied results + centrally but relies on each tool self-reporting its success result).""" + + def _bare_session(self): + session = _make_session() + session._agent_system_messages = [] + session._task_tools = [] + return session + + def test_success_reports_result(self): + session = self._bare_session() + with ( + patch.object(session, "_run_agent", return_value="the synthesis"), + patch.object(session, "_report_tool_result") as rpt, + ): + cid, out = session._exec_task({"call_id": "t1", "prompt": "go"}) + assert (cid, out) == ("t1", "the synthesis") + rpt.assert_called_once_with("t1", "task_agent", "the synthesis") + + def test_error_reports_is_error(self): + session = self._bare_session() + with ( + patch.object(session, "_run_agent", side_effect=RuntimeError("boom")), + patch.object(session, "_report_tool_result") as rpt, + ): + cid, out = session._exec_task({"call_id": "t1", "prompt": "go"}) + assert out == "Task error: boom" + rpt.assert_called_once_with("t1", "task_agent", "Task error: boom", is_error=True) + + class TestEvaluateOutputLLMStage: """End-to-end coverage of _evaluate_output with the LLM judge stage.""" diff --git a/tests/test_session_ui_base.py b/tests/test_session_ui_base.py index 09d8b670..a4162637 100644 --- a/tests/test_session_ui_base.py +++ b/tests/test_session_ui_base.py @@ -18,6 +18,8 @@ import threading from typing import Any from unittest.mock import MagicMock, patch +import pytest + from turnstone.core.session_ui_base import SessionUIBase @@ -2166,8 +2168,19 @@ class TestAgentScopeInfoSuppression: """While a task agent runs, its ``on_info`` progress chatter ("[task done] N chars", a tool's "fetched N chars") carries no call_id, so it can't nest under the task card. The web pane drops it for the duration rather than let - it escape to the top level; the depth counter keeps it correct under the - parent's parallel task pool.""" + it escape to the top level; the per-thread contextvar keeps it correct under + the parent's parallel task pool (siblings in other threads aren't suppressed).""" + + @pytest.fixture(autouse=True) + def _reset_scope(self): + # The scope depth is a module-level contextvar that persists across tests + # in the same thread; reset it around each so an unbalanced test (or a + # leak from elsewhere) can't bleed suppression into another test. + from turnstone.core.session_ui_base import _agent_scope_var + + token = _agent_scope_var.set(0) + yield + _agent_scope_var.reset(token) def test_on_info_suppressed_within_scope(self) -> None: ui = _make_ui() @@ -2211,3 +2224,42 @@ class TestAgentScopeInfoSuppression: ui.begin_agent_scope() ui.on_info("suppressed") assert lq.empty() + + +class TestAgentTrajectoryStash: + """The recall store retains a finished task agent's projected sub-trajectory + keyed by call_id, LRU-bounded. A miss is the honest "not retained" signal — + /history then renders the flat parent record, never a fabricated 0-step card.""" + + def test_stash_and_get_roundtrip(self) -> None: + ui = _make_ui() + steps = [ + {"id": "t1::c1", "name": "search", "arguments": "{}", "output": "ok", "is_error": False} + ] + ui.stash_agent_trajectory("t1", steps) + assert ui.get_agent_trajectory("t1") == steps + + def test_missing_returns_none(self) -> None: + assert _make_ui().get_agent_trajectory("nope") is None + + def test_empty_call_id_ignored(self) -> None: + ui = _make_ui() + ui.stash_agent_trajectory("", [{"id": "x"}]) + assert ui.get_agent_trajectory("") is None + + def test_restash_updates_value(self) -> None: + ui = _make_ui() + ui.stash_agent_trajectory("k", [{"id": "v1"}]) + ui.stash_agent_trajectory("k", [{"id": "v2"}]) + assert ui.get_agent_trajectory("k") == [{"id": "v2"}] + + def test_lru_evicts_oldest(self) -> None: + from turnstone.core.session_ui_base import _AGENT_TRAJECTORY_CAP + + ui = _make_ui() + for i in range(_AGENT_TRAJECTORY_CAP + 3): + ui.stash_agent_trajectory(f"t{i}", [{"id": f"t{i}"}]) + # The three oldest fell out → honest None; the newest is retained. + assert ui.get_agent_trajectory("t0") is None + assert ui.get_agent_trajectory("t2") is None + assert ui.get_agent_trajectory(f"t{_AGENT_TRAJECTORY_CAP + 2}") is not None diff --git a/tests/test_workstream_endpoints.py b/tests/test_workstream_endpoints.py index fc677e3d..c841606c 100644 --- a/tests/test_workstream_endpoints.py +++ b/tests/test_workstream_endpoints.py @@ -1117,6 +1117,77 @@ class TestExportInteractive: assert "should not leak" not in r.text +class TestHistoryAgentStepsOverlay: + """The history handler attaches a live task agent's stashed sub-trajectory to + its ``task_agent`` tool_call (``agent_steps``) so the client rebuilds the + card. A cold ws / evicted entry has none → no overlay (honest flat row).""" + + def _save_task_agent_turn(self, storage, ws_id): + storage.register_workstream(ws_id, kind="interactive", user_id="test-user") + storage.save_message(ws_id, "user", "kick off") + tc_json = json.dumps( + [ + { + "id": "task1", + "type": "function", + "function": { + "name": "task_agent", + "arguments": '{"prompt":"find call sites"}', + }, + } + ] + ) + storage.save_message(ws_id, "assistant", "Working", tool_calls=tc_json) + storage.save_message(ws_id, "tool", "4 call sites found", tool_call_id="task1") + + def test_attaches_agent_steps_from_live_stash(self, _inject_storage): + ws_id = "ws-recall-warm" + self._save_task_agent_turn(_inject_storage, ws_id) + steps = [ + { + "id": "task1::c1", + "name": "search", + "arguments": "{}", + "output": "12 matches", + "is_error": False, + } + ] + mock_ws = MagicMock() + mock_ws.id = ws_id + mock_ws.ui._pending_approval = None + mock_ws.ui.get_agent_trajectory = lambda cid: steps if cid == "task1" else None + mock_mgr = MagicMock() + mock_mgr.get.return_value = mock_ws + + r = _build_history_app(mock_mgr, _inject_storage).get( + f"/v1/api/workstreams/{ws_id}/history" + ) + assert r.status_code == 200 + assistant = next(m for m in r.json()["messages"] if m.get("role") == "assistant") + tc = assistant["tool_calls"][0] + assert tc["id"] == "task1" + assert tc["agent_steps"] == steps + + def test_no_overlay_when_not_retained(self, _inject_storage): + # Cold / evicted: get_agent_trajectory returns None → no agent_steps key, + # so the client renders the flat parent record (never a 0-step card). + ws_id = "ws-recall-cold" + self._save_task_agent_turn(_inject_storage, ws_id) + mock_ws = MagicMock() + mock_ws.id = ws_id + mock_ws.ui._pending_approval = None + mock_ws.ui.get_agent_trajectory = lambda cid: None + mock_mgr = MagicMock() + mock_mgr.get.return_value = mock_ws + + r = _build_history_app(mock_mgr, _inject_storage).get( + f"/v1/api/workstreams/{ws_id}/history" + ) + assert r.status_code == 200 + assistant = next(m for m in r.json()["messages"] if m.get("role") == "assistant") + assert "agent_steps" not in assistant["tool_calls"][0] + + class TestHistoryInteractive: """Interactive parity for the lifted ``GET /v1/api/workstreams/{ws_id}/history``.""" diff --git a/turnstone/core/session.py b/turnstone/core/session.py index 364a8af9..5268d90d 100644 --- a/turnstone/core/session.py +++ b/turnstone/core/session.py @@ -12,6 +12,7 @@ import base64 import collections import concurrent.futures import contextlib +import contextvars import copy import dataclasses import difflib @@ -143,6 +144,7 @@ from turnstone.core.tools import ( from turnstone.core.trajectory import ( EffectStatus, Role, + TextBlock, ToolCall, Turn, dicts_from_turns, @@ -277,6 +279,34 @@ def _encode_image_data_uri(raw: bytes, mime: str) -> str: # Upper bound on total skill content injected into system messages _MAX_SKILL_CONTENT: int = 32768 +# Cap on a sub-agent tool's RAW output before it enters the trajectory (the +# in-loop truncation in _run_agent) — bounds what the sub-agent's own model sees +# on its next turn. Distinct from (and larger than) the recall per-step cap. +_AGENT_TOOL_OUTPUT_CAP: int = 16000 + +# Per-step output/arguments cap for a recalled task-agent sub-trajectory (the +# projected step items /history attaches for the card rebuild). Keeps the +# recall payload small — the card shows a summary, not the full tool output. +_AGENT_STEP_OUTPUT_CAP: int = 2000 + +# Cap on the NUMBER of recalled steps per task agent. With the per-step output +# cap above, this bounds each stash entry's size (the LRU caps the agent count, +# not per-entry bytes), so a 100+-tool agent can't blow the memory budget; the +# overflow is replaced by one honest "(+N not retained)" marker step. +_AGENT_STEP_COUNT_CAP: int = 100 + +# Per-task-agent file-read tracking. ``_read_files`` (the blind-overwrite guard's +# memory of "files this agent has read") is a single set on the session, but the +# parent runs task agents in a 4-wide pool — sharing it lets a sibling's read +# suppress another agent's overwrite guard (a real blind-overwrite hazard). +# ``_exec_task`` installs a fresh per-run set in this ContextVar for the sub- +# agent's duration; ``_current_read_files`` reads it. ``None`` outside a sub- +# agent → the main session's set. A ContextVar (not threading.local) so the +# set/reset is balanced per ``_exec_task`` call and survives pool-thread reuse. +_active_read_files: contextvars.ContextVar[set[str] | None] = contextvars.ContextVar( + "turnstone_active_read_files", default=None +) + # Cap on the *content portion* (text after ``path:lineno:``) of an # emitted search result line. Defends the context budget against # pathological lines (minified blobs, base64 data, etc.). @@ -7466,6 +7496,15 @@ class ChatSession: "stop_on_error": args.get("stop_on_error") is True, } + @property + def _current_read_files(self) -> set[str]: + """The read-tracking set for the current execution context: a task + agent's own per-run set while one is active (so the 4-wide pool can't + cross-contaminate the blind-overwrite guard), else the main session's + ``_read_files``. See :data:`_active_read_files`.""" + active = _active_read_files.get() + return active if active is not None else self._read_files + def _prepare_read_file(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]: path = args.get("path", "") if not path: @@ -7519,7 +7558,7 @@ class ChatSession: "error": f"Error: limit must be >= 1 (got {limit})", } # Register early so a same-batch edit_file can pass the read guard. - self._read_files.add(resolved) + self._current_read_files.add(resolved) # Build header showing range if specified header = f"\u2699 read_file: {path}" if offset is not None or limit is not None: @@ -7641,7 +7680,7 @@ class ChatSession: if mode not in ("overwrite", "append"): mode = "overwrite" is_append = mode == "append" - is_overwrite = exists and resolved not in self._read_files and not is_append + is_overwrite = exists and resolved not in self._current_read_files and not is_append # Build preview preview_parts = [] @@ -7791,7 +7830,7 @@ class ChatSession: resolved = os.path.realpath(path) is_symlink = os.path.abspath(path) != resolved - if resolved not in self._read_files: + if resolved not in self._current_read_files: return { "call_id": call_id, "func_name": "edit_file", @@ -11920,11 +11959,11 @@ class ChatSession: all_lines, _, err = self._read_text_lines(path) if err: - self._read_files.discard(resolved) + self._current_read_files.discard(resolved) self._report_tool_result(call_id, "read_file", err, is_error=True) return call_id, err - self._read_files.add(resolved) + self._current_read_files.add(resolved) total_lines = len(all_lines) # Slice if offset/limit specified @@ -11957,11 +11996,11 @@ class ChatSession: try: size = os.path.getsize(resolved) except OSError as e: - self._read_files.discard(resolved) + self._current_read_files.discard(resolved) msg = f"Error: {path}: {e}" self._report_tool_result(call_id, "read_file", msg, is_error=True) return call_id, msg - self._read_files.add(resolved) + self._current_read_files.add(resolved) desc = f"image (no vision, {size:,} bytes)" self._report_tool_result(call_id, "read_file", desc) return call_id, ( @@ -11973,18 +12012,18 @@ class ChatSession: with open(resolved, "rb") as f: raw = f.read() except FileNotFoundError: - self._read_files.discard(resolved) + self._current_read_files.discard(resolved) msg = f"Error: {path} not found" self._report_tool_result(call_id, "read_file", msg, is_error=True) return call_id, msg except Exception as e: - self._read_files.discard(resolved) + self._current_read_files.discard(resolved) msg = f"Error reading {path}: {e}" self._report_tool_result(call_id, "read_file", msg, is_error=True) return call_id, msg if len(raw) > _IMAGE_SIZE_CAP: - self._read_files.discard(resolved) + self._current_read_files.discard(resolved) size_mb = len(raw) / (1024 * 1024) cap_mb = _IMAGE_SIZE_CAP / (1024 * 1024) msg = ( @@ -11994,7 +12033,7 @@ class ChatSession: self._report_tool_result(call_id, "read_file", msg, is_error=True) return call_id, msg - self._read_files.add(resolved) + self._current_read_files.add(resolved) mime, _ = mimetypes.guess_type(path) if not mime: mime = "image/png" @@ -12183,7 +12222,7 @@ class ChatSession: if err: self._report_tool_result(call_id, "diff_file", err, is_error=True) return call_id, err - self._read_files.add(resolved_a) + self._current_read_files.add(resolved_a) if path_b: label_b = path_b @@ -12191,7 +12230,7 @@ class ChatSession: if err: self._report_tool_result(call_id, "diff_file", err, is_error=True) return call_id, err - self._read_files.add(resolved_b) + self._current_read_files.add(resolved_b) else: label_b = "(provided content)" lines_b = (content_b or "").splitlines(keepends=True) @@ -12264,6 +12303,105 @@ class ChatSession: if fn is not None: fn() + @staticmethod + def _clip_with_count(text: str, cap: int) -> str: + """Head-clip ``text`` to ``cap`` chars with a uniform truncation marker. + The one format for sub-agent output clips — the in-loop 16k clip and the + recall per-step cap — distinct from the token-budget-aware + :meth:`_truncate_output`.""" + if len(text) <= cap: + return text + return text[:cap] + f"\n\n... (truncated from {len(text)} chars)" + + @staticmethod + def _iter_agent_tool_results( + agent_turns: list[Turn], + ) -> Iterator[tuple[ToolCall, Turn | None]]: + """Yield ``(tool_call, result_turn_or_None)`` for every sub-tool the + sub-agent issued, in order, pairing each call to its result FIFO per + call_id — a queue per id consumed once, NOT a last-wins dict, so a local + provider that reuses ids across turns (``call_0`` …) can't collapse + distinct calls onto one result. Shared by :meth:`_project_agent_steps` + (recall) and :meth:`_cancel_ledger` (cancel disposition).""" + pending: dict[str, collections.deque[Turn]] = {} + for t in agent_turns: + if t.role is Role.TOOL and t.tool_call_id: + pending.setdefault(t.tool_call_id, collections.deque()).append(t) + for t in agent_turns: + if t.role is not Role.ASSISTANT: + continue + for tc in t.tool_calls: + q = pending.get(tc.id) + yield tc, (q.popleft() if q else None) + + @staticmethod + def _project_agent_steps(agent_turns: list[Turn]) -> list[dict[str, Any]]: + """Project a finished sub-agent's trajectory into recall step items for + the task card — one per sub-tool call, matched to its result (FIFO per + call_id via :meth:`_iter_agent_tool_results`). + + Reads the tool turn's payload directly rather than via ``Turn.text``, + which raises on a multimodal ``list[dict]`` result (the deferred-finding + landmine); a non-``str`` payload becomes a light placeholder so the + recall stays text-only and ``/history`` doesn't carry image bytes. Each + step's output AND arguments are capped (:data:`_AGENT_STEP_OUTPUT_CAP`) + and the step COUNT (:data:`_AGENT_STEP_COUNT_CAP`); on overflow the most + RECENT steps are kept (a sub-agent's latest edits/writes matter more on + recall than its opening searches) behind an honest leading marker — so + the stash stays memory-bounded even for a 100+-tool agent and never + silently under-reports its step count.""" + pairs = list(ChatSession._iter_agent_tool_results(agent_turns)) + dropped = max(0, len(pairs) - _AGENT_STEP_COUNT_CAP) + # Build dicts only for the retained tail, not the dropped head. + tail = pairs[-_AGENT_STEP_COUNT_CAP:] if dropped else pairs + steps: list[dict[str, Any]] = [] + if dropped: + # Leading marker naming how many earlier steps fell out — honest, and + # correct now that the RETAINED steps are the recent ones. + steps.append( + { + "id": "", + "name": "…", + "arguments": "{}", + "output": f"(+{dropped} earlier steps not retained)", + "is_error": False, + } + ) + for tc, res in tail: + output, is_error = "", False + if res is not None: + is_error = res.is_error + raw = ( + res.content[0].text + if res.content and isinstance(res.content[0], TextBlock) + else "" + ) + output = raw if isinstance(raw, str) else "[non-text result]" + steps.append( + { + "id": tc.id, + "name": tc.name, + # Clip arguments too: a write_file/edit_file call carries full + # file content here, which the per-step output cap alone would + # leave unbounded in the in-memory stash. + "arguments": ChatSession._clip_with_count(tc.arguments, _AGENT_STEP_OUTPUT_CAP), + "output": ChatSession._clip_with_count(output, _AGENT_STEP_OUTPUT_CAP), + "is_error": is_error, + } + ) + return steps + + def _stash_agent_trajectory(self, call_id: str | None, agent_turns: list[Turn]) -> None: + """Retain a finished task agent's projected sub-trajectory for ``/history`` + card recall (getattr-guarded → no-op on CLI / eval / fixtures). Called + from ``_exec_task``'s ``finally`` so it captures the full record on + success and the partial one on cancel/error — both honest.""" + if not call_id: + return + fn = getattr(self.ui, "stash_agent_trajectory", None) + if fn is not None: + fn(call_id, self._project_agent_steps(agent_turns)) + def _run_agent( self, agent_turns: list[Turn], @@ -12461,9 +12599,16 @@ class ChatSession: # output_warning still nests under the task card. self._note_agent_child(tc_dict["id"], parent_call_id) + # is_error for the recalled step: the guard / prepare / unknown + # branches produce explicit error text; the execute paths record + # the authoritative flag via ``_report_tool_result`` (consumed + # below). Without this every recalled sub-step reads as success + # and a failed sub-tool recalls as a green "done" card. + is_tool_error = False # Guard 1: block recursive agent calls. if tool_name == "task_agent": output = "Error: agents cannot spawn further agents" + is_tool_error = True # Guard 2: tool not in this agent's API tool list. elif tool_name not in tool_names: output = ( @@ -12471,11 +12616,13 @@ class ChatSession: f"agent mode. " f"Available: {', '.join(sorted(tool_names))}" ) + is_tool_error = True else: prepared = self._prepare_tool(tc_dict) if prepared.get("error"): output = prepared["error"] + is_tool_error = True # Auto-execute tools in the auto_tools set. elif tool_name in auto_tools: # Paint the step pending under the task card before it @@ -12484,6 +12631,7 @@ class ChatSession: # paint via approve_tools instead. self._paint_agent_step(parent_call_id, prepared) _, output = prepared["execute"](prepared) + is_tool_error = self._tool_error_flags.pop(tc_dict["id"], False) # Tools not in auto_tools require user approval. elif "execute" in prepared: approved, _ = self.ui.approve_tools([prepared]) @@ -12491,11 +12639,15 @@ class ChatSession: prepared["denied"] = True prepared["denial_msg"] = "Denied by user" if prepared.get("denied"): + # A denial is not an execution error — keep is_error + # False so recall shows the denial text, not red. output = prepared.get("denial_msg", "Denied by user") else: _, output = prepared["execute"](prepared) + is_tool_error = self._tool_error_flags.pop(tc_dict["id"], False) else: output = f"Unknown tool: {tool_name}" + is_tool_error = True # Output guard: evaluate before truncation so the guard # sees full output (credentials split by truncation would @@ -12511,8 +12663,8 @@ class ChatSession: # Truncate large tool outputs to avoid blowing context limits. # Agents operate autonomously; they can refine their queries # if truncation loses important detail. - if isinstance(output, str) and len(output) > 16000: - output = output[:16000] + f"\n\n... (truncated from {len(output)} chars)" + if isinstance(output, str) and len(output) > _AGENT_TOOL_OUTPUT_CAP: + output = self._clip_with_count(output, _AGENT_TOOL_OUTPUT_CAP) # NOTE: for a vision tool result ``output`` is a list[dict] of # inline content parts (read_file on an image). It lowers back @@ -12524,7 +12676,7 @@ class ChatSession: # ``create_completion`` (it currently isn't) plus content- # addressed byte storage — deferred to the recall/persist work # where that attachment path is already in scope. - agent_turns.append(Turn.tool(tc_dict["id"], output)) + agent_turns.append(Turn.tool(tc_dict["id"], output, is_error=is_tool_error)) turn += 1 # Exhausted tool turns — force a final synthesis response. @@ -12606,8 +12758,14 @@ class ChatSession: Turn.user(prompt), ] self._begin_agent_scope() + # Per-sub-agent file-read tracking. COPY the parent's current set in (so + # the agent can edit a file the parent already read for it — no spurious + # "must read before editing"), but as an INDEPENDENT set, so a pool + # sibling's reads can't suppress THIS agent's blind-overwrite guard. The + # agent's own reads merge back to the parent in ``finally``. + read_token = _active_read_files.set(set(self._current_read_files)) try: - return call_id, self._run_agent( + result = self._run_agent( agent_turns, label="task", tools=self._task_tools, @@ -12615,6 +12773,14 @@ class ChatSession: agent_alias=item.get("model_override"), parent_call_id=call_id, ) + # Self-report the task_agent's OWN result. The parent run-loop only + # reports error/denied/exception results centrally; success results + # rely on each tool self-reporting (bash, search, … all do), and the + # task_agent never did — so without this the live card has no + # completion signal (it stays "running" and the synthesis never + # renders live, only on reload). + self._report_tool_result(call_id, "task_agent", result) + return call_id, result except GenerationCancelled: # Fold back an honest disposition built from the agent's own # ledger. ``agent_turns`` is mutated in place by @@ -12629,17 +12795,35 @@ class ChatSession: # fabricate the acknowledgment but must not fabricate the # outcome … unknown, never none"). self._tool_status[call_id] = self._cancelled_agent_status(agent_turns) - return call_id, self._cancelled_agent_disposition(agent_turns, "task") + disposition = self._cancelled_agent_disposition(agent_turns, "task") + self._report_tool_result(call_id, "task_agent", disposition) + return call_id, disposition except KeyboardInterrupt: # CLI Ctrl-C: keep the terse string and let the outer loop own # propagation (unchanged behavior). return call_id, "(task interrupted by user)" except Exception as e: - self.ui.on_info(f"[task error] {e}") - return call_id, f"Task error: {e}" + # Report as the task_agent's errored result so the card flips to + # "failed" and the message renders (replaces the old on_info, which + # was suppressed during the agent scope anyway). + msg = f"Task error: {e}" + self._report_tool_result(call_id, "task_agent", msg, is_error=True) + return call_id, msg finally: + # Teardown first (cheap + critical): merge the agent's reads back to + # the parent, restore the contextvar, drop the scope + child tags — + # all BEFORE the best-effort stash, so a stash raise can't leak the + # scope depth (phantom card nesting) or the child registry. + sub_reads = _active_read_files.get() + _active_read_files.reset(read_token) + if sub_reads: + self._current_read_files.update(sub_reads) self._end_agent_scope() self._clear_agent_children(call_id) + try: + self._stash_agent_trajectory(call_id, agent_turns) + except Exception: + log.debug("task_agent.stash_failed call_id=%s", call_id, exc_info=True) @staticmethod def _cancel_ledger( @@ -12656,18 +12840,15 @@ class ChatSession: it returned, everything after never started. (The LAST gap would invert unknown/none on a multi-call turn.) Shared by the disposition string and its typed status so the two can't disagree. + + Pairs via :meth:`_iter_agent_tool_results` (FIFO per call_id), so on a + provider that reuses ids a half-answered colliding pair is correctly read + as one answered + one in-flight gap, not (set-membership) both answered. """ - answered: set[str] = set() - for t in agent_turns: - if t.role is Role.TOOL and t.tool_call_id: - answered.add(t.tool_call_id) - issued: list[tuple[str, bool]] = [] - for t in agent_turns: - if t.role is not Role.ASSISTANT: - continue - for tc in t.tool_calls: - name = (tc.name or "tool").strip() - issued.append((name, tc.id in answered)) + issued = [ + ((tc.name or "tool").strip(), res is not None) + for tc, res in ChatSession._iter_agent_tool_results(agent_turns) + ] first_gap = next((i for i, (_n, ans) in enumerate(issued) if not ans), None) return issued, first_gap @@ -13472,7 +13653,7 @@ class ChatSession: os.makedirs(os.path.dirname(resolved) or ".", exist_ok=True) with open(resolved, "a" if is_append else "w") as f: f.write(content) - self._read_files.add(resolved) + self._current_read_files.add(resolved) verb = "Appended" if is_append else "Wrote" msg = f"{verb} {len(content)} chars to {path}" self._report_tool_result(call_id, "write_file", msg) diff --git a/turnstone/core/session_routes.py b/turnstone/core/session_routes.py index 80aa7beb..b5cf89f3 100644 --- a/turnstone/core/session_routes.py +++ b/turnstone/core/session_routes.py @@ -3240,6 +3240,29 @@ def make_history_handler(cfg: SessionEndpointConfig) -> Handler: messages = await asyncio.to_thread( project_history_messages, to_project, awaiting_approval ) + # Task-agent recall: attach each task_agent tool_call's stashed + # sub-trajectory (projected step items) so the client's + # ``replayHistory`` can rebuild the collapsible card. Live + # in-memory session only — a cold/closed ws, or an entry evicted + # past the LRU cap, has none, so the card renders the flat parent + # record ("not retained"), never a fabricated 0-step card. + # [[HYPOTHESIS]] an unobserved sub-trajectory is unknown, not none. + get_traj = getattr(getattr(live_session, "ui", None), "get_agent_trajectory", None) + if get_traj is not None: + for msg in messages: + for tc in msg.get("tool_calls") or (): + # Only task_agent calls ever stash — skip the rest so + # we don't take the agent-state lock once per tool_call + # on a long history for ids that can never match. + if tc.get("name") != "task_agent": + continue + steps = get_traj(tc.get("id") or "") + # Attach only a well-formed, non-empty list — the + # ``get_agent_trajectory`` contract is ``list | None``, + # and the guard keeps a malformed result out of the + # JSON payload (a non-list can't be serialized). + if isinstance(steps, list) and steps: + tc["agent_steps"] = steps except Exception: # Operationally interesting: a persistent decoration # failure (missing migration, driver mismatch, schema diff --git a/turnstone/core/session_ui_base.py b/turnstone/core/session_ui_base.py index 2724a656..d8f3edc5 100644 --- a/turnstone/core/session_ui_base.py +++ b/turnstone/core/session_ui_base.py @@ -25,6 +25,7 @@ from __future__ import annotations import collections import contextlib +import contextvars import copy import json import math @@ -44,6 +45,25 @@ log = get_logger(__name__) # from bloating memory. _DEFAULT_LISTENER_QUEUE_MAX = 500 +# Recall: how many finished task agents' projected sub-trajectories to retain +# in memory for /history card rebuilds. LRU-bounded so a marathon workstream +# can't grow it without limit; eviction (and a cold reopen, which starts empty) +# recalls honestly as "not retained" rather than a fabricated 0-step card. +_AGENT_TRAJECTORY_CAP = 256 + +# Sub-agent scope, PER-THREAD. A task agent's progress chatter reaches the UI as +# ``on_info`` lines ("[task done] N chars", a tool's "fetched N chars") that +# carry no call_id — they can't nest under the task card and would escape to the +# top level, so the web pane drops them while a sub-agent runs. A contextvar, +# not a session-global counter, so the suppression follows the sub-agent's OWN +# thread: a parallel sibling tool running in another pool thread keeps its info +# lines. Incremented/decremented by begin_/end_agent_scope around each +# ``_run_agent``; the CLI overrides ``on_info`` and is unaffected (no card → the +# lines are its only signal). +_agent_scope_var: contextvars.ContextVar[int] = contextvars.ContextVar( + "turnstone_agent_scope_depth", default=0 +) + def _resolve_event_buffer_max() -> int: """Read ``TURNSTONE_SSE_EVENT_BUFFER_MAX`` env override at import time. @@ -262,16 +282,20 @@ class SessionUIBase: # own lock so the hot fan-out path never serializes on ``_listeners_lock``. self._agent_children: dict[str, str] = {} self._agent_children_lock = threading.Lock() - # Sub-agent scope depth. A task agent's progress chatter reaches the UI - # as ``on_info`` lines ("[task done] N chars", a tool's "fetched N - # chars, extracting...") that carry no call_id — so they can't nest under - # the task card and would escape to the top level. While any ``_run_agent`` - # is in flight this is > 0 and the web pane drops those info lines (the - # card already shows the steps + result). A depth, not a flag, so the - # parent's 4-wide parallel task pool nests correctly; guarded by - # ``_agent_children_lock``. The CLI overrides ``on_info`` and is - # unaffected (it has no card, so the lines are its only signal). - self._agent_scope_depth = 0 + # Recall store: a finished task agent's projected sub-trajectory (step + # items: id/name/arguments/output/is_error), keyed by its (parent) + # call_id, so /history can rebuild the collapsible card after a fresh + # connect / reopen while the workstream is still in memory. LRU-bounded + # (oldest evicted past the cap); IN-MEMORY ONLY — durable persistence is + # deferred, so a cold reopen (new session) finds it empty and renders the + # flat parent record. Guarded by ``_agent_children_lock`` (same low-rate + # agent-state path). [[HYPOTHESIS]] the sub-trajectory is the ledger; + # an absent one is unknown ("not retained"), never none ("0 steps"). + self._agent_trajectories: collections.OrderedDict[str, list[dict[str, Any]]] = ( + collections.OrderedDict() + ) + # (Sub-agent ``on_info`` suppression is per-thread via the module-level + # ``_agent_scope_var`` contextvar — no instance field.) # Approval blocking — the worker thread calls approve_tools # which waits on _approval_event; the /approve endpoint sets # it via resolve_approval. @@ -554,17 +578,40 @@ class SessionUIBase: def begin_agent_scope(self) -> None: """Enter a task agent's execution. Until the matching - :meth:`end_agent_scope`, the web pane drops ``on_info`` lines (the task - card carries the sub-agent's visible output, so the info chatter would - only escape to the top level). Depth-counted for the parent's parallel - task pool; the session brackets each ``_run_agent`` with this pair.""" - with self._agent_children_lock: - self._agent_scope_depth += 1 + :meth:`end_agent_scope`, the web pane drops ``on_info`` lines from THIS + thread (the task card carries the sub-agent's visible output, so the info + chatter would only escape to the top level). Per-thread via + :data:`_agent_scope_var` so a parallel SIBLING tool in another pool + thread isn't suppressed; depth-counted for safety though task agents + don't nest. The session brackets each ``_run_agent`` with this pair.""" + _agent_scope_var.set(_agent_scope_var.get() + 1) def end_agent_scope(self) -> None: """Leave a task agent's execution (see :meth:`begin_agent_scope`).""" + _agent_scope_var.set(max(0, _agent_scope_var.get() - 1)) + + def stash_agent_trajectory(self, call_id: str, steps: list[dict[str, Any]]) -> None: + """Retain a finished task agent's projected sub-trajectory (step items) + keyed by its call_id so ``/history`` can rebuild the card on a fresh + connect / reopen while the workstream is in memory. LRU-bounded; the + newest write is most-recently-used, oldest evicted past the cap. See + :data:`_AGENT_TRAJECTORY_CAP` and the field comment in ``__init__``.""" + if not call_id: + return with self._agent_children_lock: - self._agent_scope_depth = max(0, self._agent_scope_depth - 1) + self._agent_trajectories[call_id] = steps + self._agent_trajectories.move_to_end(call_id) + while len(self._agent_trajectories) > _AGENT_TRAJECTORY_CAP: + self._agent_trajectories.popitem(last=False) + + def get_agent_trajectory(self, call_id: str) -> list[dict[str, Any]] | None: + """Read a stashed sub-trajectory, or ``None`` if not retained (evicted, + or a cold reopen with an empty store). ``None`` is the honest "unknown" + signal — the caller renders the flat parent record, never a 0-step card.""" + if not call_id: + return None + with self._agent_children_lock: + return self._agent_trajectories.get(call_id) def _register_listener( self, maxsize: int = _DEFAULT_LISTENER_QUEUE_MAX @@ -2504,12 +2551,13 @@ class SessionUIBase: log.warning("Failed to record usage event", exc_info=True) def on_info(self, message: str) -> None: - # Inside a task agent, progress chatter ("[task done] N chars", a tool's - # "fetched N chars, extracting...") carries no call_id, so it can't nest - # under the task card — drop it on the web pane rather than let it escape - # to the top level. The card shows the steps + result; the CLI overrides - # this method and keeps the lines (no card there). - if self._agent_scope_depth > 0: + # Inside a task agent (on THIS thread), progress chatter ("[task done] N + # chars", a tool's "fetched N chars") carries no call_id, so it can't + # nest under the task card — drop it on the web pane rather than let it + # escape to the top level. Per-thread, so a parallel sibling tool's info + # still shows. The card shows the steps + result; the CLI overrides this + # method and keeps the lines (no card there). + if _agent_scope_var.get() > 0: return self._enqueue({"type": "info", "message": message}) diff --git a/turnstone/shared_static/interactive.js b/turnstone/shared_static/interactive.js index 603bceeb..03dfdd63 100644 --- a/turnstone/shared_static/interactive.js +++ b/turnstone/shared_static/interactive.js @@ -1979,6 +1979,10 @@ class Pane { // Replaces a JSON.stringify→dataset→JSON.parse round-trip with an // in-memory map keyed by call_id. const pendingAssessments = {}; + // Task-agent recall: call_id -> card .conv-agent wrap, so the tool-result + // branch can flip the card's done/error state from the task's own result + // (mirroring the live appendToolOutput), not from sub-step errors. + const agentCardWraps = {}; let lastToolBlock = null; for (let i = 0; i < messages.length; i++) { const msg = messages[i]; @@ -2054,30 +2058,8 @@ class Pane { msg.tool_calls.forEach((tc, idx) => { // Synthesize the live `item` shape from the stored tool_call so // replay renders the SAME .conv-row as the live path. - let header = ""; - try { - const args = JSON.parse(tc.arguments); - if (tc.name === "bash") { - header = String(Object.values(args)[0] || ""); - } else { - const parts = []; - const keys = Object.keys(args); - for (let k = 0; k < keys.length; k++) { - const val = args[keys[k]]; - let valStr = - val === null || val === undefined ? "null" : String(val); - if (valStr.length > 80) - valStr = valStr.substring(0, 77) + "..."; - parts.push(keys[k] + ": " + valStr); - } - header = parts.join("\n"); - } - } catch (e) { - header = String(tc.arguments || "").substring(0, 100); - } - const item = { func_name: tc.name, call_id: tc.id || "", header }; const row = buildToolDiv( - item, + synthToolItem(tc), indexLabel(idx, msg.tool_calls.length), ); // Verdict anchored to THIS row; replay verdicts are final. @@ -2087,6 +2069,13 @@ class Pane { ); } block.appendChild(row); + // Task-agent recall: rebuild the collapsible card under this row + // from its stashed sub-trajectory (the /history `agent_steps` + // overlay). Absent ⇒ flat parent row (cold / not-retained). + if (tc.agent_steps && tc.agent_steps.length) { + const wrap = this._replayAgentCard(row, tc.agent_steps); + if (tc.id) agentCardWraps[tc.id] = wrap; + } // Output-guard finding — deferred until the tool result lands so // it anchors under the output (mirrors live showOutputWarning). if ( @@ -2152,11 +2141,7 @@ class Pane { if (media) { insertChained(buildMediaEmbed(media, stripped)); } else { - const out = renderToolOutput(stripped, isToolError); - if (out.textContent.split("\n").length > 10) { - makeCollapsible(out); - } - insertChained(out); + insertChained(renderCollapsibleOutput(stripped, isToolError)); } } if ( @@ -2177,6 +2162,14 @@ class Pane { delete pendingAssessments[msg.tool_call_id]; } } + // Task-agent recall: flip its card done/error from the task's OWN + // result (matching the live appendToolOutput) — NOT from sub-step + // errors, since a sub-tool can fail and the agent still synthesize. + if (msg.tool_call_id && agentCardWraps[msg.tool_call_id]) { + agentCardWraps[msg.tool_call_id].dataset.state = msg.is_error + ? "error" + : "done"; + } } } else if (msg.role === "system") { // First-class operator-context turn (output-guard finding, user @@ -2553,6 +2546,28 @@ class Pane { card.label.textContent = n === 1 ? "1 step" : n + " steps"; } + _replayAgentCard(row, steps) { + // Rebuild a finished task agent's card from its recalled step items + // (/history `agent_steps`), mirroring the live _routeAgentItems nesting so a + // reload looks identical: a collapsed body of step rows, each with its + // result. Recall is terminal — no live approval affordances. Card state + // defaults to "done"; the caller flips it to "error" from the task's OWN + // result (the role==="tool" branch), matching the live path — sub-step + // errors don't decide it (an agent can recover and synthesize fine). + const card = buildAgentCardBody(); + steps.forEach((step) => { + card.body.appendChild(buildToolDiv(synthToolItem(step), "")); + const out = stripAnsi(String(step.output || "")).trim(); + if (out) { + card.body.appendChild(renderCollapsibleOutput(out, !!step.is_error)); + } + }); + card.wrap.dataset.state = "done"; + this._updateAgentLabel(card); + row.appendChild(card.wrap); + return card.wrap; + } + appendToolOutput(callId, name, output, isError) { // Capture pin before the streamEl removal + result insertion change // scrollHeight — see announceToolBlock. The result block is the other @@ -2650,7 +2665,10 @@ class Pane { } } - const out = renderToolOutput(stripped, isError); + // The media / MCP-error dispatch above both early-return, so by here it's + // the plain-output path — the shared helper applies (test_app_js pins that + // tryParseMcpError precedes this renderer call). + const out = renderCollapsibleOutput(stripped, isError); // Mark the parent approval block as errored if ( @@ -2662,10 +2680,6 @@ class Pane { appendToolErrorBadge(parentBlock); } - if (out.textContent.split("\n").length > 10) { - makeCollapsible(out); - } - target.after(out); this.scrollToBottom(stick); } @@ -3314,6 +3328,35 @@ function buildToolDiv(item, indexLabel) { return row; } +// Synthesize the live `item` shape ({func_name, call_id, header}) from a stored +// tool_call ({name, id, arguments}) so /history replay AND task-agent card +// recall render the SAME .conv-row as the live path. Header derivation mirrors +// the live serialize: bash shows the command; other tools show `key: value` +// lines, values clipped at 80 chars; unparseable args fall back to a raw clip. +function synthToolItem(tc) { + tc = tc || {}; + let header = ""; + try { + const args = JSON.parse(tc.arguments); + if (tc.name === "bash") { + header = String(Object.values(args)[0] || ""); + } else { + const parts = []; + const keys = Object.keys(args); + for (let k = 0; k < keys.length; k++) { + const val = args[keys[k]]; + let valStr = val === null || val === undefined ? "null" : String(val); + if (valStr.length > 80) valStr = valStr.substring(0, 77) + "..."; + parts.push(keys[k] + ": " + valStr); + } + header = parts.join("\n"); + } + } catch (e) { + header = String(tc.arguments || "").substring(0, 100); + } + return { func_name: tc.name, call_id: tc.id || "", header }; +} + function renderVerdictBadge(verdict, judgePending) { // Thin wrapper over the shared builder (returns a fragment [badge, detail]). return buildConvVerdict(verdict, { judgePending }); @@ -3381,6 +3424,16 @@ function renderToolOutput(stripped, isError) { return out; } +// Shared recipe: render tool output and auto-collapse past 10 lines. The one +// source for the live appendToolOutput's plain-output path, the /history +// tool-result replay, and the task-agent card recall — so the collapse +// threshold can't drift between them. +function renderCollapsibleOutput(stripped, isError) { + const out = renderToolOutput(stripped, isError); + if (out.textContent.split("\n").length > 10) makeCollapsible(out); + return out; +} + function buildMediaEmbed(media, rawJson) { const wrapper = document.createElement("div"); wrapper.className = "media-embed";