This commit is contained in:
Mikan
2026-06-21 09:24:42 +03:00
parent 7cbe8da103
commit c45ab1ddd5
24 changed files with 1438 additions and 148 deletions

View File

@@ -312,7 +312,16 @@ async def _run_tool_loop(
max_substeps: int,
settings: dict[str, Any],
) -> dict[str, Any] | None:
"""Generic tool-calling loop. Returns the result of the terminal tool call."""
"""Generic tool-calling loop. Returns the result of the terminal tool call.
Rules:
- The terminal tool (e.g. submit_plan) cannot be called on the first substep.
The model must do at least some work first.
- If the terminal tool is called in the same response as other tools that
FAILED, the terminal call is cancelled (the model should fix errors first).
- If the model returns no tool calls, retry with a system nudge (up to
`llm.tool_retry_attempts` times, default 3).
"""
registry = get_registry()
ctx = ToolContext(
db=db, world=world, stage=stage,
@@ -324,6 +333,8 @@ async def _run_tool_loop(
]
tools = registry.to_openai_format(stage)
last_terminal_result: dict[str, Any] | None = None
tool_retry_attempts = int(settings.get("llm.tool_retry_attempts", 3)) if settings else 3
no_tool_retries = 0
for substep in range(max_substeps):
await sse.emit("llm_call_start", {"stage": stage, "model": getattr(llm, "_model", "mock")})
@@ -350,8 +361,16 @@ async def _run_tool_loop(
msg["content"] = content
tool_calls = msg.get("tool_calls") or []
if not tool_calls:
# No tool calls — append assistant message and retry with a nudge.
# Up to 3 retries.
# No tool calls — retry with nudge (up to tool_retry_attempts)
no_tool_retries += 1
if no_tool_retries > tool_retry_attempts:
# Exhausted retries — force terminal
messages.append({"role": "assistant", "content": msg.get("content", "")})
messages.append({
"role": "user",
"content": f"No more retries. You MUST call {terminal_tool} now to end this stage.",
})
continue
messages.append({"role": "assistant", "content": msg.get("content", "")})
messages.append({
"role": "user",
@@ -363,13 +382,43 @@ async def _run_tool_loop(
),
})
continue
# Reset retry counter on success
no_tool_retries = 0
# Separate terminal from non-terminal calls
non_terminal_calls = [tc for tc in tool_calls if isinstance(tc, dict)
and (tc.get("function", {}) if isinstance(tc.get("function"), dict) else {})
.get("name") != terminal_tool]
terminal_calls = [tc for tc in tool_calls if isinstance(tc, dict)
and (tc.get("function", {}) if isinstance(tc.get("function"), dict) else {})
.get("name") == terminal_tool]
# Rule 1: terminal tool not allowed on first substep
if substep == 0 and terminal_calls and not non_terminal_calls:
messages.append(msg)
messages.append({
"role": "user",
"content": (
f"You called {terminal_tool} without doing any work first. "
f"You MUST call other tools (entity_create, env_update, etc.) to accomplish the task. "
f"Only call {terminal_tool} after you have completed the work."
),
})
continue
# Execute non-terminal calls first
messages.append(msg)
for tc in tool_calls:
any_failed = False
for tc in non_terminal_calls:
fn = tc.get("function", {}) if isinstance(tc, dict) else {}
if not isinstance(fn, dict):
fn = {"name": tc.get("name", ""), "arguments": tc.get("arguments", "{}")}
tname = fn.get("name", "")
args_str = fn.get("arguments", "{}")
if not isinstance(args_str, str):
args_str = json.dumps(args_str)
try:
targs = json.loads(fn.get("arguments") or "{}")
targs = json.loads(args_str) if args_str else {}
except json.JSONDecodeError:
targs = {}
result = await registry.execute(tname, targs, ctx)
@@ -379,9 +428,49 @@ async def _run_tool_loop(
"name": tname,
"content": json.dumps(result.to_dict(), ensure_ascii=False),
})
if tname == terminal_tool:
last_terminal_result = result.to_dict()
return last_terminal_result
if not result.ok:
any_failed = True
# Rule 2: if any non-terminal tool failed, cancel terminal calls
if terminal_calls:
if any_failed:
# Cancel terminal — tell the model to fix errors first
for tc in terminal_calls:
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", ""),
"name": terminal_tool,
"content": json.dumps({
"ok": False,
"error": {
"code": "cancelled",
"message": f"{terminal_tool} cancelled because other tools in this response failed. Fix the errors first, then call {terminal_tool}.",
},
}),
})
continue
else:
# Execute terminal call
tc = terminal_calls[0]
fn = tc.get("function", {}) if isinstance(tc, dict) else {}
if not isinstance(fn, dict):
fn = {"name": tc.get("name", ""), "arguments": tc.get("arguments", "{}")}
args_str = fn.get("arguments", "{}")
if not isinstance(args_str, str):
args_str = json.dumps(args_str)
try:
targs = json.loads(args_str) if args_str else {}
except json.JSONDecodeError:
targs = {}
result = await registry.execute(terminal_tool, targs, ctx)
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", ""),
"name": terminal_tool,
"content": json.dumps(result.to_dict(), ensure_ascii=False),
})
if result.ok:
last_terminal_result = result.to_dict()
return last_terminal_result
# If we exhausted substeps without terminal, return None
return last_terminal_result