This commit is contained in:
Timothy Jaeryang Baek
2026-08-23 02:34:08 -04:00
parent 4807866a1c
commit f3f76095d1
4 changed files with 183 additions and 40 deletions
+80 -39
View File
@@ -142,6 +142,48 @@ logging.basicConfig(stream=sys.stdout, level=GLOBAL_LOG_LEVEL)
log = logging.getLogger(__name__)
def _is_tool_result_error(value: Any) -> bool:
if isinstance(value, str):
text = value.strip().lower()
if (
text.startswith('error:')
or text.startswith('exception:')
or text.startswith('traceback')
or text.startswith('http error!')
):
return True
parsed = value
while isinstance(parsed, str):
try:
parsed = JSONCodec.loads(parsed)
except (JSONCodec.JSONDecodeError, TypeError, ValueError):
break
if not isinstance(parsed, dict):
return False
error = parsed.get('error')
if isinstance(error, str):
has_error = bool(error.strip())
else:
has_error = isinstance(error, (dict, list)) and bool(error)
if has_error:
return True
status = parsed.get('status')
if isinstance(status, str) and status.strip().lower() in {'error', 'failed'}:
return True
if parsed.get('success') is False or parsed.get('ok') is False:
message = parsed.get('message')
return has_error or (
bool(message.strip()) if isinstance(message, str) else isinstance(message, (dict, list)) and bool(message)
)
return False
async def publish_chat_finished_event(
request: Request, user: UserModel, metadata: dict, title: str, content: str, output: list | None = None
):
@@ -1299,7 +1341,7 @@ async def chat_completion_tools_handler(
tool_result = await tool_function(**tool_function_params)
except Exception as e:
tool_result = str(e)
tool_result = {'error': str(e)}
tool_result, tool_result_files, tool_result_embeds = await process_tool_result(
request,
@@ -3076,7 +3118,7 @@ async def execute_tool_call_for_output(request, form_data, user, metadata, event
)
result = await function(**params)
except Exception as e:
result = str(e)
result = {'error': str(e)}
result, files, embeds = await process_tool_result(
request,
@@ -3098,28 +3140,6 @@ async def execute_tool_call_for_output(request, form_data, user, metadata, event
}
def append_tool_result_output(output: list[dict], result: dict) -> None:
output_parts = [{'type': 'input_text', 'text': result.get('content', '')}]
display_files = []
for file_item in result.get('files', []):
if file_item.get('type') == 'image' and file_item.get('url', '').startswith('data:'):
output_parts.append({'type': 'input_image', 'image_url': file_item['url']})
else:
display_files.append(file_item)
output.append(
{
'type': 'function_call_output',
'id': output_id('fco'),
'call_id': result.get('tool_call_id', ''),
'output': output_parts,
'status': 'completed',
**({'files': display_files} if display_files else {}),
**({'embeds': result.get('embeds')} if result.get('embeds') else {}),
}
)
async def drain_approved_tool_calls(request, form_data, user, model, metadata) -> bool:
chat_id = metadata.get('chat_id')
message_id = metadata.get('message_id') or metadata.get('assistant_message_id')
@@ -3186,9 +3206,27 @@ async def drain_approved_tool_calls(request, form_data, user, model, metadata) -
event_emitter,
tool_call,
)
item['status'] = 'completed'
item['arguments'] = tool_call.get('function', {}).get('arguments', '{}')
append_tool_result_output(output, result)
output_parts = [{'type': 'input_text', 'text': result.get('content', '')}]
item['status'] = 'failed' if _is_tool_result_error(result.get('content', '')) else 'completed'
display_files = []
for file_item in result.get('files', []):
if file_item.get('type') == 'image' and file_item.get('url', '').startswith('data:'):
output_parts.append({'type': 'input_image', 'image_url': file_item['url']})
else:
display_files.append(file_item)
output.append(
{
'type': 'function_call_output',
'id': output_id('fco'),
'call_id': result.get('tool_call_id', ''),
'output': output_parts,
'status': item['status'],
**({'files': display_files} if display_files else {}),
**({'embeds': result.get('embeds')} if result.get('embeds') else {}),
}
)
changed = True
if changed:
@@ -5489,7 +5527,7 @@ async def streaming_chat_response_handler(response, ctx):
)
result = await function(**params)
except Exception as e:
result = str(e)
result = {'error': str(e)}
return params, result, tool, tool_type, direct_tool
delegate_calls = [
@@ -5575,19 +5613,13 @@ async def streaming_chat_response_handler(response, ctx):
}
)
# Update function_call statuses and append function_call_output items
for tc in response_tool_calls:
call_id = tc.get('id', '')
# Mark function_call as completed
for item in output:
if item.get('type') == 'function_call' and item.get('call_id') == call_id:
item['status'] = 'completed'
# Update arguments with parsed/sanitized version
item['arguments'] = tc.get('function', {}).get('arguments', '{}')
break
result_status_by_call_id = {}
for result in results:
output_parts = [{'type': 'input_text', 'text': result.get('content', '')}]
local_output_status = (
'failed' if _is_tool_result_error(result.get('content', '')) else 'completed'
)
result_status_by_call_id[result.get('tool_call_id', '')] = local_output_status
# Separate image data URIs (for LLM via input_image) from
# other files (for frontend display via files attribute).
@@ -5606,12 +5638,21 @@ async def streaming_chat_response_handler(response, ctx):
'id': output_id('fco'),
'call_id': result.get('tool_call_id', ''),
'output': output_parts,
'status': 'completed',
'status': local_output_status,
**({'files': display_files} if display_files else {}),
**({'embeds': result.get('embeds')} if result.get('embeds') else {}),
}
)
# Update function_call statuses and parsed/sanitized arguments.
for tc in response_tool_calls:
call_id = tc.get('id', '')
for item in output:
if item.get('type') == 'function_call' and item.get('call_id') == call_id:
item['status'] = result_status_by_call_id.get(call_id, 'completed')
item['arguments'] = tc.get('function', {}).get('arguments', '{}')
break
# Append a new empty message item for the next response
output.append(
{
+1 -1
View File
@@ -312,7 +312,7 @@ def convert_output_to_messages(
for item in output
if item.get('type') == 'function_call'
and item.get('call_id')
and item.get('status') in {'completed', 'rejected'}
and item.get('status') in {'completed', 'failed', 'rejected'}
}
result_call_ids = {
item.get('call_id') for item in output if item.get('type') == 'function_call_output' and item.get('call_id')
@@ -21,6 +21,7 @@
export let id = '';
export let tokens: Array<{
summary?: string;
text?: string;
attributes?: {
type?: string;
name?: string;
@@ -50,6 +51,49 @@
}
}
function isToolResultError(value: unknown): boolean {
if (typeof value === 'string') {
const text = value.trim().toLowerCase();
if (
text.startsWith('error:') ||
text.startsWith('exception:') ||
text.startsWith('traceback') ||
text.startsWith('http error!')
) {
return true;
}
}
let parsed = value;
while (typeof parsed === 'string') {
try {
parsed = JSON.parse(parsed);
} catch {
break;
}
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return false;
const result = parsed as Record<string, unknown>;
const error = result.error;
if (
(typeof error === 'string' && error.trim().length > 0) ||
(typeof error === 'object' && error !== null)
) {
return true;
}
const status = typeof result.status === 'string' ? result.status.trim().toLowerCase() : '';
if (status === 'error' || status === 'failed') return true;
const message = result.message;
return (
(result.success === false || result.ok === false) &&
((typeof message === 'string' && message.trim().length > 0) ||
(typeof message === 'object' && message !== null))
);
}
$: toolCallCount = tokens.filter((t) => t?.attributes?.type === 'tool_calls').length;
$: reasoningCount = tokens.filter((t) => t?.attributes?.type === 'reasoning').length;
$: pendingToolTokens = tokens.filter(
@@ -66,6 +110,12 @@
$: hasRejected = tokens.some(
(t) => t?.attributes?.type === 'tool_calls' && t?.attributes?.status === 'rejected'
);
$: hasError = tokens.some(
(t) =>
t?.attributes?.type === 'tool_calls' &&
(t?.attributes?.status === 'failed' ||
(t?.attributes?.done === 'true' && isToolResultError(decode(t?.text ?? ''))))
);
$: codeInterpreterCount = tokens.filter((t) => t?.attributes?.type === 'code_interpreter').length;
@@ -157,6 +207,10 @@
<div class="text-red-400 dark:text-red-500">
<XMark className="size-4" strokeWidth="2.5" />
</div>
{:else if toolCallCount > 0 && hasError}
<div class="text-red-500 dark:text-red-400">
<XMark className="size-4" strokeWidth="2.5" />
</div>
{:else if toolCallCount > 0}
<div class="text-emerald-500 dark:text-emerald-400">
<CheckCircle className="size-4" strokeWidth="2" />
@@ -90,6 +90,49 @@
}
}
function isToolResultError(value: unknown): boolean {
if (typeof value === 'string') {
const text = value.trim().toLowerCase();
if (
text.startsWith('error:') ||
text.startsWith('exception:') ||
text.startsWith('traceback') ||
text.startsWith('http error!')
) {
return true;
}
}
let parsed = value;
while (typeof parsed === 'string') {
try {
parsed = JSON.parse(parsed);
} catch {
break;
}
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return false;
const result = parsed as Record<string, unknown>;
const error = result.error;
if (
(typeof error === 'string' && error.trim().length > 0) ||
(typeof error === 'object' && error !== null)
) {
return true;
}
const status = typeof result.status === 'string' ? result.status.trim().toLowerCase() : '';
if (status === 'error' || status === 'failed') return true;
const message = result.message;
return (
(result.success === false || result.ok === false) &&
((typeof message === 'string' && message.trim().length > 0) ||
(typeof message === 'object' && message !== null))
);
}
export let resultContent: string = '';
$: result = resultContent || decode(attributes?.result ?? '');
@@ -110,6 +153,7 @@
$: isExecuting = !isDone && !isRejected && attributes?.status === 'completed';
$: isPreparing = !isDone && !isRejected && !needsApproval && !needsInput && !isExecuting;
$: isActive = isPreparing || isExecuting;
$: isError = attributes?.status === 'failed' || (isDone && isToolResultError(result));
$: parsedArgs = parseArguments(args);
$: parsedResult = parseJSONString(result);
@@ -171,6 +215,10 @@
<div class="text-red-400 dark:text-red-500">
<XMark className="size-4" strokeWidth="2.5" />
</div>
{:else if isError}
<div class="text-red-500 dark:text-red-400">
<XMark className="size-4" strokeWidth="2.5" />
</div>
{:else if isDone}
<div class="text-emerald-500 dark:text-emerald-400">
<CheckCircle className="size-4" strokeWidth="2" />