refactor: add is_error to on_tool_result protocol, remove text heuris… (#207)

* refactor: add is_error to on_tool_result protocol, remove text heuristics

Add is_error keyword arg to SessionUI.on_tool_result() so tools
report errors structurally. Server and JS client no longer guess
from output text prefixes — each tool sets the flag at the source.

Bash tool: exit code >= 2 is error, exit code 1 is ambiguous (grep
no-match). History reconstruction keeps text heuristic as fallback
for pre-migration data.

Update SDKs (Python + TypeScript), test mocks, docs, and diagrams.

* fix: infinite recursion in _report_tool_result, signal exits, stale docs

* fix: add _tool_error_flags to test_load_skill ChatSession stubs
This commit is contained in:
Patrick Buckley
2026-03-28 22:09:52 -07:00
committed by GitHub
parent d00aae2429
commit f74aa2264e
27 changed files with 185 additions and 119 deletions
+2 -2
View File
@@ -386,10 +386,10 @@ Each item in `items` (shared by `tool_info` and `approve_request`):
{"type": "tool_output_chunk", "call_id": "call_abc123", "chunk": "Building project...\n"}
```
**`tool_result`** -- final output from a completed tool execution. The `call_id` matches the corresponding `tool_info`/`approve_request` item and any preceding `tool_output_chunk` events. For bash tools, this arrives after all streaming chunks and includes both stdout and stderr.
**`tool_result`** -- final output from a completed tool execution. The `call_id` matches the corresponding `tool_info`/`approve_request` item and any preceding `tool_output_chunk` events. For bash tools, this arrives after all streaming chunks and includes both stdout and stderr. The `is_error` field is `true` when the tool execution failed (e.g. bash exit code >= 2 or signal, file not found, timeout). Exit code 1 is ambiguous (e.g. `grep` no-match) and is not flagged. User denials are tracked separately via a `denied` flag. Clients should use `is_error` instead of text-prefix heuristics.
```json
{"type": "tool_result", "call_id": "call_abc123", "name": "bash", "output": "file1.py\nfile2.py\n"}
{"type": "tool_result", "call_id": "call_abc123", "name": "bash", "output": "file1.py\nfile2.py\n", "is_error": false}
```
**`status`** -- token usage statistics, sent after each model turn.
+1 -1
View File
@@ -242,7 +242,7 @@ class SessionUI(Protocol):
def on_content_token(self, text: str) -> None: ...
def on_stream_end(self) -> None: ...
def approve_tools(self, items: list[dict]) -> tuple[bool, str | None]: ...
def on_tool_result(self, call_id: str, name: str, output: str) -> None: ...
def on_tool_result(self, call_id: str, name: str, output: str, *, is_error: bool = False) -> None: ...
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
def on_status(self, usage: dict, context_window: int, effort: str) -> None: ...
def on_plan_review(self, content: str) -> str: ...
+1 -1
View File
@@ -12,7 +12,7 @@ interface "SessionUI" as SessionUI <<Protocol>> {
+ on_content_token(text: str)
+ on_stream_end()
+ approve_tools(items: list) → (bool, str|None)
+ on_tool_result(call_id: str, name: str, output: str)
+ on_tool_result(call_id: str, name: str, output: str, *, is_error: bool = False)
+ on_tool_output_chunk(call_id: str, chunk: str)
+ on_status(usage: dict, ctx_window: int, effort: str)
+ on_plan_review(content: str) → str
+2 -1
View File
@@ -133,7 +133,8 @@ group loop [while tool_calls present]
note right of TP
bash: on_tool_output_chunk(call_id, line)
called per stdout line,
then on_tool_result(call_id, name, output).
then on_tool_result(call_id, name, output, is_error).
is_error=True when execution failed.
call_id routes chunks/results to correct
tool div during parallel execution.
Other tools: on_tool_result() only.
+1 -1
View File
@@ -127,7 +127,7 @@ partition "Phase 3: Execute" #E3F2FD {
:_truncate_output() on each result\n(max context_window × chars_per_token × 0.5 chars\ndefault: ~context_window × 2 chars);
:bash: ui.on_tool_output_chunk(call_id, line) per stdout line;
:ui.on_tool_result(call_id, name, output) for each;
:ui.on_tool_result(call_id, name, output, is_error) for each;
if (plan tool was executed?) then (yes)
:ui.on_plan_review(output);
+1
View File
@@ -147,6 +147,7 @@ package "Outbound Events (Bridge → Client)" as OutPkg #E3F2FD {
+ call_id: str
+ name: str
+ output: str
+ is_error: bool
}
class PlanReviewEvent {
type = "plan_review"
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0e605963c649574c7bf987b2d338257c78bc1fc68b524ac8276bb25365035e06
size 594096
oid sha256:6471e611beebf647f3a191eb16588571a404cc52a43067883a2b6f06dd936376
size 594676
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:da9d32000e3d92d92ce621661ced60f276f9b5be652f5ed6123b400505415f4a
size 319702
oid sha256:3aa8d972bba40d78152f9f0c762b9f5ec616d8052c45fa52b7dd1c679ed81d61
size 325245
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:43844b07d36beb04db871f6795a3f3be17852a6a484fdc0ea207403bd7f512a6
size 274286
oid sha256:72b3932ce99a860f5069544cd8423d3cdae3a51f6566db262b19ced19c780eb0
size 274374
+2 -2
View File
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2636e2d4d2f84f26f93de6e984b780f6ad5bf8d3618a0202ea0a2ee80859c5b5
size 312409
oid sha256:d831c5e10266f0232262b6a29b0ea8e45b3cc63df75f716a80f18462b4f85e66
size 319125
+1 -1
View File
@@ -127,7 +127,7 @@ SSE events are deserialized into typed dataclasses. Use `event.type` to discrimi
| `reasoning` | `ReasoningEvent` | `text` |
| `tool_info` | `ToolInfoEvent` | `items` |
| `approve_request` | `ApproveRequestEvent` | `items` |
| `tool_result` | `ToolResultEvent` | `call_id`, `name`, `output` |
| `tool_result` | `ToolResultEvent` | `call_id`, `name`, `output`, `is_error` |
| `tool_output_chunk` | `ToolOutputChunkEvent` | `call_id`, `chunk` |
| `status` | `StatusEvent` | `prompt_tokens`, `total_tokens`, `pct`, `effort`, `cache_creation_tokens`, `cache_read_tokens` |
| `plan_review` | `PlanReviewEvent` | `content` |
+7 -2
View File
@@ -102,10 +102,15 @@ Each item's `execute` callable is invoked:
- Errored or denied items return their error/denial message without executing.
- The `bash` tool streams stdout incrementally: each line calls
`ui.on_tool_output_chunk(call_id, line)` as it is produced, then the final
combined output (stdout + stderr) is delivered via `ui.on_tool_result(call_id, name, output)`.
combined output (stdout + stderr) is delivered via
`ui.on_tool_result(call_id, name, output, is_error=...)`.
The `call_id` links `tool_info`/`approve_request` items to their streaming chunks and
final result, enabling correct routing when multiple bash tools run in parallel.
Other tools deliver results atomically via `ui.on_tool_result(call_id, name, output)` only.
The `is_error` flag is `True` when the tool execution failed (e.g. bash exit code >= 2
or signal, file not found, timeout). Exit code 1 is ambiguous and not flagged; user
denials are tracked separately. This removes the need for text-prefix heuristics.
Other tools deliver results atomically via
`ui.on_tool_result(call_id, name, output, is_error=...)` only.
- Special post-execution gate for `plan`: the plan output is shown to the user
for review, and the user can reject or annotate it.
+1
View File
@@ -59,6 +59,7 @@ export interface ToolResultEvent {
call_id: string;
name: string;
output: string;
is_error?: boolean;
}
export interface ToolOutputChunkEvent {
+1 -1
View File
@@ -37,7 +37,7 @@ class NullUI:
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output):
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
+2
View File
@@ -54,6 +54,7 @@ def _make_session(skills: list[dict[str, Any]] | None = None):
session._notify_on_complete = "{}"
session.messages = []
session._config = {}
session._tool_error_flags = {}
# Stub set_skill to just record the call
session._set_skill_called: list[str | None] = []
@@ -423,6 +424,7 @@ class TestSkillCatalogDisclosure:
session._tool_search = None
session._mcp_client = None
session._notify_on_complete = "{}"
session._tool_error_flags = {}
# Memory stubs
session._memory_config = MagicMock()
+1 -1
View File
@@ -339,7 +339,7 @@ class _FakeUI:
def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]:
return True, None
def on_tool_result(self, call_id: str, name: str, output: str) -> None: ...
def on_tool_result(self, call_id: str, name: str, output: str, **kwargs: Any) -> None: ...
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None: ...
def on_plan_review(self, content: str) -> str:
+1 -1
View File
@@ -29,7 +29,7 @@ class NullUI:
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output):
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
+1 -1
View File
@@ -88,7 +88,7 @@ class RecordingUI:
def approve_tools(self, items):
return True, None # auto-approve everything
def on_tool_result(self, call_id, name, output):
def on_tool_result(self, call_id, name, output, **kwargs):
self.tool_results.append((call_id, name, output))
def on_tool_output_chunk(self, call_id, chunk):
+1 -1
View File
@@ -28,7 +28,7 @@ class NullUI:
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output):
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
+1 -1
View File
@@ -52,7 +52,7 @@ class NullUI:
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output):
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
+1 -1
View File
@@ -54,7 +54,7 @@ class FakeUI:
def approve_tools(self, items):
return True, None
def on_tool_result(self, call_id, name, output):
def on_tool_result(self, call_id, name, output, **kwargs):
pass
def on_tool_output_chunk(self, call_id, chunk):
+17 -3
View File
@@ -251,7 +251,14 @@ class TerminalUI(SessionUI):
item["denial_msg"] = denial_msg
return False, None
def on_tool_result(self, call_id: str, name: str, output: str) -> None:
def on_tool_result(
self,
call_id: str,
name: str,
output: str,
*,
is_error: bool = False,
) -> None:
pass # Optional: display summary
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None:
@@ -424,9 +431,16 @@ class WorkstreamTerminalUI(TerminalUI):
else:
self._buffer("error", message)
def on_tool_result(self, call_id: str, name: str, output: str) -> None:
def on_tool_result(
self,
call_id: str,
name: str,
output: str,
*,
is_error: bool = False,
) -> None:
if self.is_foreground:
super().on_tool_result(call_id, name, output)
super().on_tool_result(call_id, name, output, is_error=is_error)
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None:
if self.is_foreground:
+108 -65
View File
@@ -190,7 +190,14 @@ class SessionUI(Protocol):
def on_content_token(self, text: str) -> None: ...
def on_stream_end(self) -> None: ...
def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]: ...
def on_tool_result(self, call_id: str, name: str, output: str) -> None: ...
def on_tool_result(
self,
call_id: str,
name: str,
output: str,
*,
is_error: bool = False,
) -> None: ...
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None: ...
def on_status(self, usage: dict[str, Any], context_window: int, effort: str) -> None: ...
def on_plan_review(self, content: str) -> str: ...
@@ -344,6 +351,8 @@ class ChatSession:
self._pending_nudge: list[tuple[str, str]] = [] # (type, text)
# Repeat detection: track recent tool call signatures
self._recent_tool_sigs: set[str] = set()
# Tool error tracking: call_id → is_error for message persistence
self._tool_error_flags: dict[str, bool] = {}
# Cooperative cancellation: set from outside to stop generation
self._cancel_event = threading.Event()
self._cancel_ref: _CancelRef = _CancelRef(self) # provider appends SDK stream here
@@ -728,6 +737,19 @@ class ChatSession:
"\n".join([header, *lines]) if lines else "MCP refresh complete: no servers to refresh."
)
def _report_tool_result(
self,
call_id: str,
name: str,
output: str,
*,
is_error: bool = False,
) -> None:
"""Notify the UI and record error flag for message persistence."""
if is_error:
self._tool_error_flags[call_id] = True
self.ui.on_tool_result(call_id, name, output, is_error=is_error)
def _truncate_output(self, output: str) -> str:
"""Truncate tool output to self.tool_truncation chars, keeping head + tail."""
limit = self.tool_truncation
@@ -1474,6 +1496,8 @@ class ChatSession:
"tool_call_id": tc_id,
"content": output,
}
if self._tool_error_flags.pop(tc_id, False):
tool_msg["is_error"] = True
self.messages.append(tool_msg)
# Token estimation — image content uses a fixed heuristic
@@ -2411,8 +2435,11 @@ class ChatSession:
) -> tuple[str, str | list[dict[str, Any]]]:
self._check_cancelled()
if item.get("error"):
self.ui.on_tool_result(
item["call_id"], item.get("func_name", "unknown"), item["error"]
self._report_tool_result(
item["call_id"],
item.get("func_name", "unknown"),
item["error"],
is_error=True,
)
return item["call_id"], item["error"]
if item.get("denied"):
@@ -2426,7 +2453,7 @@ class ChatSession:
func = item.get("func_name", "unknown")
msg = f"Error executing {func}: {e}"
log.warning("tool_exec.failed", tool=func, error=str(e), exc_info=True)
self.ui.on_tool_result(item["call_id"], func, msg)
self._report_tool_result(item["call_id"], func, msg, is_error=True)
return item["call_id"], msg
if len(items) == 1:
@@ -3504,12 +3531,12 @@ class ChatSession:
skill_data = get_skill_by_name(name)
if not skill_data or not skill_data.get("enabled", True):
msg = f"Error: skill '{name}' not found"
self.ui.on_tool_result(call_id, "skill", msg)
self._report_tool_result(call_id, "skill", msg, is_error=True)
return call_id, msg
if self._skill_name == name:
msg = f"Skill '{name}' is already active"
self.ui.on_tool_result(call_id, "skill", msg)
self._report_tool_result(call_id, "skill", msg)
return call_id, msg
self.set_skill(name)
@@ -3522,7 +3549,7 @@ class ChatSession:
if scan:
parts.append(f"Security tier: {scan}")
msg = "\n".join(parts)
self.ui.on_tool_result(call_id, "skill", msg)
self._report_tool_result(call_id, "skill", msg)
return call_id, msg
# action == "search"
@@ -3576,7 +3603,7 @@ class ChatSession:
if not rows:
msg = "No skills found" + (f" matching '{query}'" if query else "")
self.ui.on_tool_result(call_id, "skill", msg)
self._report_tool_result(call_id, "skill", msg)
return call_id, msg
lines = [f"Found {len(rows)} skill(s):", ""]
@@ -3598,7 +3625,7 @@ class ChatSession:
lines.append(line)
msg = "\n".join(lines)
self.ui.on_tool_result(call_id, "skill", msg)
self._report_tool_result(call_id, "skill", msg)
return call_id, msg
# -- MCP tool prepare/execute ----------------------------------------------
@@ -3639,17 +3666,20 @@ class ChatSession:
args: dict[str, Any] = item["mcp_args"]
assert self._mcp_client is not None
mcp_error = False
try:
output = self._mcp_client.call_tool_sync(func_name, args, timeout=self.tool_timeout)
except TimeoutError:
output = f"MCP tool timed out after {self.tool_timeout}s"
mcp_error = True
self.ui.on_error(output)
except Exception as e:
output = f"MCP tool error: {e}"
mcp_error = True
self.ui.on_error(output)
output = self._truncate_output(output)
self.ui.on_tool_result(call_id, func_name, output)
self._report_tool_result(call_id, func_name, output, is_error=mcp_error)
return call_id, output
@staticmethod
@@ -3712,18 +3742,21 @@ class ChatSession:
uri: str = item["resource_uri"]
assert self._mcp_client is not None
mcp_error = False
try:
output = self._mcp_client.read_resource_sync(uri, timeout=self.tool_timeout)
except TimeoutError:
output = f"MCP resource read timed out after {self.tool_timeout}s"
mcp_error = True
self.ui.on_error(output)
except Exception:
log.warning("MCP resource read failed for %s", uri, exc_info=True)
output = "MCP resource error: failed to read resource"
mcp_error = True
self.ui.on_error(output)
output = self._truncate_output(output)
self.ui.on_tool_result(call_id, "read_resource", output)
self._report_tool_result(call_id, "read_resource", output, is_error=mcp_error)
return call_id, output
def _prepare_use_prompt(self, call_id: str, args: dict[str, Any]) -> dict[str, Any]:
@@ -3791,6 +3824,7 @@ class ChatSession:
arguments: dict[str, str] = item["prompt_arguments"]
assert self._mcp_client is not None
mcp_error = False
try:
messages = self._mcp_client.get_prompt_sync(
name, arguments or None, timeout=self.tool_timeout
@@ -3798,14 +3832,16 @@ class ChatSession:
output = "\n\n".join(f"[{m['role']}]: {m['content']}" for m in messages)
except TimeoutError:
output = f"MCP prompt timed out after {self.tool_timeout}s"
mcp_error = True
self.ui.on_error(output)
except Exception:
log.warning("MCP prompt invocation failed for %s", name, exc_info=True)
output = "MCP prompt error: failed to invoke prompt"
mcp_error = True
self.ui.on_error(output)
output = self._truncate_output(output)
self.ui.on_tool_result(call_id, "use_prompt", output)
self._report_tool_result(call_id, "use_prompt", output, is_error=mcp_error)
return call_id, output
# -- Execute methods (do the work, report output via UI) -------------------
@@ -3901,20 +3937,21 @@ class ChatSession:
output = output.strip()
output = self._truncate_output(output)
bash_error = proc.returncode not in (0, 1)
if proc.returncode != 0:
output += f"\n[exit code: {proc.returncode}]"
self.ui.on_tool_result(call_id, "bash", output)
self._report_tool_result(call_id, "bash", output, is_error=bash_error)
return call_id, output if output else "(no output)"
except subprocess.TimeoutExpired:
msg = f"Command timed out after {self.tool_timeout}s"
self.ui.on_tool_result(call_id, "bash", msg)
self._report_tool_result(call_id, "bash", msg, is_error=True)
return call_id, msg
except Exception as e:
msg = f"Error executing command: {e}"
self.ui.on_tool_result(call_id, "bash", msg)
self._report_tool_result(call_id, "bash", msg, is_error=True)
return call_id, msg
def _exec_read_file(self, item: dict[str, Any]) -> tuple[str, str | list[dict[str, Any]]]:
@@ -3935,12 +3972,12 @@ class ChatSession:
except FileNotFoundError:
self._read_files.discard(resolved)
msg = f"Error: {path} not found"
self.ui.on_tool_result(call_id, "read_file", msg)
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)
msg = f"Error reading {path}: {e}"
self.ui.on_tool_result(call_id, "read_file", msg)
self._report_tool_result(call_id, "read_file", msg, is_error=True)
return call_id, msg
self._read_files.add(resolved)
@@ -3963,7 +4000,7 @@ class ChatSession:
if offset is not None or limit is not None:
end = start + len(lines) - 1
desc += f" (lines {start}-{end} of {total_lines})"
self.ui.on_tool_result(call_id, "read_file", desc)
self._report_tool_result(call_id, "read_file", desc)
return call_id, output if output else "(empty file)"
@@ -3978,11 +4015,11 @@ class ChatSession:
except OSError as e:
self._read_files.discard(resolved)
msg = f"Error: {path}: {e}"
self.ui.on_tool_result(call_id, "read_file", msg)
self._report_tool_result(call_id, "read_file", msg, is_error=True)
return call_id, msg
self._read_files.add(resolved)
desc = f"image (no vision, {size:,} bytes)"
self.ui.on_tool_result(call_id, "read_file", desc)
self._report_tool_result(call_id, "read_file", desc)
return call_id, (
f"Binary image file: {path} ({size:,} bytes). "
"Current model does not support vision."
@@ -3994,12 +4031,12 @@ class ChatSession:
except FileNotFoundError:
self._read_files.discard(resolved)
msg = f"Error: {path} not found"
self.ui.on_tool_result(call_id, "read_file", msg)
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)
msg = f"Error reading {path}: {e}"
self.ui.on_tool_result(call_id, "read_file", msg)
self._report_tool_result(call_id, "read_file", msg, is_error=True)
return call_id, msg
if len(raw) > _IMAGE_SIZE_CAP:
@@ -4010,7 +4047,7 @@ class ChatSession:
f"Error: image {path} is {size_mb:.1f} MB, "
f"exceeds {cap_mb:.0f} MB limit for vision."
)
self.ui.on_tool_result(call_id, "read_file", msg)
self._report_tool_result(call_id, "read_file", msg, is_error=True)
return call_id, msg
self._read_files.add(resolved)
@@ -4027,7 +4064,7 @@ class ChatSession:
},
]
self.ui.on_tool_result(call_id, "read_file", f"image ({len(raw):,} bytes)")
self._report_tool_result(call_id, "read_file", f"image ({len(raw):,} bytes)")
return call_id, content_parts
def _exec_search(self, item: dict[str, Any]) -> tuple[str, str]:
@@ -4070,17 +4107,17 @@ class ChatSession:
desc = f"{match_count} matches" if match_count else "no matches"
if original_len > 500:
desc += f" ({original_len} chars)"
self.ui.on_tool_result(call_id, "search", desc)
self._report_tool_result(call_id, "search", desc)
return call_id, output
except subprocess.TimeoutExpired:
msg = f"Search timed out after {self.tool_timeout}s"
self.ui.on_tool_result(call_id, "search", msg)
self._report_tool_result(call_id, "search", msg, is_error=True)
return call_id, msg
except Exception as e:
msg = f"Error: search failed: {e}"
self.ui.on_tool_result(call_id, "search", msg)
self._report_tool_result(call_id, "search", msg, is_error=True)
return call_id, msg
def _run_agent(
@@ -4561,24 +4598,25 @@ class ChatSession:
)
if not memory_id:
msg = f"Error: failed to save memory '{item['name']}'"
self.ui.on_tool_result(call_id, "memory", msg)
self._report_tool_result(call_id, "memory", msg, is_error=True)
return call_id, msg
self._init_system_messages()
if old is not None:
msg = f"Updated memory '{item['name']}' (type={item['mem_type']}, scope={item['scope']})"
else:
msg = f"Saved memory '{item['name']}' (type={item['mem_type']}, scope={item['scope']})"
self.ui.on_tool_result(call_id, "memory", msg)
self._report_tool_result(call_id, "memory", msg)
return call_id, msg
if action == "delete":
deleted = delete_structured_memory(item["name"], item["scope"], item["scope_id"])
if not deleted:
msg = f"Error: memory '{item['name']}' not found (scope={item['scope']})"
self._report_tool_result(call_id, "memory", msg, is_error=True)
else:
self._init_system_messages()
msg = f"Deleted memory '{item['name']}'"
self.ui.on_tool_result(call_id, "memory", msg)
self._report_tool_result(call_id, "memory", msg)
return call_id, msg
if action == "search":
@@ -4604,7 +4642,7 @@ class ChatSession:
if item["query"]
else "No memories stored."
)
self.ui.on_tool_result(call_id, "memory", msg)
self._report_tool_result(call_id, "memory", msg)
return call_id, msg
if action == "list":
@@ -4625,16 +4663,16 @@ class ChatSession:
msg = f"Memories ({len(rows)}):\n" + "\n".join(lines)
else:
msg = "No memories stored."
self.ui.on_tool_result(call_id, "memory", msg)
self._report_tool_result(call_id, "memory", msg)
return call_id, msg
except Exception as e:
msg = f"Error: {e}"
self.ui.on_tool_result(call_id, "memory", msg)
self._report_tool_result(call_id, "memory", msg, is_error=True)
return call_id, msg
msg = "Error: unexpected action"
self.ui.on_tool_result(call_id, "memory", msg)
self._report_tool_result(call_id, "memory", msg, is_error=True)
return call_id, msg
def _exec_recall(self, item: dict[str, Any]) -> tuple[str, str]:
@@ -4655,7 +4693,7 @@ class ChatSession:
else:
output = f"No conversation history found for '{query}'."
self.ui.on_tool_result(call_id, "recall", output)
self._report_tool_result(call_id, "recall", output)
return call_id, output
# -- Notify tool -----------------------------------------------------------
@@ -4754,7 +4792,7 @@ class ChatSession:
if self._notify_count >= 5:
msg = "Error: notification rate limit exceeded (max 5 per turn)"
self.ui.on_tool_result(call_id, "notify", msg)
self._report_tool_result(call_id, "notify", msg, is_error=True)
return call_id, msg
target: dict[str, str] = {}
@@ -4792,7 +4830,7 @@ class ChatSession:
continue
log.warning("notify.no_services_exhausted")
msg = "Error: no channel gateway services available"
self.ui.on_tool_result(call_id, "notify", msg)
self._report_tool_result(call_id, "notify", msg, is_error=True)
return call_id, msg
# Try first healthy gateway, fall back to next
@@ -4817,7 +4855,7 @@ class ChatSession:
):
self._notify_count += 1
msg = "Notification sent successfully"
self.ui.on_tool_result(call_id, "notify", msg)
self._report_tool_result(call_id, "notify", msg)
return call_id, msg
last_error = "no successful deliveries"
continue
@@ -4846,7 +4884,7 @@ class ChatSession:
)
msg = "Error: notification delivery failed"
self.ui.on_tool_result(call_id, "notify", msg)
self._report_tool_result(call_id, "notify", msg, is_error=True)
return call_id, msg
# -- Watch tool ----------------------------------------------------------
@@ -5031,12 +5069,12 @@ class ChatSession:
if action == "list":
if not storage:
msg = "No watches (storage unavailable)"
self.ui.on_tool_result(call_id, "watch", msg)
self._report_tool_result(call_id, "watch", msg)
return call_id, msg
watches = storage.list_watches_for_ws(self._ws_id)
if not watches:
msg = "No active watches."
self.ui.on_tool_result(call_id, "watch", msg)
self._report_tool_result(call_id, "watch", msg)
return call_id, msg
from turnstone.core.watch import format_interval
@@ -5051,14 +5089,14 @@ class ChatSession:
f"cmd: {w['command'][:60]}"
)
msg = "Active watches:\n" + "\n".join(lines)
self.ui.on_tool_result(call_id, "watch", msg)
self._report_tool_result(call_id, "watch", msg)
return call_id, msg
if action == "cancel":
name = item.get("watch_name", "")
if not storage:
msg = "Error: storage unavailable"
self.ui.on_tool_result(call_id, "watch", msg)
self._report_tool_result(call_id, "watch", msg, is_error=True)
return call_id, msg
watches = storage.list_watches_for_ws(self._ws_id)
target = None
@@ -5068,17 +5106,17 @@ class ChatSession:
break
if target is None:
msg = f'Watch "{name}" not found.'
self.ui.on_tool_result(call_id, "watch", msg)
self._report_tool_result(call_id, "watch", msg, is_error=True)
return call_id, msg
storage.update_watch(target["watch_id"], active=False, next_poll="")
msg = f'Watch "{target["name"]}" cancelled.'
self.ui.on_tool_result(call_id, "watch", msg)
self._report_tool_result(call_id, "watch", msg)
return call_id, msg
# action == "create"
if not storage:
msg = "Error: storage unavailable"
self.ui.on_tool_result(call_id, "watch", msg)
self._report_tool_result(call_id, "watch", msg, is_error=True)
return call_id, msg
watch_id = uuid.uuid4().hex
@@ -5107,7 +5145,7 @@ class ChatSession:
f" Command: {item['command']}\n"
f" Condition: {stop_desc}"
)
self.ui.on_tool_result(call_id, "watch", msg)
self._report_tool_result(call_id, "watch", msg)
return call_id, msg
_MAX_WATCH_CHAIN = 5 # max consecutive watch dispatches per worker thread
@@ -5143,11 +5181,11 @@ class ChatSession:
f.write(content)
self._read_files.add(resolved)
msg = f"Wrote {len(content)} chars to {path}"
self.ui.on_tool_result(call_id, "write_file", msg)
self._report_tool_result(call_id, "write_file", msg)
return call_id, msg
except Exception as e:
msg = f"Error writing {path}: {e}"
self.ui.on_tool_result(call_id, "write_file", msg)
self._report_tool_result(call_id, "write_file", msg, is_error=True)
return call_id, msg
def _exec_edit_file(self, item: dict[str, Any]) -> tuple[str, str]:
@@ -5170,7 +5208,7 @@ class ChatSession:
occurrences = find_occurrences(content, old_string)
if len(occurrences) == 0:
msg = f"Error: old_string no longer found in {path} (file changed)"
self.ui.on_tool_result(call_id, "edit_file", msg)
self._report_tool_result(call_id, "edit_file", msg, is_error=True)
return call_id, msg
if len(occurrences) > 1 and near_line is None:
line_list = ", ".join(str(ln) for ln in occurrences)
@@ -5178,7 +5216,7 @@ class ChatSession:
f"Error: old_string found {len(occurrences)} times "
f"at lines {line_list} (file changed)"
)
self.ui.on_tool_result(call_id, "edit_file", msg)
self._report_tool_result(call_id, "edit_file", msg, is_error=True)
return call_id, msg
if near_line is not None and len(occurrences) > 1:
# Replace only the occurrence nearest to near_line
@@ -5189,11 +5227,11 @@ class ChatSession:
with open(path, "w") as f:
f.write(content)
msg = f"Edited {path}: replaced 1 occurrence"
self.ui.on_tool_result(call_id, "edit_file", msg)
self._report_tool_result(call_id, "edit_file", msg)
return call_id, msg
except Exception as e:
msg = f"Error writing {path}: {e}"
self.ui.on_tool_result(call_id, "edit_file", msg)
self._report_tool_result(call_id, "edit_file", msg, is_error=True)
return call_id, msg
def _exec_math(self, item: dict[str, Any]) -> tuple[str, str]:
@@ -5203,7 +5241,7 @@ class ChatSession:
output = self._truncate_output(output)
result_msg = f"Error:\n{output}" if is_error else output if output else "(no output)"
self.ui.on_tool_result(call_id, "math", result_msg)
self._report_tool_result(call_id, "math", result_msg, is_error=is_error)
return call_id, result_msg
def _exec_man(self, item: dict[str, Any]) -> tuple[str, str]:
@@ -5247,20 +5285,20 @@ class ChatSession:
text = result.stdout
else:
msg = f"No man or info page found for '{page}'"
self.ui.on_tool_result(call_id, "man", msg)
self._report_tool_result(call_id, "man", msg)
return call_id, msg
except FileNotFoundError:
msg = "Error: man command not available"
self.ui.on_tool_result(call_id, "man", msg)
self._report_tool_result(call_id, "man", msg, is_error=True)
return call_id, msg
except subprocess.TimeoutExpired:
msg = "Error: man page lookup timed out"
self.ui.on_tool_result(call_id, "man", msg)
self._report_tool_result(call_id, "man", msg, is_error=True)
return call_id, msg
text = self._truncate_output(text)
self.ui.on_tool_result(call_id, "man", f"{len(text)} chars")
self._report_tool_result(call_id, "man", f"{len(text)} chars")
return call_id, text
@@ -5289,15 +5327,15 @@ class ChatSession:
except httpx.HTTPStatusError as e:
msg = f"Error: fetch failed: HTTP {e.response.status_code}"
self.ui.on_tool_result(call_id, "web_fetch", msg)
self._report_tool_result(call_id, "web_fetch", msg, is_error=True)
return call_id, msg
except (httpx.RequestError, ValueError) as e:
msg = f"Error: fetch failed: {e}"
self.ui.on_tool_result(call_id, "web_fetch", msg)
self._report_tool_result(call_id, "web_fetch", msg, is_error=True)
return call_id, msg
except Exception as e:
msg = f"Error fetching URL: {e}"
self.ui.on_tool_result(call_id, "web_fetch", msg)
self._report_tool_result(call_id, "web_fetch", msg, is_error=True)
return call_id, msg
if not text.strip():
@@ -5348,7 +5386,12 @@ class ChatSession:
except Exception as e:
answer = f"Extraction failed (page was fetched but summarization errored): {e}"
self.ui.on_tool_result(call_id, "web_fetch", answer)
self._report_tool_result(
call_id,
"web_fetch",
answer,
is_error=answer.startswith("Extraction failed"),
)
return call_id, answer
@@ -5363,17 +5406,17 @@ class ChatSession:
client = self._resolve_search_client()
if not client:
msg = "Error: web search backend not available"
self.ui.on_tool_result(call_id, "web_search", msg)
self._report_tool_result(call_id, "web_search", msg, is_error=True)
return call_id, msg
try:
output = client.search(query, max_results=max_results, topic=topic)
except Exception as e:
msg = f"Error: web search failed: {e}"
self.ui.on_tool_result(call_id, "web_search", msg)
self._report_tool_result(call_id, "web_search", msg, is_error=True)
return call_id, msg
self.ui.on_tool_result(call_id, "web_search", output)
self._report_tool_result(call_id, "web_search", output)
return call_id, output
def handle_command(self, cmd_line: str) -> bool:
+8 -1
View File
@@ -105,7 +105,14 @@ class NullUI:
def approve_tools(self, items: list[dict[str, Any]]) -> tuple[bool, str | None]:
return True, None
def on_tool_result(self, call_id: str, name: str, output: str) -> None:
def on_tool_result(
self,
call_id: str,
name: str,
output: str,
*,
is_error: bool = False,
) -> None:
pass
def on_tool_output_chunk(self, call_id: str, chunk: str) -> None:
+1
View File
@@ -107,6 +107,7 @@ class ToolResultEvent(ServerEvent):
call_id: str = ""
name: str = ""
output: str = ""
is_error: bool = False
@dataclass
+13 -12
View File
@@ -382,22 +382,20 @@ class WebUI:
return approved, feedback
def on_tool_result(self, call_id: str, name: str, output: str) -> None:
def on_tool_result(
self,
call_id: str,
name: str,
output: str,
*,
is_error: bool = False,
) -> None:
_metrics.record_tool_call(name)
with self._ws_lock:
self._ws_tool_calls[name] = self._ws_tool_calls.get(name, 0) + 1
self._ws_current_activity = ""
self._ws_activity_state = ""
self._broadcast_activity()
is_error = isinstance(output, str) and (
output.startswith("Error")
or output.startswith("Command timed out")
or output.startswith("Search timed out")
or output.startswith("Unknown tool:")
or output.startswith("JSON parse error:")
or output.startswith("MCP prompt timed out")
or output.startswith("MCP prompt error")
)
event: dict[str, Any] = {
"type": "tool_result",
"call_id": call_id,
@@ -647,8 +645,11 @@ def _build_history(
if isinstance(content, str):
if content.startswith("Denied by user") or content.startswith("Blocked"):
entry["denied"] = True
elif (
content.startswith("Error")
# Use persisted flag if available, fall back to text
# heuristic for historical data that predates is_error.
if (
msg.get("is_error")
or content.startswith("Error")
or content.startswith("Command timed out")
or content.startswith("Search timed out")
or content.startswith("Unknown tool:")
+4 -14
View File
@@ -619,11 +619,7 @@ Pane.prototype.replayHistory = function (messages) {
msg.denied ||
/^Denied by user/.test(stripped) ||
/^Blocked/.test(stripped);
var isToolError =
msg.is_error ||
/^Error[:. \n]|^Command timed out|^Search timed out|^Unknown tool:|^JSON parse error:|^MCP prompt /.test(
stripped,
);
var isToolError = !!msg.is_error;
if (stripped && !isDenied) {
var out = document.createElement("div");
out.className =
@@ -924,19 +920,13 @@ Pane.prototype.appendToolOutput = function (callId, name, output, isError) {
var stripped = stripAnsi(output || "").trim();
if (!stripped) return;
// Detect error by flag or content prefix
var hasError =
isError ||
/^Error[:. \n]|^Command timed out|^Search timed out|^Unknown tool:|^JSON parse error:|^MCP prompt /.test(
stripped,
);
// Style tool output as error when indicated by isError flag
var out = document.createElement("div");
out.className = "tool-output" + (hasError ? " tool-output-error" : "");
out.className = "tool-output" + (isError ? " tool-output-error" : "");
out.textContent = stripped;
// Mark the parent approval block as errored
if (hasError) {
if (isError) {
var parentBlock = target.closest(".approval-block");
if (parentBlock && !parentBlock.classList.contains("denied")) {
parentBlock.classList.add("error");