From 113f88086338c7d31d0fb2cccd55a1c77d6cb114 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Sun, 6 Sep 2026 13:06:41 -0400 Subject: [PATCH] fix(agent): v4.15.1 - protocole tool_calls conforme + plus de repli silencieux sur le mock (echos) - 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 --- CHANGELOG.md | 17 ++++++++ VERSION | 2 +- app/main.py | 2 +- app/services/agent_engine.py | 76 ++++++++++++++++++++++++------------ app/services/llm_client.py | 19 ++++++--- tests/test_agent.py | 47 +++++++++++++++++++++- 6 files changed, 130 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fd4d5f..a24e88b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog — FlowDeck +## v4.15.1 (2026-09-06) — Agent : réparation des conversations réelles (protocole tool_calls) + +> Le chatbot ne « répondait » plus : avec un fournisseur réel, dès qu'un **outil** était appelé la passe +> suivante était refusée par l'API (message assistant sans ses `tool_calls`) et l'agent retombait +> silencieusement sur le **mock hors-ligne**, qui se contentait de répéter la question. + +### Correctifs +- **Protocole tool-calling conforme** (`agent_engine.py`) : le message assistant qui annonce un appel + d'outil transporte désormais ses `tool_calls` (avec `id`), et chaque résultat d'outil répond avec le + `tool_call_id` correspondant (y compris en cas de refus/permission). Le run se poursuit alors + correctement et se termine par une vraie réponse du modèle. +- **Plus de repli silencieux** (`llm_client.py`) : quand un fournisseur **réel** configuré échoue, + l'erreur remonte (événement SSE `error`) au lieu de produire silencieusement un écho hors-ligne. + Le mock n'est utilisé que si le fournisseur est réellement non configuré (`offline` / pas de clé). +- Les `tool_calls` analysés conservent leur `id` et les arguments bruts (`arguments_raw`). +- Test de régression `test_engine_tool_protocol_messages`. + ## v4.15.0 (2026-09-06) — Agent : actions sous chaque réponse, mentions @ & skills / > Le panneau FlowDeck Agent gagne l'expérience « type Notion AI » : chaque réponse de l'agent expose diff --git a/VERSION b/VERSION index 5c517bf..fb05571 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.15.0 +4.15.1 diff --git a/app/main.py b/app/main.py index 8c4b41c..2ba3290 100644 --- a/app/main.py +++ b/app/main.py @@ -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, diff --git a/app/services/agent_engine.py b/app/services/agent_engine.py index 8abd238..cc6f8e2 100644 --- a/app/services/agent_engine.py +++ b/app/services/agent_engine.py @@ -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." diff --git a/app/services/llm_client.py b/app/services/llm_client.py index 3f35f6a..ea4dc94 100644 --- a/app/services/llm_client.py +++ b/app/services/llm_client.py @@ -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, diff --git a/tests/test_agent.py b/tests/test_agent.py index eb341b5..3aea591 100644 --- a/tests/test_agent.py +++ b/tests/test_agent.py @@ -354,11 +354,54 @@ def test_engine_run_creates_document_in_workspace_and_titles_conversation(client assert "Projet" in title +class _Resp: + def __init__(self, text="", tool_calls=None): + self.text = text + self.tool_calls = tool_calls or [] + self.usage = {} + + +def test_engine_tool_protocol_messages(client): + """Le message assistant qui annonce un outil porte ses `tool_calls`, et le + résultat d'outil est renvoyé avec le `tool_call_id` correspondant — sans quoi + le fournisseur refuse la passe suivante (et l'agent retombait sur le mock).""" + from app.services.agent_engine import AgentEngine + + class FakeLLM: + def __init__(self): + self.calls = [] + + async def complete(self, messages, *, model=None, tools=None, stream=False): + self.calls.append([dict(m) for m in messages]) + if len(self.calls) == 1: + return _Resp("Plan de création.", + [{"id": "call_abc", "name": "create_collection", + "arguments": {"name": "ProtoCol"}}]) + return _Resp("Collection ProtoCol créée.", []) + + fake = FakeLLM() + engine = AgentEngine(_admin_id(), llm=fake) + conv = client.post("/api/agent/conversations", + json={"title": "protocol"}).json()["id"] + events = asyncio.run(_run_engine(engine, conv, "crée la collection ProtoCol")) + + finals = [e for e in events if e["type"] == "final"] + assert any("ProtoCol" in e.get("content", "") for e in finals), events + + # la 2e passe reçoit un bloc assistant `tool_calls` + un résultat d'outil lié + call2 = fake.calls[1] + asm = next((m for m in call2 if m["role"] == "assistant" and m.get("tool_calls")), None) + tmsg = next((m for m in call2 if m["role"] == "tool"), None) + assert asm is not None and tmsg is not None + assert asm["tool_calls"][0]["id"] == "call_abc" + assert tmsg["tool_call_id"] == asm["tool_calls"][0]["id"] + payload = json.loads(tmsg["content"]) + assert payload["status"] == "ok" + + def _make_conversation(client) -> int: resp = client.post("/api/agent/conversations", json={"title": "test"}) return resp.json()["id"] - - # ── Router: agents, conversations, run, skills ── def test_router_list_agents(client):