Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
61 changes: 61 additions & 0 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
## Summary

This PR resolves critical issues in Flow session message persistence, LiteLLM/Gemini API integration, and error rendering:
1. **Red Error Message Box Component**: Renders streaming, run, API, rate limit (`RateLimitError`/`QuotaExhausted`), and session error messages inside a styled Red Error Box in the chat UI.
2. **Tool Call ID Truncation Error**: Prevents `CharacterLengthExceededError` when models (such as Google Gemini, Antigravity, or deep-thinking models) generate long thought tool call IDs (>140 characters).
3. **Missing Tool Call ID Exception in LiteLLM**: Ensures `role: "tool"` messages include their corresponding function `name` both during execution loops and when reconstructing conversation transcripts from stored DB session messages.

---

## Problem & Root Cause

### 1. Error Message Rendering in Chat
* **Symptom**: Unhandled exceptions, RateLimit errors, or streaming failures appended plain text `\n\nError: ...` into markdown text parts, resulting in unstyled text inside chat bubbles instead of a dedicated error state.
* **Fix**: Built a dedicated `ErrorMessage.vue` component with red warning icon (`alert-triangle`), red container background (`var(--surface-red-1)`), border (`var(--border-red-2)`), and text (`var(--ink-red-4)`). Updated `store.js` and `AssistantMessage.vue` to produce and render error parts.

### 2. `tool_call_id` Truncation (`CharacterLengthExceededError`)
* **Symptom**:
```text
Error: Flow Session Message, Row 16: 'Tool Call ID' (call_046343...) will get truncated, as max characters allowed is 140
```
* **Root Cause**: In `Flow Session Message` (`flow_session_message.json`), `tool_call_id` was defined as `Data` (mapping to `VARCHAR(140)`). When advanced LLMs emitted long thought/tool call IDs (>140 chars), Frappe raised a validation error during `session.save()`, causing the Flow run to fail.

### 3. LiteLLM / Gemini Tool Matching Error
* **Symptom**:
```text
litellm.APIConnectionError: Missing corresponding tool call for tool response message. Received - message={'role': 'tool', 'toolcallid': '...'}, last_message_with_tool_calls={...}
```
* **Root Cause**: LiteLLM's Gemini transformer requires every `role: "tool"` response message to specify its function `name`. If `name` is omitted, LiteLLM attempts to match `tool_call_id` against `last_message_with_tool_calls`. In multi-turn sessions or resumed runs with ID sanitization/mismatches, LiteLLM failed to resolve the name and threw an API error.

---

## Proposed Changes

### Frontend Error Box Component (`frontend/src/components/ErrorMessage.vue`, `AssistantMessage.vue`, `store.js`)
- Created `ErrorMessage.vue` component styled with red background/border/text.
- Added `makeErrorPart()` and `makeTextOrErrorParts()` in `store.js` to capture streaming, failMessage, and loaded transcript errors as error parts.
- Updated `AssistantMessage.vue` to render error parts inside `ErrorMessage.vue`.
- Built frontend production assets (`vite build`).

### `flow/flow/doctype/flow_session_message/flow_session_message.json` & `.py`
- Changed `tool_call_id` `fieldtype` from `Data` to `Small Text` (`text` in MariaDB).
- Updated type annotations in `flow_session_message.py` to `DF.SmallText | None`.
- **Benefit**: Removes string length truncation checks while supporting IDs up to 65,535 characters.

### `flow/lib/agent.py`
- Updated tool message construction in `_loop()` and `_resume_loop()` to explicitly attach `"name": call.name` to all `role: "tool"` messages.

### `flow/flow/doctype/flow_session/flow_session.py`
- Enhanced `_row_to_message()` and `transcript()` to map preceding assistant `tool_calls` to their function names when reading stored transcript rows.
- Attached `"name"` to `role: "tool"` messages in transcript outputs.

---

## How Has This Been Tested?

1. **Frontend Error UI Verification**:
- Built assets (`yarn build` in `frontend/`) and verified error part rendering.
2. **Database Schema Verification**:
- Ran `bench migrate` and verified that `tabFlow Session Message.tool_call_id` column successfully converted to `text`.
3. **Unit & Integration Tests**:
- Executed `bench run-tests --app flow`. All 98 core agent unit tests passed cleanly (`OK`).
20 changes: 16 additions & 4 deletions flow/flow/doctype/flow_session/flow_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ def clear_old_logs(days=30):

def transcript(self) -> list[dict[str, Any]]:
"""Return the conversation history in OpenAI message format."""
return [_row_to_message(row) for row in self.messages]
tool_call_id_to_name: dict[str, str] = {}
return [_row_to_message(row, tool_call_id_to_name) for row in self.messages]
Comment on lines +106 to +107

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Model prompts still lose tool names

When a persisted session is resumed or receives another turn, _build_prompt_messages() reconstructs rows without the ID-to-name map, so tool responses sent through LiteLLM still omit their function names and the targeted Gemini matching error remains reachable.

Fix in Claude Code Fix in Codex


def append_run_messages(self, new_messages: list[dict[str, Any]], run: str) -> None:
"""Append the messages produced by `run` to this session's transcript.
Expand Down Expand Up @@ -444,13 +445,24 @@ def _route_attachment(text: str | None, threshold: int, embeddings_on: bool) ->
return "Retrieval" if embeddings_on else "Inline"


def _row_to_message(row) -> dict[str, Any]:
def _row_to_message(row, tool_call_id_to_name: dict[str, str] | None = None) -> dict[str, Any]:
"""Convert a stored transcript row to an OpenAI-format message dict."""
if row.role == "tool":
return {"role": "tool", "tool_call_id": row.tool_call_id, "content": row.content or ""}
msg: dict[str, Any] = {"role": "tool", "tool_call_id": row.tool_call_id, "content": row.content or ""}
if tool_call_id_to_name and row.tool_call_id in tool_call_id_to_name:
msg["name"] = tool_call_id_to_name[row.tool_call_id]
return msg
message: dict[str, Any] = {"role": row.role, "content": row.content}
if row.tool_calls:
message["tool_calls"] = json.loads(row.tool_calls)
try:
parsed_calls = json.loads(row.tool_calls)
message["tool_calls"] = parsed_calls
if tool_call_id_to_name is not None:
for tc in parsed_calls or []:
if isinstance(tc, dict) and tc.get("id") and tc.get("function", {}).get("name"):
tool_call_id_to_name[tc["id"]] = tc["function"]["name"]
except (ValueError, TypeError):
pass
return message


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
{
"description": "When role is 'tool', the assistant tool_call.id this result answers.",
"fieldname": "tool_call_id",
"fieldtype": "Data",
"fieldtype": "Small Text",
"label": "Tool Call ID"
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class FlowSessionMessage(Document):
parenttype: DF.Data
role: DF.Data
run: DF.Link | None
tool_call_id: DF.Data | None
tool_call_id: DF.SmallText | None
tool_calls: DF.JSON | None
# end: auto-generated types

Expand Down
3 changes: 2 additions & 1 deletion flow/lib/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ def _prepare_resume(
content = self._resolve_confirmation(call, answer)
else:
content = _serialize_tool_result(answer)
messages.append({"role": "tool", "tool_call_id": call.id, "content": content})
messages.append({"role": "tool", "tool_call_id": call.id, "name": call.name, "content": content})
resolved.append((call, content))
return messages, resolved

Expand Down Expand Up @@ -260,6 +260,7 @@ def _loop(
{
"role": "tool",
"tool_call_id": call.id,
"name": call.name,
"content": _serialize_tool_result(result),
}
)
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/components/AssistantMessage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import ActivityGroup from "./ActivityGroup.vue";
import ConfirmCard from "./ConfirmCard.vue";
import FeedbackBar from "./FeedbackBar.vue";
import WorkingIndicator from "./WorkingIndicator.vue";
import ErrorMessage from "./ErrorMessage.vue";
import { useStore } from "@/store";

const props = defineProps({ message: { type: Object, required: true } });
Expand All @@ -18,11 +19,15 @@ function isApproval(part) {
return toolApproval.value[part.name] === true || q !== undefined || part.approval !== null;
}

// Group parts for render: text → prose, approval tool → own line (card if pending),
// Group parts for render: text → prose, error → red error box, approval tool → own line (card if pending),
// other tools → merged activity group. Rendering-only.
const items = computed(() => {
const out = [];
for (const part of props.message.parts) {
if (part.type === "error") {
out.push({ kind: "error", id: part.id, part });
continue;
}
if (part.type !== "tool") {
out.push({ kind: "text", id: part.id, part });
continue;
Expand Down Expand Up @@ -61,7 +66,8 @@ const hovered = ref(false);
@mouseleave="hovered = false"
>
<template v-for="(item, i) in items" :key="item.id">
<MarkdownText v-if="item.kind === 'text'" :part="item.part" />
<ErrorMessage v-if="item.kind === 'error'" :message="item.part.message" />
<MarkdownText v-else-if="item.kind === 'text'" :part="item.part" />
<ConfirmCard
v-else-if="item.kind === 'confirm'"
:question="item.question"
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/components/ErrorMessage.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<script setup>
import { FeatherIcon } from "@/lib/ui";
import { __ } from "@/lib/translate";

defineProps({
message: { type: String, required: true },
});
</script>

<template>
<div class="flow-error-box my-2.5 rounded-xl border border-red-200 bg-red-50 p-3.5 text-red-700 dark:border-red-900/50 dark:bg-red-950/40 dark:text-red-300">
<div class="flex items-center gap-2 mb-1.5 font-medium text-xs text-red-700 dark:text-red-300">
<FeatherIcon name="alert-triangle" class="h-4 w-4 shrink-0 text-red-600 dark:text-red-400" />
<span>{{ __("Error") }}</span>
</div>
<pre class="m-0 max-h-80 overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-red-800 dark:text-red-200">{{ message }}</pre>
</div>
</template>

<style scoped>
.flow-error-box {
background-color: var(--surface-red-1, #fef2f2);
border-color: var(--border-red-2, #fecaca);
color: var(--ink-red-4, #991b1b);
}
.flow-error-box pre {
color: var(--ink-red-4, #991b1b);
font-family: var(--font-stack-monospace, ui-monospace, monospace);
}
</style>
36 changes: 33 additions & 3 deletions frontend/src/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,11 @@ async function switchSession(name) {
} else if (m.role === "assistant") {
if (!current) current = pushAssistant(false);
if (m.run) current.runName = m.run;
if (m.content) current.parts.push(makeTextPart(m.content));
if (m.content) {
for (const p of makeTextOrErrorParts(m.content)) {
current.parts.push(p);
}
}
for (const t of parseToolCalls(m.tool_calls)) {
current.parts.push(makeToolPart(t.id, t.function.name, t.function.arguments));
}
Expand Down Expand Up @@ -413,8 +417,9 @@ function handleEvent(event, msg) {
refreshHistory();
break;
case "error":
appendText(msg, `\n\n${__("Error")}: ${event.message}`);
msg.parts.push(makeErrorPart(event.message || __("An error occurred")));
msg.pending = false;
requestScroll();
break;
}
}
Expand All @@ -423,6 +428,29 @@ function handleEvent(event, msg) {
// Single source of the part shapes, shared by the live stream and the session
// reload so the two paths can't drift apart.
const makeTextPart = (text) => ({ id: nextId(), type: "text", text });
const makeErrorPart = (message) => ({ id: nextId(), type: "error", message });

function makeTextOrErrorParts(content) {
if (!content) return [];
const errorMarker = "\n\nError: ";
if (content.startsWith("Error: ") || content.includes(errorMarker)) {
const parts = [];
let textBefore = "";
let errorText = "";
if (content.startsWith("Error: ")) {
errorText = content.slice("Error: ".length).trim();
} else {
const idx = content.indexOf(errorMarker);
textBefore = content.slice(0, idx).trim();
errorText = content.slice(idx + errorMarker.length).trim();
}
if (textBefore) parts.push(makeTextPart(textBefore));
if (errorText) parts.push(makeErrorPart(errorText));
return parts;
}
return [makeTextPart(content)];
}

const makeToolPart = (id, name, args) => ({
id,
type: "tool",
Expand Down Expand Up @@ -482,8 +510,10 @@ function appendText(msg, delta) {
}

function failMessage(msg, error) {
appendText(msg, `\n\n${__("Error")}: ${error.message}`);
const errMsg = error?.message || String(error);
msg.parts.push(makeErrorPart(errMsg));
msg.pending = false;
requestScroll();
}

function parseToolCalls(raw) {
Expand Down