fix
This commit is contained in:
@@ -88,7 +88,8 @@ async def run_iteration(
|
||||
sse_emitter=sse.emit)
|
||||
tools = registry.to_openai_format("orchestrator_phase2")
|
||||
phase2_msg: dict[str, Any] = {}
|
||||
for retry in range(3):
|
||||
phase2_retries = int(settings.get("llm.tool_retry_attempts", 3))
|
||||
for retry in range(phase2_retries + 1):
|
||||
resp = await llm.complete(
|
||||
stage="orchestrator_phase2",
|
||||
messages=messages,
|
||||
@@ -158,12 +159,13 @@ async def run_iteration(
|
||||
db=db, world=world, step=step, llm=llm, sse=sse, settings=settings,
|
||||
)
|
||||
|
||||
# 3.3 Suggest actions
|
||||
# 3.3 Suggest actions — retry up to tool_retry_attempts if no tools returned
|
||||
suggest_msgs = await build_orchestrator_phase3_suggest_context(
|
||||
db=db, world=world, scene_text=step.scene_text or "", settings=settings,
|
||||
)
|
||||
suggest_tools = registry.to_openai_format("orchestrator_phase3_suggest")
|
||||
for retry in range(2):
|
||||
tool_retry_limit = int(settings.get("llm.tool_retry_attempts", 3))
|
||||
for retry in range(tool_retry_limit + 1):
|
||||
resp = await llm.complete(
|
||||
stage="orchestrator_phase3_suggest",
|
||||
messages=suggest_msgs,
|
||||
@@ -173,9 +175,19 @@ async def run_iteration(
|
||||
world_id=world.id, step_id=step.id, session=db,
|
||||
)
|
||||
msg = resp.get("message", {})
|
||||
# Apply text replacements
|
||||
content = msg.get("content", "") or ""
|
||||
if content:
|
||||
from app.core.settings_service import apply_text_replacements
|
||||
content = await apply_text_replacements(db, content)
|
||||
msg = dict(msg)
|
||||
msg["content"] = content
|
||||
tcs = msg.get("tool_calls") or []
|
||||
found = False
|
||||
for tc in tcs:
|
||||
fn = tc.get("function", {})
|
||||
fn = tc.get("function", {}) if isinstance(tc, dict) else {}
|
||||
if not isinstance(fn, dict):
|
||||
fn = {"name": tc.get("name", ""), "arguments": tc.get("arguments", "{}")}
|
||||
if fn.get("name") == "suggest_actions":
|
||||
try:
|
||||
args = json.loads(fn.get("arguments") or "{}")
|
||||
@@ -185,11 +197,24 @@ async def run_iteration(
|
||||
if result.ok:
|
||||
step.suggested_actions = result.data.get("actions", [])
|
||||
await sse.emit("suggested_actions", {"actions": step.suggested_actions})
|
||||
found = True
|
||||
break
|
||||
if step.suggested_actions:
|
||||
if found:
|
||||
break
|
||||
# No suggest_actions call — retry with nudge
|
||||
suggest_msgs.append(msg)
|
||||
suggest_msgs.append({"role": "user", "content": "Call suggest_actions with 1-3 actions."})
|
||||
suggest_msgs.append({
|
||||
"role": "user",
|
||||
"content": (
|
||||
"You did not call suggest_actions. "
|
||||
"You MUST call the suggest_actions tool with 1-3 short action strings. "
|
||||
"If you tried before and it didn't work, try again with proper JSON arguments."
|
||||
),
|
||||
})
|
||||
# If still no actions after retries, provide defaults
|
||||
if not step.suggested_actions:
|
||||
step.suggested_actions = ["Continue exploring", "Talk to someone nearby"]
|
||||
await sse.emit("suggested_actions", {"actions": step.suggested_actions})
|
||||
|
||||
await db.commit()
|
||||
await sse.emit("iteration_complete", {
|
||||
|
||||
@@ -25,17 +25,25 @@ class SseEmitter:
|
||||
In a producer task:
|
||||
await emitter.emit("tool_call", {...})
|
||||
await emitter.done({"result": "ok"})
|
||||
|
||||
Production mode: when `debug=False`, raw `tool_call` and `llm_call_*`
|
||||
events are filtered or transformed into friendly `status` events.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, debug: bool = True) -> None:
|
||||
self._queue: asyncio.Queue[tuple[str, str, str] | None] = asyncio.Queue()
|
||||
# (event_type, data_json, event_id)
|
||||
self._event_counter = 0
|
||||
self._closed = False
|
||||
self._debug = debug
|
||||
|
||||
async def emit(self, event_type: str, data: Any) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
# In production mode, transform/fiter debug-only events
|
||||
if not self._debug:
|
||||
event_type, data = self._transform_for_prod(event_type, data)
|
||||
if event_type is None:
|
||||
return # event filtered out
|
||||
self._event_counter += 1
|
||||
event_id = f"evt_{self._event_counter}"
|
||||
try:
|
||||
@@ -44,6 +52,76 @@ class SseEmitter:
|
||||
data_str = json.dumps({"error": "serialization_failed"})
|
||||
await self._queue.put((event_type, data_str, event_id))
|
||||
|
||||
def _transform_for_prod(self, event_type: str, data: Any) -> tuple[str | None, Any]:
|
||||
"""Transform debug events into user-friendly status events for production."""
|
||||
if event_type == "tool_call":
|
||||
# Transform tool_call into a friendly status message
|
||||
tool = data.get("tool", "") if isinstance(data, dict) else ""
|
||||
result = data.get("result", {}) if isinstance(data, dict) else {}
|
||||
is_success = data.get("is_success", True) if isinstance(data, dict) else True
|
||||
# Friendly message based on tool type
|
||||
friendly = self._friendly_tool_message(tool, result, is_success)
|
||||
if friendly:
|
||||
return ("status", {"message": friendly, "type": "tool"})
|
||||
return (None, None) # filter out
|
||||
elif event_type in ("llm_call_start", "llm_call_end"):
|
||||
# Filter out raw LLM call events in production
|
||||
return (None, None)
|
||||
elif event_type == "phase_start":
|
||||
# Keep but with friendly name
|
||||
phase = data.get("phase") if isinstance(data, dict) else None
|
||||
friendly_names = {
|
||||
1: "planning",
|
||||
2: "writing",
|
||||
3: "sending",
|
||||
}
|
||||
name = friendly_names.get(phase, data.get("name", ""))
|
||||
return ("phase_start", {"phase": phase, "name": name, "status": name})
|
||||
elif event_type == "phase_end":
|
||||
return (event_type, data)
|
||||
elif event_type == "warning":
|
||||
return (event_type, data)
|
||||
else:
|
||||
return (event_type, data)
|
||||
|
||||
def _friendly_tool_message(self, tool: str, result: dict, is_success: bool) -> str | None:
|
||||
"""Generate a user-friendly message for a tool call."""
|
||||
if not is_success:
|
||||
return None # hide failed tool calls in production
|
||||
data = result.get("data", {}) if isinstance(result, dict) else {}
|
||||
msg = result.get("message", "") if isinstance(result, dict) else ""
|
||||
if tool == "entity_create":
|
||||
name = data.get("entity_id", "")
|
||||
return f"Added new entity" + (f": {name}" if name else "")
|
||||
elif tool == "entity_update":
|
||||
return "Updated entity"
|
||||
elif tool == "entity_delete":
|
||||
return "Removed entity"
|
||||
elif tool == "env_update":
|
||||
return "Updated game state"
|
||||
elif tool == "update_plot_rails":
|
||||
return "Updated story progress"
|
||||
elif tool == "advance_time":
|
||||
new_time = data.get("new_time", "")
|
||||
return f"Time advanced" + (f" to {new_time}" if new_time else "")
|
||||
elif tool == "schedule_trigger":
|
||||
return "Scheduled future event"
|
||||
elif tool == "rag_query":
|
||||
return None # hide RAG queries in production
|
||||
elif tool == "rag_add":
|
||||
return "Recorded a new fact"
|
||||
elif tool == "calc":
|
||||
return None # hide calculations
|
||||
elif tool == "random_choice":
|
||||
return None
|
||||
elif tool == "submit_plan":
|
||||
return None
|
||||
elif tool == "submit_step":
|
||||
return None
|
||||
elif tool == "suggest_actions":
|
||||
return None
|
||||
return msg if msg else None
|
||||
|
||||
async def ping(self) -> None:
|
||||
await self.emit("ping", {"ts": _now_iso()})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user