mirror of
https://github.com/Jeuners/astra-vision.git
synced 2026-09-09 15:02:35 +02:00
fix: 400 from Ollama after a tool call, and silent interruptions
Pipecat stores a tool call's assistant message OpenAI-style, with
function.arguments as a JSON string. Ollama's native /api/chat wants an
object there and rejects the follow-up request with 400 ("Value looks
like object, but can't find closing '}' symbol") — reproduced verbatim
against real Ollama, then fixed with astra/core.py::normalize_tool_calls,
applied in build_request. Verified end-to-end with a real browser test
(synthesized speech as the fake mic): "erzeuge ein Bild von einer Katze
im Weltraum" now completes the full round trip — tool call, ComfyUI
image, and Astra's follow-up reply — with no error.
Also: an assistant turn interrupted before producing any text showed
nothing at all in the transcript. astra/server.py's assistant_turn
handler now notifies on interruption even with empty content, so an
early interruption (e.g. mic picking up noise during the slightly
longer vision-augmented responses) is visible instead of silent.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVgSHNHdRx3UNTBodFmhRA
This commit is contained in:
parent
9dcfa7d9f5
commit
f8963ebc79
3 changed files with 78 additions and 4 deletions
|
|
@ -1,5 +1,6 @@
|
|||
"""Configuration and pure request policy, independent of audio hardware."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
|
@ -106,10 +107,36 @@ def trim_messages(messages: list[dict], max_chars: int = 10000) -> list[dict]:
|
|||
return system + turns
|
||||
|
||||
|
||||
def normalize_tool_calls(messages: list[dict]) -> list[dict]:
|
||||
"""Ollama's native /api/chat rejects tool_calls whose function.arguments
|
||||
is a JSON string (400 "Value looks like object..."); it wants an object.
|
||||
Pipecat's context stores tool_calls OpenAI-style, arguments as a string,
|
||||
so every request has to convert it back before it reaches Ollama.
|
||||
"""
|
||||
normalized = []
|
||||
for message in messages:
|
||||
tool_calls = message.get("tool_calls")
|
||||
if not tool_calls:
|
||||
normalized.append(message)
|
||||
continue
|
||||
new_calls = []
|
||||
for call in tool_calls:
|
||||
function = call.get("function", {})
|
||||
arguments = function.get("arguments")
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
arguments = json.loads(arguments)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
new_calls.append({**call, "function": {**function, "arguments": arguments}})
|
||||
normalized.append({**message, "tool_calls": new_calls})
|
||||
return normalized
|
||||
|
||||
|
||||
def build_request(settings: Settings, messages: list[dict]) -> dict:
|
||||
return {
|
||||
"model": settings.model,
|
||||
"messages": trim_messages(messages),
|
||||
"messages": normalize_tool_calls(trim_messages(messages)),
|
||||
"think": False,
|
||||
"stream": True,
|
||||
"keep_alive": -1,
|
||||
|
|
|
|||
|
|
@ -158,12 +158,12 @@ async def run_voice(connection, models, config, voice_state, voice_name, context
|
|||
|
||||
@aggregators.assistant().event_handler("on_assistant_turn_stopped")
|
||||
async def assistant_turn(aggregator, message):
|
||||
if message.content:
|
||||
if message.content or message.interrupted:
|
||||
notify(
|
||||
{
|
||||
"type": "transcript",
|
||||
"role": "assistant",
|
||||
"text": message.content,
|
||||
"text": message.content or "…",
|
||||
"interrupted": message.interrupted,
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import unittest
|
||||
|
||||
from astra.core import Settings, build_request, local_origin_allowed, trim_messages
|
||||
from astra.core import (
|
||||
Settings,
|
||||
build_request,
|
||||
local_origin_allowed,
|
||||
normalize_tool_calls,
|
||||
trim_messages,
|
||||
)
|
||||
|
||||
|
||||
class CoreTests(unittest.TestCase):
|
||||
|
|
@ -64,6 +70,47 @@ class CoreTests(unittest.TestCase):
|
|||
self.assertEqual(trimmed[2]["tool_calls"][0]["function"]["name"], "generate_image")
|
||||
self.assertEqual(trimmed[3]["tool_call_id"], "call_1")
|
||||
|
||||
def test_tool_call_string_arguments_are_normalized_to_an_object(self):
|
||||
messages = [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "generate_image", "arguments": '{"prompt": "a cat"}'},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
normalized = normalize_tool_calls(messages)
|
||||
self.assertEqual(
|
||||
normalized[0]["tool_calls"][0]["function"]["arguments"], {"prompt": "a cat"}
|
||||
)
|
||||
# The original message is untouched (immutable transform).
|
||||
self.assertIsInstance(messages[0]["tool_calls"][0]["function"]["arguments"], str)
|
||||
|
||||
def test_build_request_normalizes_tool_call_arguments_end_to_end(self):
|
||||
messages = [
|
||||
{"role": "user", "content": "Erzeuge ein Bild."},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "generate_image", "arguments": '{"prompt": "a cat"}'},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": '{"status": "ok"}'},
|
||||
]
|
||||
request = build_request(Settings(), messages)
|
||||
tool_call_message = next(m for m in request["messages"] if m.get("tool_calls"))
|
||||
self.assertIsInstance(tool_call_message["tool_calls"][0]["function"]["arguments"], dict)
|
||||
|
||||
def test_image_attachment_survives_trimming(self):
|
||||
messages = [
|
||||
{"role": "system", "content": "Deutsch"},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue