Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/agent_tui/codemode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
64 changes: 62 additions & 2 deletions src/agent_tui/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,66 @@
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):
# 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):
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.
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": error, "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(
Expand Down Expand Up @@ -325,8 +385,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
Expand Down
12 changes: 10 additions & 2 deletions src/agent_tui/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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},
}


Expand Down
68 changes: 55 additions & 13 deletions src/agent_tui/openrouter.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,34 +98,74 @@ 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")
if call_id:
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")

Expand All @@ -146,9 +186,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):
Expand Down
5 changes: 3 additions & 2 deletions src/agent_tui/planner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
6 changes: 4 additions & 2 deletions src/agent_tui/policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
62 changes: 57 additions & 5 deletions src/agent_tui/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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]}",
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
Loading
Loading