fix(agent): v4.15.1 - protocole tool_calls conforme + plus de repli silencieux sur le mock (echos)
FlowDeck CI / test (push) Failing after 11s
FlowDeck CI / docker (push) Skipped

- L'engine envoie le message assistant avec ses tool_calls (id) et chaque resultat d'outil avec son tool_call_id, y compris en cas de refus; la passe suivante n'est plus refusee par l'API
- Un fournisseur reel configure qui echoue remonte maintenant une erreur (SSE error) au lieu de repeter la question via le mock hors-ligne (mock reserve a offline/sans cle)
- llm_client conserve id + arguments_raw des tool_calls
- test de regression test_engine_tool_protocol_messages
This commit is contained in:
2026-09-06 13:06:41 -04:00
parent 571d78115b
commit 113f880863
6 changed files with 130 additions and 33 deletions
+1 -1
View File
@@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
version="4.15.0",
version="4.15.1",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
+52 -24
View File
@@ -158,45 +158,73 @@ class AgentEngine:
if response.text and response.text.strip():
yield self._event("reasoning", {"content": response.text})
messages.append({"role": "assistant", "content": response.text})
if not response.tool_calls:
messages.append({"role": "assistant", "content": response.text or ""})
final_text = response.text or self._no_tool_message(response)
yield self._event("final", {"content": final_text})
break
for call in response.tool_calls:
# L'API de chat exige que le message assistant qui *annonce* les appels
# d'outils porte les `tool_calls` (avec id), puis que chaque résultat
# d'outil soit fourni avec le `tool_call_id` correspondant. Sans cela
# la passe suivante est refusée par le fournisseur (et l'agent retombait
# silencieusement sur le mock hors-ligne).
tool_specs = []
for idx, call in enumerate(response.tool_calls):
call_id = call.get("id") or f"call_{conversation_id}_{idx}_{self._tokens}"
tool_specs.append({
"id": call_id,
"type": "function",
"function": {
"name": call["name"],
"arguments": call.get("arguments_raw")
or json.dumps(call.get("arguments") or {}, ensure_ascii=False),
},
})
assistant_msg = {"role": "assistant", "content": response.text or ""}
assistant_msg["tool_calls"] = tool_specs
messages.append(assistant_msg)
for idx, call in enumerate(response.tool_calls):
tool, args = call["name"], call.get("arguments") or {}
call_id = tool_specs[idx]["id"]
denied = False
try:
self.perms.assert_can(tool, args, self.workspace_id, approval_mode)
except Exception as exc: # permission / approval guard
detail = self._exc_detail(exc)
yield self._event("action", {"tool": tool, "status": "error", "detail": detail})
self._log_action(conversation_id, tool, args, {}, "error", detail=detail)
continue
result = await self.tools.execute(tool, args, user_id=self.user_id)
if result.status == "success":
yield self._event("action", {
"tool": tool, "status": result.status,
"target_type": result.target_type, "target_id": result.target_id,
"message": result.message,
})
self._log_action(conversation_id, tool, args, result.data, "success",
target_type=result.target_type, target_id=result.target_id,
undo=result.undo)
messages.append({
"role": "tool", "name": tool,
"content": json.dumps({"status": "ok", "result": result.data, "target_id": result.target_id}, ensure_ascii=False),
})
else:
yield self._event("action", {"tool": tool, "status": "error", "detail": result.message})
self._log_action(conversation_id, tool, args, {}, "error", detail=result.message)
messages.append({
"role": "tool", "name": tool,
"content": json.dumps({"status": "error", "message": result.message}, ensure_ascii=False),
"role": "tool", "tool_call_id": call_id,
"content": json.dumps({"status": "error", "message": f"Permission refusée: {detail}"}, ensure_ascii=False),
})
denied = True
if not denied:
result = await self.tools.execute(tool, args, user_id=self.user_id)
if result.status == "success":
yield self._event("action", {
"tool": tool, "status": result.status,
"target_type": result.target_type, "target_id": result.target_id,
"message": result.message,
})
self._log_action(conversation_id, tool, args, result.data, "success",
target_type=result.target_type, target_id=result.target_id,
undo=result.undo)
messages.append({
"role": "tool", "tool_call_id": call_id,
"content": json.dumps({"status": "ok", "result": result.data, "target_id": result.target_id}, ensure_ascii=False),
})
else:
yield self._event("action", {"tool": tool, "status": "error", "detail": result.message})
self._log_action(conversation_id, tool, args, {}, "error", detail=result.message)
messages.append({
"role": "tool", "tool_call_id": call_id,
"content": json.dumps({"status": "error", "message": result.message}, ensure_ascii=False),
})
if final_text is None:
final_text = "Objectif traité. Consultez le journal des actions pour le détail."
+14 -5
View File
@@ -116,9 +116,12 @@ class LLMClient:
self._http_complete(messages, model, tools),
timeout=settings.agent_run_timeout_seconds,
)
except Exception as exc: # noqa: BLE001 — degrade gracefully to mock
logger.warning("LLM provider '%s' failed (%s); falling back to offline", self.provider, exc)
return await self._mock_complete(messages, model, tools)
except Exception as exc: # noqa: BLE001 — never mask a real-provider failure
# On NE retombe PAS silencieusement sur le mock quand un fournisseur
# réel est configuré : l'erreur doit remonter (SSE "error") pour que
# l'utilisateur voie pourquoi rien n'a été généré.
logger.warning("LLM provider '%s' failed (%s)", self.provider, exc)
raise
async def is_available(self) -> bool:
"""True when a real provider is configured."""
@@ -172,11 +175,17 @@ class LLMClient:
text = choice.get("content") or ""
tool_calls = []
for tc in choice.get("tool_calls") or []:
fn = tc.get("function") or {}
try:
args = json.loads(tc["function"].get("arguments") or "{}")
args = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
args = {}
tool_calls.append({"name": tc["function"]["name"], "arguments": args})
tool_calls.append({
"id": tc.get("id") or "",
"name": fn.get("name"),
"arguments": args,
"arguments_raw": fn.get("arguments") or "",
})
return LLMResponse(
text=text,