Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/app/api/dingtalk.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,8 @@ async def process_dingtalk_message(
.order_by(ChatMessage.created_at.desc())
.limit(ctx_size)
)
history = [{"role": m.role, "content": m.content} for m in reversed(history_r.scalars().all())]
from app.services.llm.utils import convert_chat_messages_to_llm_format as _conv
history = _conv(reversed(history_r.scalars().all()))

# Save user message
db.add(ChatMessage(
Expand Down
3 changes: 2 additions & 1 deletion backend/app/api/discord_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,8 @@ async def handle_in_background():
.order_by(ChatMessage.created_at.desc())
.limit(ctx_size)
)
history = [{"role": m.role, "content": m.content} for m in reversed(history_r.scalars().all())]
from app.services.llm.utils import convert_chat_messages_to_llm_format as _conv
history = _conv(reversed(history_r.scalars().all()))

# Save user message
bg_db.add(ChatMessage(agent_id=agent_id, user_id=platform_user_id, role="user", content=user_text, conversation_id=session_conv_id))
Expand Down
9 changes: 6 additions & 3 deletions backend/app/api/feishu.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,8 @@ async def process_feishu_event(agent_id: uuid.UUID, body: dict, db: AsyncSession
.limit(ctx_size)
)
history_msgs = history_result.scalars().all()
history = [{"role": m.role, "content": m.content} for m in reversed(history_msgs)]
from app.services.llm.utils import convert_chat_messages_to_llm_format
history = convert_chat_messages_to_llm_format(reversed(history_msgs))

# --- Resolve Feishu sender identity & find/create platform user ---
import uuid as _uuid
Expand Down Expand Up @@ -1250,7 +1251,8 @@ async def _handle_feishu_file(db, agent_id, config, message, sender_open_id, cha
.order_by(ChatMessage.created_at.desc())
.limit(ctx_size)
)
_history = [{"role": m.role, "content": m.content} for m in reversed(_hist_r.scalars().all())]
from app.services.llm.utils import convert_chat_messages_to_llm_format as _convert_hist
_history = _convert_hist(reversed(_hist_r.scalars().all()))

await db.commit()

Expand Down Expand Up @@ -1503,9 +1505,10 @@ async def _call_agent_llm(
# Build conversation messages (without system prompt — call_llm adds it)
messages: list[dict] = []
from app.models.agent import DEFAULT_CONTEXT_WINDOW_SIZE
from app.services.llm.utils import truncate_messages_with_pair_integrity as _truncate_pairs
ctx_size = agent.context_window_size or DEFAULT_CONTEXT_WINDOW_SIZE
if history:
messages.extend(history[-ctx_size:])
messages.extend(_truncate_pairs(history, ctx_size))
messages.append({"role": "user", "content": user_text})

# Use actual user_id so the system prompt knows who it's chatting with
Expand Down
5 changes: 2 additions & 3 deletions backend/app/api/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,9 +389,8 @@ async def _send_to_agent_background(
)
hist_msgs = list(reversed(hist_result.scalars().all()))

messages = []
for h in hist_msgs:
messages.append({"role": h.role, "content": h.content or ""})
from app.services.llm.utils import convert_chat_messages_to_llm_format as _conv
messages = _conv(reversed(hist_msgs))
Comment thread
ThomasOscar marked this conversation as resolved.
Outdated

# Add the new message with agent communication context
user_msg = f"{agent_comm_alert}\n\n[Message from agent: {source_agent_name}]\n{content}"
Expand Down
3 changes: 2 additions & 1 deletion backend/app/api/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,8 @@ async def slack_event_webhook(
.order_by(ChatMessage.created_at.desc())
.limit(ctx_size)
)
history = [{"role": m.role, "content": m.content} for m in reversed(history_r.scalars().all())]
from app.services.llm.utils import convert_chat_messages_to_llm_format as _conv
history = _conv(reversed(history_r.scalars().all()))

# Handle file attachments: save to workspace/uploads/ and send ack
from app.config import get_settings as _gs
Expand Down
3 changes: 2 additions & 1 deletion backend/app/api/teams.py
Original file line number Diff line number Diff line change
Expand Up @@ -477,7 +477,8 @@ async def teams_event_webhook(
.order_by(ChatMessage.created_at.desc())
.limit(ctx_size)
)
history = [{"role": m.role, "content": m.content} for m in reversed(history_r.scalars().all())]
from app.services.llm.utils import convert_chat_messages_to_llm_format as _conv
history = _conv(reversed(history_r.scalars().all()))

# Save user message
db.add(ChatMessage(agent_id=agent_id, user_id=platform_user_id, role="user", content=user_text, conversation_id=session_conv_id))
Expand Down
45 changes: 3 additions & 42 deletions backend/app/api/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from app.models.llm import LLMModel
from app.models.user import User
from app.services.llm import call_llm, call_llm_with_failover
from app.services.llm.utils import convert_chat_messages_to_llm_format, truncate_messages_with_pair_integrity

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

Expand Down Expand Up @@ -285,45 +286,7 @@ async def websocket_chat(
await websocket.send_json({"type": "connected", "session_id": conv_id})

# Build conversation context from history
conversation: list[dict] = []
for msg in history_messages:
if msg.role == "tool_call":
# Convert stored tool_call JSON into OpenAI-format assistant+tool pair
try:
import json as _j_hist
tc_data = _j_hist.loads(msg.content)
tc_name = tc_data.get("name", "unknown")
tc_args = tc_data.get("args", {})
tc_result = tc_data.get("result", "")
tc_id = f"call_{msg.id}" # synthetic tool_call_id
# Assistant message with tool_calls array
asst_msg = {
"role": "assistant",
"content": None,
"tool_calls": [{
"id": tc_id,
"type": "function",
"function": {"name": tc_name, "arguments": _j_hist.dumps(tc_args, ensure_ascii=False)},
}],
}
if tc_data.get("reasoning_content"):
asst_msg["reasoning_content"] = tc_data["reasoning_content"]
conversation.append(asst_msg)
# Tool result message.
from app.services.vision_inject import sanitize_history_tool_result
sanitized_result = sanitize_history_tool_result(str(tc_result))
conversation.append({
"role": "tool",
"tool_call_id": tc_id,
"content": sanitized_result[:500],
})
except Exception:
continue # Skip malformed tool_call records
else:
entry = {"role": msg.role, "content": msg.content}
if hasattr(msg, 'thinking') and msg.thinking:
entry["thinking"] = msg.thinking
conversation.append(entry)
conversation = convert_chat_messages_to_llm_format(history_messages)

try:
# Send welcome message on new session (no history)
Expand Down Expand Up @@ -516,9 +479,7 @@ async def _on_failover(reason: str):
await websocket.send_json({"type": "info", "content": f"Primary model error, {reason}"})

# To prevent tool call message pairs(assistant + tool) from being broken down.
_truncated = conversation[-ctx_size:]
while _truncated and _truncated[0].get("role") == "tool":
_truncated.pop(0)
_truncated = truncate_messages_with_pair_integrity(conversation, ctx_size)

return await call_llm_with_failover(
primary_model=llm_model,
Expand Down
3 changes: 2 additions & 1 deletion backend/app/api/wecom.py
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,8 @@ async def _process_wecom_text(
.order_by(ChatMessage.created_at.desc())
.limit(ctx_size)
)
history = [{"role": m.role, "content": m.content} for m in reversed(history_r.scalars().all())]
from app.services.llm.utils import convert_chat_messages_to_llm_format as _conv
history = _conv(reversed(history_r.scalars().all()))

# Save user message
db.add(ChatMessage(
Expand Down
6 changes: 2 additions & 4 deletions backend/app/services/discord_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,10 +215,8 @@ async def _handle_message(
.order_by(ChatMessage.created_at.desc())
.limit(ctx_size)
)
history = [
{"role": m.role, "content": m.content}
for m in reversed(history_r.scalars().all())
]
from app.services.llm.utils import convert_chat_messages_to_llm_format as _conv
history = _conv(reversed(history_r.scalars().all()))

# Save user message
db.add(ChatMessage(
Expand Down
77 changes: 73 additions & 4 deletions backend/app/services/llm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -657,10 +657,20 @@ def _messages_to_input(self, messages: list[LLMMessage]) -> list[dict[str, Any]]
input_items: list[dict[str, Any]] = []

for msg in messages:
if msg.role in {"system", "user", "assistant"} and msg.content is not None:
item: dict[str, Any] = {"role": msg.role}
item["content"] = self._format_content_for_input(msg.content)
input_items.append(item)
# Handle system messages with dynamic_content
if msg.role == "system" and msg.content is not None:
content = msg.content
if msg.dynamic_content:
content = f"{content}\n\n{msg.dynamic_content}"
input_items.append({
"role": msg.role,
"content": self._format_content_for_input(content),
})
elif msg.role in {"user", "assistant"} and msg.content is not None:
input_items.append({
"role": msg.role,
"content": self._format_content_for_input(msg.content),
})

if msg.role == "assistant" and msg.tool_calls:
for tc in msg.tool_calls:
Expand All @@ -682,8 +692,67 @@ def _messages_to_input(self, messages: list[LLMMessage]) -> list[dict[str, Any]]
"output": msg.content or "",
})

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

return input_items

@staticmethod
def _sanitize_input_items(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Remove orphaned function_call_output items that have no matching function_call.

Also removes function_call items whose function_call_output is missing,
since the Responses API requires complete pairs.
"""
# Collect all call_ids from function_call items
call_ids_with_fc: set[str] = set()
for item in items:
if item.get("type") == "function_call":
call_id = item.get("call_id", "")
if call_id:
call_ids_with_fc.add(call_id)

# Collect all call_ids from function_call_output items
call_ids_with_fco: set[str] = set()
for item in items:
if item.get("type") == "function_call_output":
call_id = item.get("call_id", "")
if call_id:
call_ids_with_fco.add(call_id)

# Determine which call_ids are orphaned (output without call, or call without output)
orphaned_fco = call_ids_with_fco - call_ids_with_fc
orphaned_fc = call_ids_with_fc - call_ids_with_fco

if not orphaned_fco and not orphaned_fc:
return items

if orphaned_fco:
logger.warning(
"[OpenAIResponses] Removing %d orphaned function_call_output item(s) "
"with no matching function_call: %s",
len(orphaned_fco),
orphaned_fco,
)
if orphaned_fc:
logger.warning(
"[OpenAIResponses] Removing %d orphaned function_call item(s) "
"with no matching function_call_output: %s",
len(orphaned_fc),
orphaned_fc,
)

# Filter out orphaned items
return [
item for item in items
if not (
(item.get("type") == "function_call_output" and item.get("call_id", "") in orphaned_fco)
or (item.get("type") == "function_call" and item.get("call_id", "") in orphaned_fc)
)
]

def _convert_tools(self, tools: list[dict] | None) -> list[dict] | None:
"""Convert OpenAI tool schema to Responses API function tool schema."""
if not tools:
Expand Down
Loading