From 2e27bed36291731be3912ee24aa8ba664704b9e0 Mon Sep 17 00:00:00 2001 From: Kamil Mosciszko Date: Fri, 9 Oct 2026 22:23:09 +0200 Subject: [PATCH 01/12] fix(executor): normalize OpenRouter tool-call argument and response variants --- src/agent_tui/openrouter.py | 67 ++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/src/agent_tui/openrouter.py b/src/agent_tui/openrouter.py index 4a0f3c9..5e6b59c 100644 --- a/src/agent_tui/openrouter.py +++ b/src/agent_tui/openrouter.py @@ -98,34 +98,73 @@ async def add(self, payload: dict[str, Any], on_token: TokenSink) -> None: ) self.model = payload.get("model") or self.model usage = payload.get("usage") or {} + if not isinstance(usage, dict): + raise _ResponseShapeError("Invalid token usage in executor response") if isinstance(usage.get("total_tokens"), int): self.tokens = usage["total_tokens"] choices = payload.get("choices") or [] + if not isinstance(choices, list): + raise _ResponseShapeError("Executor choices must be a list") if not choices: return choice = choices[0] + if not isinstance(choice, dict): + raise _ResponseShapeError("Executor choice must be an object") if choice.get("finish_reason"): self.finish_reason = choice["finish_reason"] delta = choice.get("delta") or choice.get("message") or {} + if not isinstance(delta, dict): + raise _ResponseShapeError("Executor message must be an object") content = response_text(delta.get("content")) self.content += content self.size += len(content) if content: await on_token(content) - for position, fragment in enumerate(delta.get("tool_calls") or []): + fragments = delta.get("tool_calls") + if fragments is None and delta.get("function_call") is not None: + # Some OpenAI-compatible endpoints still use the legacy single-call shape. + fragments = [{"index": 0, "id": "legacy_call_0", + "function": delta["function_call"]}] + if fragments is None: + fragments = [] + if isinstance(fragments, dict): + fragments = [fragments] + if not isinstance(fragments, list): + raise _ResponseShapeError("Executor tool_calls must be a list") + for position, fragment in enumerate(fragments): + if not isinstance(fragment, dict): + raise _ResponseShapeError("Executor tool call must be an object") index = fragment.get("index", position) - if not isinstance(index, int) or not 0 <= index < 16: - raise ExecutorError("Invalid tool-call index in stream") + if type(index) is not int or not 0 <= index < 16: + raise _ResponseShapeError("Invalid tool-call index in response") call = self.calls.setdefault(index, {"id": "", "name": "", "arguments": ""}) - if fragment.get("id"): - call["id"] = fragment["id"] + call_id = fragment.get("id") + if call_id is not None: + if not isinstance(call_id, str): + raise _ResponseShapeError("Invalid tool-call ID in response") + call["id"] = call_id function = fragment.get("function") or {} - for key in ("name", "arguments"): - value = function.get(key) or "" - if not isinstance(value, str): - raise ExecutorError(f"Invalid tool {key} in stream") - call[key] += value - self.size += len(value) + if not isinstance(function, dict): + raise _ResponseShapeError("Executor tool function must be an object") + name = function.get("name") + if name is not None: + if not isinstance(name, str): + raise _ResponseShapeError("Invalid tool name in response") + call["name"] += name + self.size += len(name) + arguments = function.get("arguments") + if arguments is not None: + if isinstance(arguments, str): + # Streaming fragments are partial JSON, concatenated in order. + call["arguments"] += arguments + self.size += len(arguments) + elif isinstance(arguments, dict) and not call["arguments"]: + # Non-streaming providers may return a JSON object, not a JSON string. + encoded = json.dumps(arguments, ensure_ascii=False, allow_nan=False) + call["arguments"] = encoded + self.size += len(encoded) + else: + raise _ResponseShapeError("Tool arguments must be JSON text or an object") if self.size > 1_000_000: raise ExecutorError("Provider response exceeds the 1 MB limit") @@ -146,9 +185,11 @@ def finish(self) -> Completion: calls = [] for index in sorted(self.calls): call = self.calls[index] - if not call["id"] or not call["name"]: + if not call["name"]: raise ExecutorError("Incomplete tool call from executor") - calls.append(ToolCall(**call)) + # IDs are required for transcript pairing; synthesize one only when the + # provider has returned a complete call without its optional-looking ID. + calls.append(ToolCall(call["id"] or f"call_{index}", call["name"], call["arguments"])) if not self.content.strip() and not calls: raise ExecutorError("Executor returned an empty response") if len({call.id for call in calls}) != len(calls): From e9eeec40e37019d9e32ed46b1ba98aceebce925a Mon Sep 17 00:00:00 2001 From: Kamil Mosciszko Date: Fri, 9 Oct 2026 22:23:40 +0200 Subject: [PATCH 02/12] fix(tools): validate structured arguments and enforce stable tool result contracts --- src/agent_tui/tools.py | 62 ++++++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/src/agent_tui/tools.py b/src/agent_tui/tools.py index 95d0021..e12964f 100644 --- a/src/agent_tui/tools.py +++ b/src/agent_tui/tools.py @@ -194,7 +194,26 @@ def api_schema(self) -> dict[str, Any]: "expected_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, }, ["action", "path"], - ), + ) | { + "oneOf": [ + { + "properties": {"action": {"const": "edit"}}, + "required": ["old_text", "new_text"], + "not": {"required": ["content"]}, + }, + { + "properties": {"action": {"const": "create"}}, + "required": ["content"], + "not": { + "anyOf": [ + {"required": ["old_text"]}, + {"required": ["new_text"]}, + {"required": ["expected_sha256"]}, + ] + }, + }, + ] + }, }, }, ["changes"], @@ -411,15 +430,28 @@ def schemas(self, selected: str | None = None) -> list[dict[str, Any]]: if selected is None or selected == spec.name ] - def validate(self, name: str, raw_arguments: str) -> dict[str, Any]: + def validate(self, name: str, raw_arguments: str | dict[str, Any]) -> dict[str, Any]: if name not in self.specs: raise ToolError(f"Tool is not available: {name}", code="unavailable_tool") try: - arguments = json.loads(raw_arguments) + if isinstance(raw_arguments, str): + arguments = json.loads(raw_arguments, parse_constant=_reject_nonfinite) + # Some OpenAI-compatible providers double-encode the argument object. + # Unwrap at most once and still validate against the exact tool schema. + if isinstance(arguments, str): + arguments = json.loads(arguments, parse_constant=_reject_nonfinite) + elif isinstance(raw_arguments, dict): + # Enforce JSON-only values at the boundary, including finite numbers. + arguments = json.loads( + json.dumps(raw_arguments, ensure_ascii=False, allow_nan=False), + parse_constant=_reject_nonfinite, + ) + else: + raise ValueError("Tool arguments must be a JSON object or encoded object") if not isinstance(arguments, dict): raise ValueError("Tool arguments must be a JSON object") Draft202012Validator(self.specs[name].parameters).validate(arguments) - except (ValueError, ValidationError) as exc: + except (TypeError, ValueError, OverflowError, RecursionError, ValidationError) as exc: message = exc.message if isinstance(exc, ValidationError) else str(exc) raise ToolError( f"Invalid arguments for {name}: {message[:400]}", @@ -804,6 +836,12 @@ async def execute(self, name: str, arguments: dict[str, Any]) -> ToolResult: self.validate(name, json.dumps(arguments)) if name in self.handlers: result = await self.handlers[name](arguments) + if not isinstance(result, ToolResult): + raise ToolError( + f"Tool {name} returned {type(result).__name__}; expected ToolResult", + code="invalid_tool_response", + retry_hint="Fix the tool handler to return ToolResult(ok, content).", + ) return self.sanitize(result, name) if name == "run_command": output = await self._run_command(**arguments) @@ -828,11 +866,25 @@ async def execute(self, name: str, arguments: dict[str, Any]) -> ToolResult: "Inspect command output and current state before retrying.", ) result = ToolResult(ok, text, error) - except (ToolError, OSError, UnicodeError, ValueError) as exc: + except (ToolError, OSError, UnicodeError, ValueError, TypeError) as exc: result = ToolResult.from_error(exc) return self.sanitize(result, name) def sanitize(self, result: ToolResult, name: str | None = None) -> ToolResult: + if ( + not isinstance(result, ToolResult) + or type(result.ok) is not bool + or not isinstance(result.content, str) + or (result.error is not None and not isinstance(result.error, ToolFailure)) + or type(result.truncated) is not bool + ): + result = ToolResult.from_error( + ToolError( + "Tool handler returned an invalid ToolResult; expected text content " + "and a structured ToolFailure for errors", + code="invalid_tool_response", + ) + ) text = self.settings.redact(result.content) limit = self.settings.max_output_chars truncated = result.truncated or len(text) > limit From fde1e22bda6a1f29344baedc5ffc58056539846b Mon Sep 17 00:00:00 2001 From: Kamil Mosciszko Date: Fri, 9 Oct 2026 22:24:03 +0200 Subject: [PATCH 03/12] fix(protocol): preserve JSON tool arguments across models, hooks and planner --- src/agent_tui/models.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/agent_tui/models.py b/src/agent_tui/models.py index c74b892..217c29b 100644 --- a/src/agent_tui/models.py +++ b/src/agent_tui/models.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from typing import Any @@ -9,13 +10,20 @@ class ToolCall: id: str name: str - arguments: str + arguments: str | dict[str, Any] def as_dict(self) -> dict[str, Any]: + # OpenAI-compatible chat transcripts require a JSON *string* here, even + # when a custom executor provides already-decoded argument objects. + arguments = ( + self.arguments + if isinstance(self.arguments, str) + else json.dumps(self.arguments, ensure_ascii=False, allow_nan=False) + ) return { "id": self.id, "type": "function", - "function": {"name": self.name, "arguments": self.arguments}, + "function": {"name": self.name, "arguments": arguments}, } From 090f34a0a6aef39299260ac52eae56a7133fb280 Mon Sep 17 00:00:00 2001 From: Kamil Mosciszko Date: Fri, 9 Oct 2026 22:24:06 +0200 Subject: [PATCH 04/12] fix(protocol): preserve JSON tool arguments across models, hooks and planner --- src/agent_tui/policy.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/agent_tui/policy.py b/src/agent_tui/policy.py index b67ead0..0aafc23 100644 --- a/src/agent_tui/policy.py +++ b/src/agent_tui/policy.py @@ -43,9 +43,11 @@ async def execute( hook = await agent.middleware.dispatch( "before_tool", {"tool": call.name, "arguments": arguments, "step": step} ) - arguments = agent.registry.validate(call.name, json.dumps(hook["arguments"])) + arguments = agent.registry.validate(call.name, hook["arguments"]) if exchange is not None: - exchange[0]["tool_calls"][0]["function"]["arguments"] = json.dumps(arguments) + exchange[0]["tool_calls"][0]["function"]["arguments"] = json.dumps( + arguments, ensure_ascii=False, allow_nan=False + ) fingerprint = call.name + json.dumps(arguments, sort_keys=True) run_tools.repeats[fingerprint] += 1 if run_tools.repeats[fingerprint] > 2: From 984213115141e62032864563470db7d632431bb0 Mon Sep 17 00:00:00 2001 From: Kamil Mosciszko Date: Fri, 9 Oct 2026 22:24:09 +0200 Subject: [PATCH 05/12] fix(protocol): preserve JSON tool arguments across models, hooks and planner --- src/agent_tui/planner.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/agent_tui/planner.py b/src/agent_tui/planner.py index 3eff733..19e9d0b 100644 --- a/src/agent_tui/planner.py +++ b/src/agent_tui/planner.py @@ -85,9 +85,10 @@ def parse_plan(completion: Completion, tools: Sequence[ToolDescriptor], max_dept if len(completion.calls) != 1 or completion.calls[0].name != "submit_plan": raise PlanningError("Planner must return one submit_plan call") try: - raw = json.loads(completion.calls[0].arguments) + arguments = completion.calls[0].arguments + raw = json.loads(arguments) if isinstance(arguments, str) else arguments Draft202012Validator(plan_schema(tools, max_depth)).validate(raw) - except (ValueError, ValidationError) as exc: + except (TypeError, ValueError, ValidationError) as exc: raise PlanningError(f"Invalid planner output: {str(exc)[:200]}") from None paths = tuple(tuple(PredictedAction(**action) for action in path) for path in raw["paths"]) roots = [path[0].tool for path in paths] From 2b459057fac06e3933daaaaf4a30402d326ebf9e Mon Sep 17 00:00:00 2001 From: Kamil Mosciszko Date: Fri, 9 Oct 2026 22:24:49 +0200 Subject: [PATCH 06/12] fix(context): compact tool responses as valid JSON with stable envelopes --- src/agent_tui/context.py | 58 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/agent_tui/context.py b/src/agent_tui/context.py index f35eb0d..1812f1c 100644 --- a/src/agent_tui/context.py +++ b/src/agent_tui/context.py @@ -14,6 +14,60 @@ from .relevance import keywords +def _compact_tool_reply(content: str, limit: int) -> str: + """Bound a previous tool result without ever cutting the JSON response envelope. + + Context pruning must not turn valid tool messages into malformed JSON. Preserve + the output's object/list/string shape when possible, and mark any loss of data. + """ + try: + parsed = json.loads(content) + except ValueError: + parsed = content + if not isinstance(parsed, dict) or not {"ok", "output", "error", "truncated"} <= parsed.keys(): + parsed = {"ok": True, "output": parsed, "error": None, "truncated": False} + reply = { + "ok": parsed["ok"], + "output": parsed["output"], + "error": parsed["error"], + "truncated": True, + } + + def shrink(value: Any, keep: int) -> Any: + if isinstance(value, str): + return value[:keep] + if isinstance(value, list): + return [shrink(item, keep) for item in value[: keep // 128]] + if isinstance(value, dict): + return {key: shrink(item, keep) for key, item in value.items()} + return value + + best = "" + low, high = 0, len(content) + while low <= high: + middle = (low + high) // 2 + candidate = {**reply, "output": shrink(reply["output"], middle)} + if candidate["error"] is not None: + candidate["error"] = shrink(reply["error"], min(middle, 200)) + if isinstance(candidate["output"], dict) and "truncated" in candidate["output"]: + candidate["output"]["truncated"] = True + serialized = json.dumps(candidate, ensure_ascii=False, allow_nan=False) + if len(serialized) <= limit: + best = serialized + low = middle + 1 + else: + high = middle - 1 + if best: + return best + + # If metadata alone exceeds the allowance, retain the protocol envelope and + # an explicit omission flag rather than returning a sliced JSON document. + return json.dumps( + {"ok": reply["ok"], "output": None, "error": None, "truncated": True}, + ensure_ascii=False, + ) + + def _turn_text(turn: list[dict[str, Any]]) -> tuple[str, str]: task = next((str(m.get("content") or "") for m in turn if m.get("role") == "user"), "") outcome = next( @@ -325,8 +379,8 @@ def sized(groups): "increase --context-chars, unload a skill, or start a smaller task" ) message = max(candidates, key=lambda m: len(m["content"])) - message["content"] = message["content"][: len(message["content"]) // 2] + ( - "\n[Tool result excerpt; re-read for full output]" + message["content"] = _compact_tool_reply( + message["content"], max(160, len(message["content"]) // 2) ) recent[-1] = sized([latest])[0] stats.chars += recent[-1][1] - previous_size From 4c84869c47cac68b45a99402b2076ad1afd3fc2d Mon Sep 17 00:00:00 2001 From: Kamil Mosciszko Date: Fri, 9 Oct 2026 22:25:19 +0200 Subject: [PATCH 07/12] fix(codemode): align tool budget and response shape instructions --- src/agent_tui/codemode.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/agent_tui/codemode.py b/src/agent_tui/codemode.py index b946edc..797be6f 100644 --- a/src/agent_tui/codemode.py +++ b/src/agent_tui/codemode.py @@ -117,8 +117,9 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> ToolReply: ... edit_file/write_file: {"path": str, "bytes": int}. run_command: {"exit_code": int, "output": str, "truncated": bool, "timed_out": bool}. Use result["output"]["content"] for file text and result["output"]["output"] for command -text. Check has_more and optional flags with output.get("truncated", False); request smaller -ranges before relying on an excerpt. +text. Check result["truncated"] and has_more before relying on an excerpt. When an +older result was compacted from context its output may be None; re-read the source +instead of assuming missing data means an empty result. Paths are workspace-relative. run_command takes an argv list, not a shell command string. 7. End with a small JSON-compatible expression (dict, list, string, number, bool or None), or print short evidence. Do not end with only assignments, an unawaited coroutine, a set, @@ -138,8 +139,8 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> ToolReply: ... For single edits, edit_file still works; write_file is for new files or requested full replacements. Never reconstruct a full file from a partial excerpt. Verify changed files with context_collect or a test command before finishing. -9. Keep output and loops bounded. A program has at most 32 tool calls, 5 seconds of -computation (excluding tools/approvals), 64 MiB memory and 24,000 source characters. +9. Keep output and loops bounded. A program has at most 1024 tool calls, 5 seconds of +computation (excluding tools/approvals), 64 MiB memory and 96,000 source characters. On failure, read error, phase, tools and retry_hint. Correct the cause in a NEW program. Validation errors run no tools. Runtime errors may follow successful actions, which are not rolled back: inspect current state and never blindly replay earlier mutations. @@ -298,7 +299,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]: "max_feed_duration_secs": EXECUTION_SECONDS, "max_memory": MEMORY_BYTES, "max_recursion_depth": 100, - "max_suspensions": 256, + "max_suspensions": MAX_TOOL_CALLS + 16, }, os_policy={"sleep": "zero"}, ) as session, From 271c18f1402c894e1483a0626811e08fa9885775 Mon Sep 17 00:00:00 2001 From: Kamil Mosciszko Date: Fri, 9 Oct 2026 22:26:29 +0200 Subject: [PATCH 08/12] test: cover argument and response-shape regressions without running suites --- tests/test_openrouter.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/test_openrouter.py b/tests/test_openrouter.py index ebdc22d..1ccb409 100644 --- a/tests/test_openrouter.py +++ b/tests/test_openrouter.py @@ -387,3 +387,39 @@ def handler(request): with pytest.raises(ExecutorError, match="No tool from the incomplete response"): await OpenRouterExecutor(settings, client).complete([], [], None, ignore_token) assert len(requests) == 1 + +async def test_nonstreaming_provider_normalizes_object_tool_arguments(settings): + payload = { + "model": "free/tool-model", + "choices": [{ + "message": { + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": {"name": "read_file", "arguments": {"path": "README.md"}}, + }] + }, + "finish_reason": "tool_calls", + }], + } + response = httpx.Response(200, json=payload) + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: response)) as client: + result = await OpenRouterExecutor(settings, client).complete( + [], ToolRegistry(settings).schemas("read_file"), "read_file", ignore_token + ) + assert result.calls[0].arguments == '{"path": "README.md"}' + assert result.calls[0].id == "call_1" + + +async def test_legacy_function_call_is_normalized_with_a_transcript_id(settings): + response = httpx.Response(200, json={"choices": [{ + "message": {"function_call": {"name": "finish", "arguments": '{"summary":"Done"}'}}, + "finish_reason": "function_call", + }]}) + async with httpx.AsyncClient(transport=httpx.MockTransport(lambda _: response)) as client: + result = await OpenRouterExecutor(settings, client).complete( + [], ToolRegistry(settings).schemas("finish"), "finish", ignore_token + ) + assert result.calls[0].name == "finish" + assert result.calls[0].id == "legacy_call_0" + assert json.loads(result.calls[0].arguments) == {"summary": "Done"} From e9135099472ee9f6f5d762ff53f8920734c1ce07 Mon Sep 17 00:00:00 2001 From: Kamil Mosciszko Date: Fri, 9 Oct 2026 22:26:32 +0200 Subject: [PATCH 09/12] test: cover argument and response-shape regressions without running suites --- tests/test_tools.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/test_tools.py b/tests/test_tools.py index 57106ca..e11a12e 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -392,3 +392,37 @@ async def handler(arguments): tools.replace(replace(old, risk=RiskLevel.HIGH), handler) assert "read_file" not in tools.specs assert not (await tools.execute("read_file", {"path": "x"})).ok + +def test_argument_shapes_are_normalized_and_patchset_actions_validated(settings): + registry = ToolRegistry(settings) + payload = {"path": "README.md"} + assert registry.validate("read_file", payload) == payload + assert registry.validate("read_file", json.dumps(json.dumps(payload))) == payload + with pytest.raises(ToolError, match="Invalid arguments"): + registry.validate("read_file", '{"path":"README.md","start_line":NaN}') + with pytest.raises(ToolError, match="Invalid arguments"): + registry.validate("apply_patchset", {"changes": [{"action": "edit", "path": "x.py"}]}) + with pytest.raises(ToolError, match="Invalid arguments"): + registry.validate("apply_patchset", { + "changes": [{"action": "create", "path": "x.py", "content": "x", + "old_text": "x"}], + }) + assert registry.validate("apply_patchset", { + "changes": [{"action": "create", "path": "x.py", "content": "x"}], + })["changes"][0]["action"] == "create" + + +async def test_invalid_custom_tool_result_yields_structured_error(settings): + registry = ToolRegistry(settings) + + async def wrong_shape(arguments): + return {"ok": True, "content": arguments["value"]} + + registry.register( + ToolSpec("custom", "Invalid handler", object_schema({"value": {"type": "string"}})), + wrong_shape, + ) + response = await registry.execute("custom", {"value": "ok"}) + assert not response.ok + assert response.as_dict()["error"]["code"] == "invalid_tool_response" + assert response.as_dict()["output"] is None From 4f90a41bb24271a88a0e4ac56edadc5bc530fd38 Mon Sep 17 00:00:00 2001 From: Kamil Mosciszko Date: Fri, 9 Oct 2026 22:26:36 +0200 Subject: [PATCH 10/12] test: cover argument and response-shape regressions without running suites --- tests/test_context_memory_skills.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_context_memory_skills.py b/tests/test_context_memory_skills.py index a7a771b..476cbd4 100644 --- a/tests/test_context_memory_skills.py +++ b/tests/test_context_memory_skills.py @@ -289,3 +289,29 @@ def test_short_continuations_preserve_the_topic_used_for_skill_selection(setting assert skills.task_goal == "Investigate parser" skills.begin_task("Optimize parser") assert "debug-tests" in skills.active + +def test_context_compaction_preserves_json_tool_reply_shapes(settings): + manager = ContextManager(replace(settings, context_chars=1100)) + tool_reply = { + "ok": True, + "output": {"path": "large.py", "content": "source\n" * 700, + "total_lines": 700, "has_more": False}, + "error": None, + "truncated": False, + } + exchange = [ + {"role": "assistant", "tool_calls": [{ + "id": "read_1", "type": "function", + "function": {"name": "read_file", "arguments": '{"path":"large.py"}'}, + }]}, + {"role": "tool", "tool_call_id": "read_1", "content": json.dumps(tool_reply)}, + ] + result = manager.build("Rules", "Inspect large.py", [exchange]) + reply = json.loads(result[-1]["content"]) + assert set(reply) == {"ok", "output", "error", "truncated"} + assert reply["ok"] and reply["truncated"] + assert isinstance(reply["output"], dict) + assert set(reply["output"]) == set(tool_reply["output"]) + assert reply["output"]["content"] != tool_reply["output"]["content"] + assert reply["output"]["path"] == "large.py" + assert result[-2]["tool_calls"][0]["id"] == result[-1]["tool_call_id"] From 5e066648e5a579626d9895b61a75659af4734329 Mon Sep 17 00:00:00 2001 From: Kamil Mosciszko Date: Fri, 9 Oct 2026 22:27:34 +0200 Subject: [PATCH 11/12] fix(executor): preserve streamed tool IDs across empty fragments --- src/agent_tui/openrouter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/agent_tui/openrouter.py b/src/agent_tui/openrouter.py index 5e6b59c..c436a5c 100644 --- a/src/agent_tui/openrouter.py +++ b/src/agent_tui/openrouter.py @@ -142,7 +142,8 @@ async def add(self, payload: dict[str, Any], on_token: TokenSink) -> None: if call_id is not None: if not isinstance(call_id, str): raise _ResponseShapeError("Invalid tool-call ID in response") - call["id"] = call_id + if call_id: + call["id"] = call_id function = fragment.get("function") or {} if not isinstance(function, dict): raise _ResponseShapeError("Executor tool function must be an object") From dbeb24195370487f5c1387c8f9dedfa794274a9e Mon Sep 17 00:00:00 2001 From: Kamil Mosciszko Date: Fri, 9 Oct 2026 22:29:04 +0200 Subject: [PATCH 12/12] fix(context): retain error envelope and identifiers when pruning tool replies --- src/agent_tui/context.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/agent_tui/context.py b/src/agent_tui/context.py index 1812f1c..2630cee 100644 --- a/src/agent_tui/context.py +++ b/src/agent_tui/context.py @@ -35,7 +35,8 @@ def _compact_tool_reply(content: str, limit: int) -> str: def shrink(value: Any, keep: int) -> Any: if isinstance(value, str): - return value[:keep] + # Preserve short identifiers (paths, hashes, error codes) unmodified. + return value if len(value) <= 128 else value[:keep] if isinstance(value, list): return [shrink(item, keep) for item in value[: keep // 128]] if isinstance(value, dict): @@ -62,8 +63,13 @@ def shrink(value: Any, keep: int) -> Any: # If metadata alone exceeds the allowance, retain the protocol envelope and # an explicit omission flag rather than returning a sliced JSON document. + error = ( + {"code": "context_truncated", "message": "Earlier tool error omitted", + "retry_hint": None} + if not reply["ok"] else None + ) return json.dumps( - {"ok": reply["ok"], "output": None, "error": None, "truncated": True}, + {"ok": reply["ok"], "output": None, "error": error, "truncated": True}, ensure_ascii=False, )