diff --git a/agent_platform/gateway/provider.py b/agent_platform/gateway/provider.py index a69721c..2f5ba49 100644 --- a/agent_platform/gateway/provider.py +++ b/agent_platform/gateway/provider.py @@ -19,6 +19,7 @@ from docker.errors import ImageNotFound, NotFound import docker from agent_platform.config import Settings from agent_platform.gateway.schemas import ToolResult, WorkspaceListing, WorkspaceStatus +from agent_platform.text import truncate_middle def normalize_workspace_path(raw_path: str) -> PurePosixPath: @@ -208,7 +209,7 @@ class DockerExecutionProvider: output = ((stdout or b"") + (stderr or b"")).decode("utf-8", errors="replace") truncated = len(output) > max_output if truncated: - output = output[:max_output] + "\n… output truncated …" + output = truncate_middle(output, max_output, "\n… output truncated; middle omitted …\n") return ToolResult( ok=result.exit_code == 0, output=output, diff --git a/agent_platform/runtime/loop.py b/agent_platform/runtime/loop.py index f5cd524..f273c7e 100644 --- a/agent_platform/runtime/loop.py +++ b/agent_platform/runtime/loop.py @@ -77,6 +77,7 @@ VERIFICATION_TOOLS = {"read_file", "git_status", "git_diff", "exec", "poll_proce EXECUTION_TOOLS = {"exec", "start_process", "poll_process"} REPEAT_GUARDED_EXECUTION_TOOLS = {"exec", "start_process"} MAX_CONSECUTIVE_CHECKPOINT_REJECTIONS = 3 +MAX_TOOL_CALLS_PER_ITERATION = 8 CODE_FILE_SUFFIXES = { ".c", ".cc", @@ -568,6 +569,17 @@ class AgentLoop: ) return candidate + if len(tool_calls) > MAX_TOOL_CALLS_PER_ITERATION: + await recorder.emit( + "tool.batch_limited", + { + "requested": len(tool_calls), + "accepted": MAX_TOOL_CALLS_PER_ITERATION, + "iteration": iteration + 1, + "depth": depth, + }, + ) + tool_calls = tool_calls[:MAX_TOOL_CALLS_PER_ITERATION] consecutive_checkpoint_rejections = 0 assistant_message = { "role": "assistant", diff --git a/agent_platform/runtime/tools.py b/agent_platform/runtime/tools.py index 413b8ef..fcc48f2 100644 --- a/agent_platform/runtime/tools.py +++ b/agent_platform/runtime/tools.py @@ -10,6 +10,7 @@ from agent_platform.auth import UserIdentity from agent_platform.config import Settings from agent_platform.runtime.schemas import PlanItem from agent_platform.store import RuntimeStore +from agent_platform.text import truncate_middle @dataclass(frozen=True, slots=True) @@ -337,6 +338,4 @@ class ToolRegistry: def tool_result_text(result: Any, limit: int) -> str: text = json.dumps(result, ensure_ascii=False, separators=(",", ":"), default=str) - if len(text) <= limit: - return text - return text[:limit] + "…" + return truncate_middle(text, limit) diff --git a/agent_platform/text.py b/agent_platform/text.py new file mode 100644 index 0000000..e681dee --- /dev/null +++ b/agent_platform/text.py @@ -0,0 +1,15 @@ +from __future__ import annotations + + +def truncate_middle(text: str, limit: int, marker: str = "\n… middle omitted …\n") -> str: + if limit <= 0: + return "" + if len(text) <= limit: + return text + if limit <= len(marker): + return text[:limit] + remaining = limit - len(marker) + head = (remaining + 1) // 2 + tail = remaining - head + suffix = text[-tail:] if tail else "" + return text[:head] + marker + suffix diff --git a/tests/test_agent_loop.py b/tests/test_agent_loop.py index cd61cd7..7d48e45 100644 --- a/tests/test_agent_loop.py +++ b/tests/test_agent_loop.py @@ -120,6 +120,59 @@ class ParallelRegistry: return {"ok": True, "path": arguments["path"]} +class CountingRegistry: + def __init__(self) -> None: + self.calls = 0 + + def specs(self, *, read_only=False, allow_delegate=True): + return [TOOL_METADATA["read_file"].openai_spec()] + + async def execute(self, name, arguments, context): + self.calls += 1 + return {"ok": True, "path": arguments["path"]} + + +async def test_tool_call_batch_is_bounded(settings) -> None: + crowded = { + "choices": [ + { + "message": { + "role": "assistant", + "content": None, + "tool_calls": [tool_call(str(index), "read_file", {"path": f"{index}.txt"}) for index in range(20)], + }, + "finish_reason": "tool_calls", + } + ] + } + final = {"choices": [{"message": {"role": "assistant", "content": "done"}, "finish_reason": "stop"}]} + store = RuntimeStore(settings.database_url) + await store.initialize() + registry = CountingRegistry() + loop = AgentLoop(ScriptedProvider([crowded, final]), registry, store, max_tool_output_chars=10_000) + events = [] + + async def callback(event_type, payload): + events.append((event_type, payload)) + + try: + answer = await loop.run( + spec=get_model_spec("work-medium"), + messages=[{"role": "user", "content": "检查当前状态"}], + identity=UserIdentity("u1", "u1@example.test", "U1", "user"), + raw_user_jwt="jwt", + chat_id="bounded-batch", + callback=callback, + ) + finally: + await store.close() + + assert answer == "done" + assert registry.calls == 8 + limited = next(payload for event_type, payload in events if event_type == "tool.batch_limited") + assert limited == {"requested": 20, "accepted": 8, "iteration": 1, "depth": 0} + + async def test_read_only_tool_calls_run_in_parallel(settings) -> None: first = { "choices": [ diff --git a/tests/test_text.py b/tests/test_text.py new file mode 100644 index 0000000..5413e72 --- /dev/null +++ b/tests/test_text.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from agent_platform.text import truncate_middle + + +def test_truncate_middle_preserves_error_at_end() -> None: + text = "stdout:" + ("x" * 1000) + "\nNameError: start is not defined" + + truncated = truncate_middle(text, 120) + + assert len(truncated) == 120 + assert truncated.startswith("stdout:") + assert "middle omitted" in truncated + assert truncated.endswith("NameError: start is not defined") + + +def test_truncate_middle_leaves_short_text_unchanged() -> None: + assert truncate_middle("short", 20) == "short" + + +def test_truncate_middle_honors_tiny_limits() -> None: + marker = "" + + assert truncate_middle("abcdef", 0, marker) == "" + assert truncate_middle("abcdef", 3, marker) == "abc" + assert truncate_middle("abcdefghijklmnopqrstuvwxyz", len(marker) + 1, marker) == "a"