Skip to content

Support native function-calling serialization in messages.py to remove raw dicts in AgentBot #275

@eric-ormoni

Description

@eric-ormoni

Summary

AgentBot currently constructs OpenAI/LiteLLM function-calling payloads using raw dicts rather than messages.py classes. This is because our message classes do not serialize the provider-required fields (assistant.tool_calls and tool.tool_call_id). This gap led to OpenAI 400 errors (“messages with role 'tool' must be a response to a preceding message with 'tool_calls'”) and forces AgentBot to bypass typed messages when interacting with providers.

Impact

  • Forces AgentBot to build raw message dicts to satisfy function-calling protocol.
  • Causes provider errors if the tool_call protocol isn’t precisely followed.
  • Spreads message serialization logic outside messages.py, making maintenance harder.

Error example

OpenAI 400:

Invalid parameter: messages with role 'tool' must be a response to a preceeding message with 'tool_calls'.

Root cause

  • AIMessage doesn’t serialize tool_calls into the OpenAI/LiteLLM shape:
    [{ id, type: 'function', function: { name, arguments } }]
  • ToolMessage lacks tool_call_id, so we cannot emit:
    { role: 'tool', content: '...', tool_call_id: '<assistant.tool_calls[i].id>' }
  • SimpleBot.make_response filters messages down to only role and content, stripping function-calling metadata.

Proposed changes

  1. Extend ToolMessage to include tool_call_id and preserve it in serialization.
class ToolMessage(BaseMessage):
    content: str
    role: str = "tool"
    tool_call_id: str | None = None

    def model_dump(self):
        d = super().model_dump()
        if self.tool_call_id is not None:
            d["tool_call_id"] = self.tool_call_id
        return d
  1. Teach AIMessage to include tool_calls in model_dump() using the provider’s expected shape.
class AIMessage(BaseMessage):
    content: str
    role: str = "assistant"

    def model_dump(self):
        d = {"role": "assistant", "content": self.content}
        if self.tool_calls:
            def _tc(tc):
                return {
                    "id": getattr(tc, "id", None),
                    "type": "function",
                    "function": {
                        "name": tc.function.name,
                        "arguments": tc.function.arguments,
                    },
                }
            d["tool_calls"] = [_tc(tc) for tc in self.tool_calls]
        return d
  1. Stop stripping fields in SimpleBot.make_response.
  • Replace:
messages_dumped = [
    {k: v for k, v in m.model_dump().items() if k in ("role", "content")}
    for m in messages
]
  • With:
messages_dumped = [m.model_dump() for m in messages]
  1. Use messages.py classes end-to-end in AgentBot.
  • When the model returns assistant tool_calls, append an AIMessage with those tool_calls.
  • For tool results, append a ToolMessage(content=str(result), tool_call_id=<assistant_call.id>).
  • Build outbound payloads exclusively via message.model_dump().

Acceptance criteria

  • AgentBot uses only messages.py classes to drive function-calling (no raw dicts).
  • OpenAI/OpenRouter/Groq/Ollama tool-calling works without 400 errors.
  • Tests cover presence of assistant.tool_calls and matching tool.tool_call_id.

Nice-to-haves

  • Helper to convert LiteLLM ChatCompletionMessageToolCall into serialized dicts.
  • Docs note on function-calling protocol and message serialization.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions