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
This commit is contained in:
@@ -1,5 +1,22 @@
|
|||||||
# Changelog — FlowDeck
|
# 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 /
|
## 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
|
> Le panneau FlowDeck Agent gagne l'expérience « type Notion AI » : chaque réponse de l'agent expose
|
||||||
|
|||||||
+1
-1
@@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="FlowDeck",
|
title="FlowDeck",
|
||||||
version="4.15.0",
|
version="4.15.1",
|
||||||
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
docs_url="/docs" if settings.log_level == "DEBUG" else None,
|
||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
|
|||||||
@@ -158,45 +158,73 @@ class AgentEngine:
|
|||||||
|
|
||||||
if response.text and response.text.strip():
|
if response.text and response.text.strip():
|
||||||
yield self._event("reasoning", {"content": response.text})
|
yield self._event("reasoning", {"content": response.text})
|
||||||
messages.append({"role": "assistant", "content": response.text})
|
|
||||||
|
|
||||||
if not response.tool_calls:
|
if not response.tool_calls:
|
||||||
|
messages.append({"role": "assistant", "content": response.text or ""})
|
||||||
final_text = response.text or self._no_tool_message(response)
|
final_text = response.text or self._no_tool_message(response)
|
||||||
yield self._event("final", {"content": final_text})
|
yield self._event("final", {"content": final_text})
|
||||||
break
|
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 {}
|
tool, args = call["name"], call.get("arguments") or {}
|
||||||
|
call_id = tool_specs[idx]["id"]
|
||||||
|
denied = False
|
||||||
try:
|
try:
|
||||||
self.perms.assert_can(tool, args, self.workspace_id, approval_mode)
|
self.perms.assert_can(tool, args, self.workspace_id, approval_mode)
|
||||||
except Exception as exc: # permission / approval guard
|
except Exception as exc: # permission / approval guard
|
||||||
detail = self._exc_detail(exc)
|
detail = self._exc_detail(exc)
|
||||||
yield self._event("action", {"tool": tool, "status": "error", "detail": detail})
|
yield self._event("action", {"tool": tool, "status": "error", "detail": detail})
|
||||||
self._log_action(conversation_id, tool, args, {}, "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({
|
messages.append({
|
||||||
"role": "tool", "name": tool,
|
"role": "tool", "tool_call_id": call_id,
|
||||||
"content": json.dumps({"status": "ok", "result": result.data, "target_id": result.target_id}, ensure_ascii=False),
|
"content": json.dumps({"status": "error", "message": f"Permission refusée: {detail}"}, 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),
|
|
||||||
})
|
})
|
||||||
|
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:
|
if final_text is None:
|
||||||
final_text = "Objectif traité. Consultez le journal des actions pour le détail."
|
final_text = "Objectif traité. Consultez le journal des actions pour le détail."
|
||||||
|
|||||||
@@ -116,9 +116,12 @@ class LLMClient:
|
|||||||
self._http_complete(messages, model, tools),
|
self._http_complete(messages, model, tools),
|
||||||
timeout=settings.agent_run_timeout_seconds,
|
timeout=settings.agent_run_timeout_seconds,
|
||||||
)
|
)
|
||||||
except Exception as exc: # noqa: BLE001 — degrade gracefully to mock
|
except Exception as exc: # noqa: BLE001 — never mask a real-provider failure
|
||||||
logger.warning("LLM provider '%s' failed (%s); falling back to offline", self.provider, exc)
|
# On NE retombe PAS silencieusement sur le mock quand un fournisseur
|
||||||
return await self._mock_complete(messages, model, tools)
|
# 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:
|
async def is_available(self) -> bool:
|
||||||
"""True when a real provider is configured."""
|
"""True when a real provider is configured."""
|
||||||
@@ -172,11 +175,17 @@ class LLMClient:
|
|||||||
text = choice.get("content") or ""
|
text = choice.get("content") or ""
|
||||||
tool_calls = []
|
tool_calls = []
|
||||||
for tc in choice.get("tool_calls") or []:
|
for tc in choice.get("tool_calls") or []:
|
||||||
|
fn = tc.get("function") or {}
|
||||||
try:
|
try:
|
||||||
args = json.loads(tc["function"].get("arguments") or "{}")
|
args = json.loads(fn.get("arguments") or "{}")
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
args = {}
|
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(
|
return LLMResponse(
|
||||||
text=text,
|
text=text,
|
||||||
|
|||||||
+45
-2
@@ -354,11 +354,54 @@ def test_engine_run_creates_document_in_workspace_and_titles_conversation(client
|
|||||||
assert "Projet" in title
|
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:
|
def _make_conversation(client) -> int:
|
||||||
resp = client.post("/api/agent/conversations", json={"title": "test"})
|
resp = client.post("/api/agent/conversations", json={"title": "test"})
|
||||||
return resp.json()["id"]
|
return resp.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
# ── Router: agents, conversations, run, skills ──
|
# ── Router: agents, conversations, run, skills ──
|
||||||
|
|
||||||
def test_router_list_agents(client):
|
def test_router_list_agents(client):
|
||||||
|
|||||||
Reference in New Issue
Block a user