fix: preserve tool failures under noisy output
This commit is contained in:
@@ -19,6 +19,7 @@ from docker.errors import ImageNotFound, NotFound
|
|||||||
import docker
|
import docker
|
||||||
from agent_platform.config import Settings
|
from agent_platform.config import Settings
|
||||||
from agent_platform.gateway.schemas import ToolResult, WorkspaceListing, WorkspaceStatus
|
from agent_platform.gateway.schemas import ToolResult, WorkspaceListing, WorkspaceStatus
|
||||||
|
from agent_platform.text import truncate_middle
|
||||||
|
|
||||||
|
|
||||||
def normalize_workspace_path(raw_path: str) -> PurePosixPath:
|
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")
|
output = ((stdout or b"") + (stderr or b"")).decode("utf-8", errors="replace")
|
||||||
truncated = len(output) > max_output
|
truncated = len(output) > max_output
|
||||||
if truncated:
|
if truncated:
|
||||||
output = output[:max_output] + "\n… output truncated …"
|
output = truncate_middle(output, max_output, "\n… output truncated; middle omitted …\n")
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
ok=result.exit_code == 0,
|
ok=result.exit_code == 0,
|
||||||
output=output,
|
output=output,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ VERIFICATION_TOOLS = {"read_file", "git_status", "git_diff", "exec", "poll_proce
|
|||||||
EXECUTION_TOOLS = {"exec", "start_process", "poll_process"}
|
EXECUTION_TOOLS = {"exec", "start_process", "poll_process"}
|
||||||
REPEAT_GUARDED_EXECUTION_TOOLS = {"exec", "start_process"}
|
REPEAT_GUARDED_EXECUTION_TOOLS = {"exec", "start_process"}
|
||||||
MAX_CONSECUTIVE_CHECKPOINT_REJECTIONS = 3
|
MAX_CONSECUTIVE_CHECKPOINT_REJECTIONS = 3
|
||||||
|
MAX_TOOL_CALLS_PER_ITERATION = 8
|
||||||
CODE_FILE_SUFFIXES = {
|
CODE_FILE_SUFFIXES = {
|
||||||
".c",
|
".c",
|
||||||
".cc",
|
".cc",
|
||||||
@@ -568,6 +569,17 @@ class AgentLoop:
|
|||||||
)
|
)
|
||||||
return candidate
|
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
|
consecutive_checkpoint_rejections = 0
|
||||||
assistant_message = {
|
assistant_message = {
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from agent_platform.auth import UserIdentity
|
|||||||
from agent_platform.config import Settings
|
from agent_platform.config import Settings
|
||||||
from agent_platform.runtime.schemas import PlanItem
|
from agent_platform.runtime.schemas import PlanItem
|
||||||
from agent_platform.store import RuntimeStore
|
from agent_platform.store import RuntimeStore
|
||||||
|
from agent_platform.text import truncate_middle
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -337,6 +338,4 @@ class ToolRegistry:
|
|||||||
|
|
||||||
def tool_result_text(result: Any, limit: int) -> str:
|
def tool_result_text(result: Any, limit: int) -> str:
|
||||||
text = json.dumps(result, ensure_ascii=False, separators=(",", ":"), default=str)
|
text = json.dumps(result, ensure_ascii=False, separators=(",", ":"), default=str)
|
||||||
if len(text) <= limit:
|
return truncate_middle(text, limit)
|
||||||
return text
|
|
||||||
return text[:limit] + "…"
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -120,6 +120,59 @@ class ParallelRegistry:
|
|||||||
return {"ok": True, "path": arguments["path"]}
|
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:
|
async def test_read_only_tool_calls_run_in_parallel(settings) -> None:
|
||||||
first = {
|
first = {
|
||||||
"choices": [
|
"choices": [
|
||||||
|
|||||||
@@ -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 = "<omitted>"
|
||||||
|
|
||||||
|
assert truncate_middle("abcdef", 0, marker) == ""
|
||||||
|
assert truncate_middle("abcdef", 3, marker) == "abc"
|
||||||
|
assert truncate_middle("abcdefghijklmnopqrstuvwxyz", len(marker) + 1, marker) == "a<omitted>"
|
||||||
Reference in New Issue
Block a user