Skip to content

Commit b3fd14f

Browse files
authored
Merge pull request #448 from ThomasOscar/fix/tool-call-pair-integrity-324
fix(llm): prevent "No tool call found" errors by ensuring tool call pair integrity
2 parents 8a53778 + 9ab60c2 commit b3fd14f

12 files changed

Lines changed: 248 additions & 80 deletions

File tree

backend/app/api/dingtalk.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,8 @@ async def process_dingtalk_message(
218218
.order_by(ChatMessage.created_at.desc())
219219
.limit(ctx_size)
220220
)
221-
history = [{"role": m.role, "content": m.content} for m in reversed(history_r.scalars().all())]
221+
from app.services.llm.utils import convert_chat_messages_to_llm_format as _conv
222+
history = _conv(reversed(history_r.scalars().all()))
222223

223224
# Build saved_content for DB (no base64 blobs, keep it display-friendly)
224225
import re as _re_dt

backend/app/api/discord_bot.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,8 @@ async def handle_in_background():
335335
.order_by(ChatMessage.created_at.desc())
336336
.limit(ctx_size)
337337
)
338-
history = [{"role": m.role, "content": m.content} for m in reversed(history_r.scalars().all())]
338+
from app.services.llm.utils import convert_chat_messages_to_llm_format as _conv
339+
history = _conv(reversed(history_r.scalars().all()))
339340

340341
# Save user message
341342
bg_db.add(ChatMessage(agent_id=agent_id, user_id=platform_user_id, role="user", content=user_text, conversation_id=session_conv_id))

backend/app/api/feishu.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,20 @@
66
from collections.abc import Awaitable, Callable
77
from datetime import datetime
88

9-
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
9+
from fastapi import APIRouter, Depends, HTTPException, Request, status
10+
from fastapi.responses import HTMLResponse
1011
from loguru import logger
11-
from sqlalchemy import select, or_
12+
from sqlalchemy import select
1213
from sqlalchemy.ext.asyncio import AsyncSession
1314

1415
from app.core.permissions import check_agent_access, is_agent_creator, is_agent_expired
1516
from app.core.security import get_current_user
1617
from app.database import async_session as _async_session, get_db
1718
from app.models.channel_config import ChannelConfig
1819
from app.models.user import User
19-
from app.models.identity import IdentityProvider
2020
from app.schemas.schemas import ChannelConfigCreate, ChannelConfigOut, TokenResponse, UserOut
2121
from app.services.feishu_service import feishu_service
22+
from app.services.llm.utils import convert_chat_messages_to_llm_format, truncate_messages_with_pair_integrity
2223
from app.services.storage import agent_upload_key, get_storage_backend, store_agent_upload
2324

2425
router = APIRouter(tags=["feishu"])
@@ -265,8 +266,6 @@ async def _save_feishu_tool_call(
265266

266267
# ─── OAuth ──────────────────────────────────────────────
267268

268-
from fastapi.responses import HTMLResponse, Response
269-
270269
@router.get("/auth/feishu/callback")
271270
@router.post("/auth/feishu/callback", response_model=TokenResponse)
272271
async def feishu_oauth_callback(
@@ -673,10 +672,9 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict):
673672
.limit(ctx_size)
674673
)
675674
history_msgs = history_result.scalars().all()
676-
history = _build_llm_history_from_chat_messages(list(reversed(history_msgs)))
675+
history = convert_chat_messages_to_llm_format(reversed(history_msgs))
677676

678677
# --- Resolve Feishu sender identity & find/create platform user ---
679-
import uuid as _uuid
680678
import httpx as _httpx
681679

682680
sender_name = ""
@@ -732,7 +730,9 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict):
732730
# Cache sender info so feishu_user_search can find them by name
733731
if sender_name and sender_open_id:
734732
try:
735-
import pathlib as _pl, json as _cj, time as _ct
733+
import pathlib as _pl
734+
import json as _cj
735+
import time as _ct
736736
_safe_id = str(agent_id).replace("..", "").replace("/", "")
737737
_cache = _pl.Path(f"/data/workspaces/{_safe_id}/feishu_contacts_cache.json")
738738
_cache.parent.mkdir(parents=True, exist_ok=True)
@@ -1221,15 +1221,14 @@ async def _handle_feishu_file(
12211221
chat_id,
12221222
):
12231223
"""Handle incoming file or image messages from Feishu (runs as a background task)."""
1224-
import asyncio, random, json
1224+
import asyncio
1225+
import random
1226+
import json
12251227
from app.models.audit import ChatMessage
12261228
from app.models.agent import Agent as AgentModel
1227-
from app.models.user import User as UserModel
12281229
from app.services.channel_session import find_or_create_channel_session
1229-
from app.core.security import hash_password
12301230
from app.database import async_session as _async_session
12311231
from datetime import datetime as _dt, timezone as _tz
1232-
import uuid as _uuid
12331232
from sqlalchemy import select as _select
12341233

12351234
msg_type = message.get("message_type", "file")
@@ -1404,7 +1403,7 @@ async def _handle_feishu_file(
14041403
.order_by(ChatMessage.created_at.desc())
14051404
.limit(ctx_size)
14061405
)
1407-
_history = _build_llm_history_from_chat_messages(list(reversed(_hist_r.scalars().all())))
1406+
_history = convert_chat_messages_to_llm_format(reversed(_hist_r.scalars().all()))
14081407

14091408
await db.commit()
14101409

@@ -1666,7 +1665,7 @@ async def _call_llm_with_config(
16661665
from app.models.agent import DEFAULT_CONTEXT_WINDOW_SIZE
16671666
ctx_size = agent.context_window_size or DEFAULT_CONTEXT_WINDOW_SIZE
16681667
if history:
1669-
messages.extend(_normalize_history_messages(history)[-ctx_size:])
1668+
messages.extend(truncate_messages_with_pair_integrity(history, ctx_size))
16701669
messages.append({"role": "user", "content": user_text})
16711670

16721671
effective_user_id = user_id or agent_id

backend/app/api/gateway.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -392,9 +392,8 @@ async def _send_to_agent_background(
392392
)
393393
hist_msgs = list(reversed(hist_result.scalars().all()))
394394

395-
messages = []
396-
for h in hist_msgs:
397-
messages.append({"role": h.role, "content": h.content or ""})
395+
from app.services.llm.utils import convert_chat_messages_to_llm_format as _conv
396+
messages = _conv(hist_msgs)
398397

399398
# Add the new message with agent communication context
400399
user_msg = f"{agent_comm_alert}\n\n[Message from agent: {source_agent_name}]\n{content}"

backend/app/api/slack.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,8 @@ async def slack_event_webhook(
306306
.order_by(ChatMessage.created_at.desc())
307307
.limit(ctx_size)
308308
)
309-
history = [{"role": m.role, "content": m.content} for m in reversed(history_r.scalars().all())]
309+
from app.services.llm.utils import convert_chat_messages_to_llm_format as _conv
310+
history = _conv(reversed(history_r.scalars().all()))
310311

311312
# Handle file attachments: save to workspace/uploads/ and send ack
312313
import asyncio as _asyncio

backend/app/api/teams.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,8 @@ async def teams_event_webhook(
477477
.order_by(ChatMessage.created_at.desc())
478478
.limit(ctx_size)
479479
)
480-
history = [{"role": m.role, "content": m.content} for m in reversed(history_r.scalars().all())]
480+
from app.services.llm.utils import convert_chat_messages_to_llm_format as _conv
481+
history = _conv(reversed(history_r.scalars().all()))
481482

482483
# Save user message
483484
db.add(ChatMessage(agent_id=agent_id, user_id=platform_user_id, role="user", content=user_text, conversation_id=session_conv_id))

backend/app/api/websocket.py

Lines changed: 6 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,15 @@
88
from time import perf_counter
99

1010

11-
from fastapi import APIRouter, Depends, Query, WebSocket, WebSocketDisconnect
11+
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
1212
from loguru import logger
1313
from sqlalchemy import select
1414
from sqlalchemy.ext.asyncio import AsyncSession
1515

1616
from app.core.logging_config import set_trace_id
1717
from app.core.permissions import check_agent_access, is_agent_expired
18-
from app.core.security import decode_access_token, get_current_user
19-
from app.database import async_session, get_db
18+
from app.core.security import decode_access_token
19+
from app.database import async_session
2020
from app.models.agent import Agent
2121
from app.models.audit import ChatMessage
2222
from app.models.chat_session import ChatSession
@@ -27,6 +27,7 @@
2727
from app.services.agentbay_live import detect_agentbay_env, get_browser_snapshot, get_desktop_screenshot
2828
from app.services.chat_session_service import ensure_primary_platform_session
2929
from app.services.llm import call_llm_with_failover
30+
from app.services.llm.utils import convert_chat_messages_to_llm_format, truncate_messages_with_pair_integrity
3031
from app.services.onboarding import is_onboarded, mark_onboarding_phase, resolve_onboarding_prompt
3132
from app.services.quota_guard import (
3233
AgentExpired,
@@ -38,7 +39,6 @@
3839
)
3940
from app.services.realtime import realtime_router
4041
from app.services.task_executor import execute_task
41-
from app.services.vision_inject import sanitize_history_tool_result
4242

4343
router = APIRouter(tags=["websocket"])
4444

@@ -451,46 +451,7 @@ async def _load_history(self, db: AsyncSession):
451451

452452
def _build_conversation_context(self) -> list[dict]:
453453
"""Translates historical ChatMessages to LLM inputs."""
454-
conversation = []
455-
for msg in self.history_messages:
456-
if msg.role == "tool_call":
457-
try:
458-
tc_data = json.loads(msg.content)
459-
tc_name = tc_data.get("name") or tc_data.get("tool_name") or "unknown"
460-
tc_args = tc_data.get("args") or tc_data.get("arguments") or {}
461-
tc_result = tc_data.get("result", "")
462-
tc_id = f"call_{msg.id}"
463-
asst_msg = {
464-
"role": "assistant",
465-
"content": None,
466-
"tool_calls": [
467-
{
468-
"id": tc_id,
469-
"type": "function",
470-
"function": {"name": tc_name, "arguments": json.dumps(tc_args, ensure_ascii=False)},
471-
}
472-
],
473-
}
474-
if tc_data.get("reasoning_content"):
475-
asst_msg["reasoning_content"] = tc_data["reasoning_content"]
476-
conversation.append(asst_msg)
477-
478-
sanitized_result = sanitize_history_tool_result(str(tc_result))
479-
conversation.append(
480-
{
481-
"role": "tool",
482-
"tool_call_id": tc_id,
483-
"content": sanitized_result[:500],
484-
}
485-
)
486-
except Exception:
487-
continue
488-
else:
489-
entry = {"role": msg.role, "content": msg.content}
490-
if hasattr(msg, "thinking") and msg.thinking:
491-
entry["thinking"] = msg.thinking
492-
conversation.append(entry)
493-
return conversation
454+
return convert_chat_messages_to_llm_format(self.history_messages)
494455

495456
async def message_loop(self):
496457
"""Core message processing loop."""
@@ -845,9 +806,7 @@ async def _call_with_failover():
845806
async def _on_failover(reason: str):
846807
await self.websocket.send_json({"type": "info", "content": f"Primary model error, {reason}"})
847808

848-
_truncated = self.conversation[-self.ctx_size :]
849-
while _truncated and _truncated[0].get("role") == "tool":
850-
_truncated.pop(0)
809+
_truncated = truncate_messages_with_pair_integrity(self.conversation, self.ctx_size)
851810

852811
# Resolve onboarding prompt
853812
skip_tools_for_greeting = False

backend/app/api/wecom.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,8 @@ async def _process_wecom_text(
579579
.order_by(ChatMessage.created_at.desc())
580580
.limit(ctx_size)
581581
)
582-
history = [{"role": m.role, "content": m.content} for m in reversed(history_r.scalars().all())]
582+
from app.services.llm.utils import convert_chat_messages_to_llm_format as _conv
583+
history = _conv(reversed(history_r.scalars().all()))
583584

584585
# Save user message
585586
db.add(ChatMessage(

backend/app/services/discord_gateway.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -215,10 +215,8 @@ async def _handle_message(
215215
.order_by(ChatMessage.created_at.desc())
216216
.limit(ctx_size)
217217
)
218-
history = [
219-
{"role": m.role, "content": m.content}
220-
for m in reversed(history_r.scalars().all())
221-
]
218+
from app.services.llm.utils import convert_chat_messages_to_llm_format as _conv
219+
history = _conv(reversed(history_r.scalars().all()))
222220

223221
# Save user message
224222
db.add(ChatMessage(

backend/app/services/llm/client.py

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -743,10 +743,20 @@ def _messages_to_input(self, messages: list[LLMMessage]) -> list[dict[str, Any]]
743743
input_items: list[dict[str, Any]] = []
744744

745745
for msg in messages:
746-
if msg.role in {"system", "user", "assistant"} and msg.content is not None:
747-
item: dict[str, Any] = {"role": msg.role}
748-
item["content"] = self._format_content_for_input(msg.content)
749-
input_items.append(item)
746+
# Handle system messages with dynamic_content
747+
if msg.role == "system" and msg.content is not None:
748+
content = msg.content
749+
if msg.dynamic_content:
750+
content = f"{content}\n\n{msg.dynamic_content}"
751+
input_items.append({
752+
"role": msg.role,
753+
"content": self._format_content_for_input(content),
754+
})
755+
elif msg.role in {"user", "assistant"} and msg.content is not None:
756+
input_items.append({
757+
"role": msg.role,
758+
"content": self._format_content_for_input(msg.content),
759+
})
750760

751761
if msg.role == "assistant" and msg.tool_calls:
752762
for tc in msg.tool_calls:
@@ -768,8 +778,67 @@ def _messages_to_input(self, messages: list[LLMMessage]) -> list[dict[str, Any]]
768778
"output": msg.content or "",
769779
})
770780

781+
# Sanitize: ensure every function_call_output has a matching function_call.
782+
# This prevents "No tool call found for function call output" API errors
783+
# caused by context window truncation breaking assistant+tool pairs.
784+
input_items = self._sanitize_input_items(input_items)
785+
771786
return input_items
772787

788+
@staticmethod
789+
def _sanitize_input_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
790+
"""Remove orphaned function_call_output items that have no matching function_call.
791+
792+
Also removes function_call items whose function_call_output is missing,
793+
since the Responses API requires complete pairs.
794+
"""
795+
# Collect all call_ids from function_call items
796+
call_ids_with_fc: set[str] = set()
797+
for item in items:
798+
if item.get("type") == "function_call":
799+
call_id = item.get("call_id", "")
800+
if call_id:
801+
call_ids_with_fc.add(call_id)
802+
803+
# Collect all call_ids from function_call_output items
804+
call_ids_with_fco: set[str] = set()
805+
for item in items:
806+
if item.get("type") == "function_call_output":
807+
call_id = item.get("call_id", "")
808+
if call_id:
809+
call_ids_with_fco.add(call_id)
810+
811+
# Determine which call_ids are orphaned (output without call, or call without output)
812+
orphaned_fco = call_ids_with_fco - call_ids_with_fc
813+
orphaned_fc = call_ids_with_fc - call_ids_with_fco
814+
815+
if not orphaned_fco and not orphaned_fc:
816+
return items
817+
818+
if orphaned_fco:
819+
logger.warning(
820+
"[OpenAIResponses] Removing %d orphaned function_call_output item(s) "
821+
"with no matching function_call: %s",
822+
len(orphaned_fco),
823+
orphaned_fco,
824+
)
825+
if orphaned_fc:
826+
logger.warning(
827+
"[OpenAIResponses] Removing %d orphaned function_call item(s) "
828+
"with no matching function_call_output: %s",
829+
len(orphaned_fc),
830+
orphaned_fc,
831+
)
832+
833+
# Filter out orphaned items
834+
return [
835+
item for item in items
836+
if not (
837+
(item.get("type") == "function_call_output" and item.get("call_id", "") in orphaned_fco)
838+
or (item.get("type") == "function_call" and item.get("call_id", "") in orphaned_fc)
839+
)
840+
]
841+
773842
def _convert_tools(self, tools: list[dict] | None) -> list[dict] | None:
774843
"""Convert OpenAI tool schema to Responses API function tool schema."""
775844
if not tools:

0 commit comments

Comments
 (0)