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
43 changes: 40 additions & 3 deletions src/xaibo/core/models/response.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
from enum import Enum
from typing import BinaryIO, Optional, List
from typing import BinaryIO, Optional, List, Any, Dict, Literal, Union

from pydantic import BaseModel

from xaibo.core.models.llm import LLMUsage


class FileType(Enum):
Expand All @@ -19,11 +23,44 @@ def __init__(self, content: BinaryIO, type: FileType) -> None:
self.type = type


class ToolCallEvent(BaseModel):
"""A tool is about to be executed"""
type: Literal["tool_call"] = "tool_call"
id: str
name: str
arguments: Dict[str, Any]


class ToolResultEvent(BaseModel):
"""A tool execution finished"""
type: Literal["tool_result"] = "tool_result"
id: str
name: str
success: bool
result: Optional[Any] = None
error: Optional[str] = None


class UsageEvent(LLMUsage):
"""Token usage reported by an LLM call"""
type: Literal["usage"] = "usage"


ResponseEvent = Union[
ToolCallEvent,
ToolResultEvent,
UsageEvent,
]


class Response:
"""Model for responses that can include text and file attachments"""
"""Model for responses that can include text, file attachments and structured events"""
text: Optional[str] = None
attachments: List[FileAttachment] = []
events: List[ResponseEvent]

def __init__(self, text: Optional[str] = None, attachments: Optional[List[FileAttachment]] = None) -> None:
def __init__(self, text: Optional[str] = None, attachments: Optional[List[FileAttachment]] = None,
events: Optional[List[ResponseEvent]] = None) -> None:
self.text = text
self.attachments = attachments or []
self.events = events or []
14 changes: 13 additions & 1 deletion src/xaibo/core/protocols/response.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Protocol, BinaryIO, runtime_checkable

from xaibo.core.models.response import Response
from xaibo.core.models.response import Response, ResponseEvent


@runtime_checkable
Expand Down Expand Up @@ -53,4 +53,16 @@ async def respond(self, response: Response) -> None:
Args:
response: Response object containing text and attachments
"""
...

async def respond_event(self, event: ResponseEvent) -> None:
"""Report a structured event produced while generating the response.

Events carry the internals of response generation (tool calls, tool
results, usage) so that adapters can surface them instead of only
the final text.

Args:
event: The event to report
"""
...
4 changes: 4 additions & 0 deletions src/xaibo/integrations/livekit/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,10 @@ async def respond_text(self, text: str) -> None:
"""Handle streaming text chunks from the agent"""
await chunk_queue.put(text)

async def respond_event(self, event) -> None:
"""Agent internals are not forwarded to the voice pipeline"""
pass

async def get_response(self):
"""Return None as we handle streaming via respond_text"""
return None
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
from xaibo.core.protocols import TextMessageHandlerProtocol, ResponseProtocol, LLMProtocol, ToolProviderProtocol, \
ConversationHistoryProtocol
from xaibo.core.models.llm import LLMMessage, LLMOptions, LLMRole, LLMFunctionResult, LLMMessageContentType, LLMMessageContent
from xaibo.core.models.response import ToolCallEvent, ToolResultEvent, UsageEvent

import json
import logging

logger = logging.getLogger(__name__)


def _json_safe(value):
"""Coerce a tool result into JSON-serializable form"""
return json.loads(json.dumps(value, default=repr))

class SimpleToolOrchestrator(TextMessageHandlerProtocol):
"""
Expand Down Expand Up @@ -49,6 +58,14 @@ def __init__(self,
self.tool_provider: ToolProviderProtocol = tool_provider
self.history = history

async def _emit_event(self, event) -> None:
"""Send a response event if the response handler supports it."""
respond_event = getattr(self.response, "respond_event", None)
if respond_event is None:
logger.debug("Response handler does not implement respond_event; dropping %s event", event.type)
return
await respond_event(event)

async def handle_text(self, text: str) -> None:
"""
Process a user text message, potentially using tools to generate a response.
Expand Down Expand Up @@ -91,7 +108,10 @@ async def handle_text(self, text: str) -> None:
)

llm_response = await self.llm.generate(conversation, options)


if llm_response.usage:
await self._emit_event(UsageEvent(**llm_response.usage.model_dump()))

# Add assistant response to conversation
assistant_message = LLMMessage(
role=LLMRole.FUNCTION if llm_response.tool_calls else LLMRole.ASSISTANT,
Expand All @@ -111,33 +131,46 @@ async def handle_text(self, text: str) -> None:
for tool_call in llm_response.tool_calls:
tool_name = tool_call.name
tool_args = tool_call.arguments


await self._emit_event(ToolCallEvent(
id=tool_call.id,
name=tool_name,
arguments=tool_args
))

try:
tool_result = await self.tool_provider.execute_tool(tool_name, tool_args)

if tool_result.success:
tool_results.append({
"id": tool_call.id,
"name": tool_name,
"result": json.dumps(tool_result.result, default=repr)
})
else:
# Tool execution failed
tool_results.append({
"id": tool_call.id,
"name": tool_name,
"error": f"Error: {tool_result.error}"
})
stress_level += 0.1
success = tool_result.success
result = tool_result.result if success else None
error = tool_result.error if not success else None
except Exception as e:
# Handle any exceptions during tool execution
success = False
result = None
error = str(e)

if success:
tool_results.append({
"id": tool_call.id,
"name": tool_name,
"result": json.dumps(result, default=repr)
})
else:
tool_results.append({
"id": tool_call.id,
"name": tool_name,
"error": f"Error: {str(e)}"
"error": f"Error: {error}"
})
stress_level += 0.1

await self._emit_event(ToolResultEvent(
id=tool_call.id,
name=tool_name,
success=success,
result=_json_safe(result),
error=error
))

conversation.append(LLMMessage(
role=LLMRole.FUNCTION,
tool_results=[
Expand Down
17 changes: 14 additions & 3 deletions src/xaibo/primitives/modules/response.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
from typing import BinaryIO

from xaibo.core.protocols import ResponseProtocol
from xaibo.core.models import FileAttachment, FileType, Response
from xaibo.core.models import FileAttachment, FileType, Response, ResponseEvent


class ResponseHandler(ResponseProtocol):
def __init__(self, config: dict = None):
self._response = Response()

async def get_response(self) -> Response:
return self._response
# Events are turn-scoped: hand them off and reset
response = Response(
text=self._response.text,
attachments=self._response.attachments,
events=self._response.events
)
self._response.events = []
return response

async def respond_text(self, response: str) -> None:
if self._response.text is None:
Expand All @@ -32,4 +39,8 @@ async def respond(self, response: Response) -> None:
self._response.text = response.text
else:
self._response.text += response.text
self._response.attachments.extend(response.attachments)
self._response.attachments.extend(response.attachments)
self._response.events.extend(response.events)

async def respond_event(self, event: ResponseEvent) -> None:
self._response.events.append(event)
4 changes: 4 additions & 0 deletions src/xaibo/server/adapters/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,10 @@ async def respond_text(self, text: str) -> None:
response = create_chunk_response({"content": text})
await queue.put(f"data: {json.dumps(response)}\n\n")

async def respond_event(self, event) -> None:
# The OpenAI chat completion grammar has no lane for agent internals
pass

async def get_response(self):
return None

Expand Down
4 changes: 4 additions & 0 deletions src/xaibo/server/adapters/openai_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,10 @@ async def respond_text(self, text: str) -> None:
await queue.put(f"data: {json.dumps(event_data)}\n\n")
self.accumulated_text += text

async def respond_event(self, event) -> None:
# Agent internals are not part of the responses API surface yet
pass

async def get_response(self):
return None

Expand Down
Loading
Loading