diff --git a/astra/core.py b/astra/core.py index d4295da..59f895b 100644 --- a/astra/core.py +++ b/astra/core.py @@ -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, diff --git a/astra/server.py b/astra/server.py index 4b3ec0a..a131b40 100644 --- a/astra/server.py +++ b/astra/server.py @@ -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, } ) diff --git a/tests/test_core.py b/tests/test_core.py index 460ee2a..16befdd 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -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"},