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
80 changes: 78 additions & 2 deletions pkg/session/session.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package session

import (
"fmt"
"log/slog"
"os"
"strings"
Expand Down Expand Up @@ -628,12 +629,53 @@ func (s *Session) GetMessages(a *agent.Agent) []chat.Message {

startIndex := lastSummaryIndex + 1

// Collect tool call IDs from other agents' assistant messages so we can
// convert both the calls and their corresponding tool-role responses into
// narrative user messages. This prevents the current agent's LLM from seeing
// other agents' tool usage as structured tool calls it could mimic.
// Inspired by Google ADK's approach: tool calls become plain text attributed
// to the originating agent, presented as "user" role context.
otherAgentToolCallIDs := make(map[string]bool)
for i := startIndex; i < len(s.Messages); i++ {
item := s.Messages[i]
if !item.IsMessage() {
continue
}
msg := item.Message
if msg.Message.Role == chat.MessageRoleAssistant && msg.AgentName != "" && msg.AgentName != a.Name() {
for _, tc := range msg.Message.ToolCalls {
otherAgentToolCallIDs[tc.ID] = true
}
}
}

// Begin adding conversation messages
for i := startIndex; i < len(s.Messages); i++ {
item := s.Messages[i]
if item.IsMessage() {
messages = append(messages, item.Message.Message)
if !item.IsMessage() {
continue
}

msg := item.Message.Message

// Convert tool responses from other agents into narrative user messages
if msg.Role == chat.MessageRoleTool && otherAgentToolCallIDs[msg.ToolCallID] {
if msg.Content != "" {
messages = append(messages, presentOtherAgentToolResponse(msg))
}
continue
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential issue: Tool call ID collision could filter legitimate responses

The code collects tool call IDs from other agents (lines 634-647) and then filters out any tool responses matching those IDs. However, if two agents generate the same tool call ID (e.g., OpenAI's short ID format could theoretically produce duplicate IDs across different API calls), this could incorrectly filter out legitimate tool responses from the current agent's previous turns.

Scenario:

  1. Agent A creates tool call with ID "call_abc123" and receives response
  2. Later, Agent B creates a tool call that also gets ID "call_abc123" (collision)
  3. When preparing messages for Agent A again, the tool response from step 1 would be incorrectly filtered out

Suggested fix:
Consider tracking tool call ownership with tuples like map[string]string mapping toolCallID -> agentName, or storing (agentName, toolCallID) pairs in a struct set. This would ensure that only tool responses actually belonging to other agents are filtered.

While ID collisions are rare in practice, this could cause confusing behavior in long-running multi-agent conversations.


// Convert other agents' assistant messages into narrative user messages
// so the current agent sees what happened without structured tool calls
if msg.Role == chat.MessageRoleAssistant && item.Message.AgentName != "" && item.Message.AgentName != a.Name() {
if converted := presentOtherAgentMessage(item.Message.AgentName, msg); converted != nil {
messages = append(messages, *converted)
}
continue
}

messages = append(messages, msg)
}

maxItems := a.NumHistoryItems()
Expand Down Expand Up @@ -664,6 +706,40 @@ func (s *Session) GetMessages(a *agent.Agent) []chat.Message {
return messages
}

// presentOtherAgentMessage converts another agent's assistant message into a
// narrative user-role message. Tool calls become plain text descriptions so the
// current agent sees what happened without structured tool call objects it could
// mimic. Returns nil if the message has no meaningful content after conversion.
func presentOtherAgentMessage(agentName string, msg chat.Message) *chat.Message {
var parts []string

if msg.Content != "" {
parts = append(parts, fmt.Sprintf("[%s] said: %s", agentName, msg.Content))
}

for _, tc := range msg.ToolCalls {
parts = append(parts, fmt.Sprintf("[%s] called tool `%s` with parameters: %s", agentName, tc.Function.Name, tc.Function.Arguments))
}

if len(parts) == 0 {
return nil
}

return &chat.Message{
Role: chat.MessageRoleUser,
Content: "For context:\n" + strings.Join(parts, "\n"),
}
}

// presentOtherAgentToolResponse converts a tool-role response from another
// agent's tool call into a narrative user-role message with the tool result.
func presentOtherAgentToolResponse(msg chat.Message) chat.Message {
return chat.Message{
Role: chat.MessageRoleUser,
Content: fmt.Sprintf("For context:\nTool returned result: %s", msg.Content),
}
}

// trimMessages ensures we don't exceed the maximum number of messages while maintaining
// consistency between assistant messages and their tool call results.
// System messages are always preserved and not counted against the limit.
Expand Down
176 changes: 176 additions & 0 deletions pkg/session/session_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,182 @@ func TestGetMessagesWithToolCalls(t *testing.T) {
}
}

func TestGetMessages_OtherAgentToolCallsConvertedToNarrative(t *testing.T) {
currentAgent := agent.New("current-agent", "you are the current agent")
otherAgent := agent.New("other-agent", "you are the other agent")

s := New()

// User asks something
s.AddMessage(NewAgentMessage(currentAgent, &chat.Message{
Role: chat.MessageRoleUser,
Content: "find info about Go",
}))

// Other agent responds with text + tool call
s.AddMessage(NewAgentMessage(otherAgent, &chat.Message{
Role: chat.MessageRoleAssistant,
Content: "Let me search for that",
ToolCalls: []tools.ToolCall{
{
ID: "call-123",
Type: "function",
Function: tools.FunctionCall{
Name: "web_search",
Arguments: `{"query": "Go programming"}`,
},
},
},
}))

// Tool response for other agent's call
s.AddMessage(NewAgentMessage(otherAgent, &chat.Message{
Role: chat.MessageRoleTool,
ToolCallID: "call-123",
Content: "Go is a statically typed language",
}))

// Other agent's follow-up text response
s.AddMessage(NewAgentMessage(otherAgent, &chat.Message{
Role: chat.MessageRoleAssistant,
Content: "Here is what I found about Go",
}))

messages := s.GetMessages(currentAgent)

// Extract only non-system conversation messages
var conv []chat.Message
for _, m := range messages {
if m.Role != chat.MessageRoleSystem {
conv = append(conv, m)
}
}

require.Len(t, conv, 4, "should have: user msg, converted assistant+tool call, converted tool response, converted assistant text")

// 1. Original user message is unchanged
assert.Equal(t, chat.MessageRoleUser, conv[0].Role)
assert.Equal(t, "find info about Go", conv[0].Content)

// 2. Other agent's assistant message with tool call → user-role narrative
assert.Equal(t, chat.MessageRoleUser, conv[1].Role)
assert.Contains(t, conv[1].Content, "For context:")
assert.Contains(t, conv[1].Content, "[other-agent] said: Let me search for that")
assert.Contains(t, conv[1].Content, "[other-agent] called tool `web_search` with parameters:")
assert.Contains(t, conv[1].Content, `"query": "Go programming"`)
assert.Empty(t, conv[1].ToolCalls, "should have no structured tool calls")

// 3. Tool response → user-role narrative
assert.Equal(t, chat.MessageRoleUser, conv[2].Role)
assert.Contains(t, conv[2].Content, "For context:")
assert.Contains(t, conv[2].Content, "Tool returned result: Go is a statically typed language")
assert.Empty(t, conv[2].ToolCallID, "should have no tool call ID")

// 4. Other agent's plain text → user-role narrative
assert.Equal(t, chat.MessageRoleUser, conv[3].Role)
assert.Contains(t, conv[3].Content, "[other-agent] said: Here is what I found about Go")
}

func TestGetMessages_OtherAgentToolCallOnly(t *testing.T) {
currentAgent := agent.New("agent-a", "you are agent a")
otherAgent := agent.New("agent-b", "you are agent b")

s := New()

// Other agent sends assistant message with ONLY a tool call (no text)
s.AddMessage(NewAgentMessage(otherAgent, &chat.Message{
Role: chat.MessageRoleAssistant,
ToolCalls: []tools.ToolCall{
{
ID: "call-456",
Type: "function",
Function: tools.FunctionCall{
Name: "read_file",
Arguments: `{"path": "/tmp/test.txt"}`,
},
},
},
}))

// Tool response with empty content
s.AddMessage(NewAgentMessage(otherAgent, &chat.Message{
Role: chat.MessageRoleTool,
ToolCallID: "call-456",
Content: "",
}))

messages := s.GetMessages(currentAgent)

var conv []chat.Message
for _, m := range messages {
if m.Role != chat.MessageRoleSystem {
conv = append(conv, m)
}
}

// Tool-call-only message should still produce a narrative (with the tool call text)
require.Len(t, conv, 1, "should have the converted tool call message; empty tool response is skipped")
assert.Equal(t, chat.MessageRoleUser, conv[0].Role)
assert.Contains(t, conv[0].Content, "[agent-b] called tool `read_file`")
}

func TestGetMessages_SameAgentToolCallsUnchanged(t *testing.T) {
currentAgent := agent.New("my-agent", "you are my agent")

s := New()

s.AddMessage(NewAgentMessage(currentAgent, &chat.Message{
Role: chat.MessageRoleUser,
Content: "do something",
}))

// Current agent's own tool call should pass through unchanged
s.AddMessage(NewAgentMessage(currentAgent, &chat.Message{
Role: chat.MessageRoleAssistant,
Content: "I will use a tool",
ToolCalls: []tools.ToolCall{
{
ID: "own-call-1",
Type: "function",
Function: tools.FunctionCall{
Name: "my_tool",
Arguments: `{"x": 1}`,
},
},
},
}))

s.AddMessage(NewAgentMessage(currentAgent, &chat.Message{
Role: chat.MessageRoleTool,
ToolCallID: "own-call-1",
Content: "tool output",
}))

messages := s.GetMessages(currentAgent)

var conv []chat.Message
for _, m := range messages {
if m.Role != chat.MessageRoleSystem {
conv = append(conv, m)
}
}

require.Len(t, conv, 3)

// User message unchanged
assert.Equal(t, chat.MessageRoleUser, conv[0].Role)

// Own assistant message keeps structured tool calls
assert.Equal(t, chat.MessageRoleAssistant, conv[1].Role)
assert.Len(t, conv[1].ToolCalls, 1)
assert.Equal(t, "own-call-1", conv[1].ToolCalls[0].ID)

// Own tool response unchanged
assert.Equal(t, chat.MessageRoleTool, conv[2].Role)
assert.Equal(t, "own-call-1", conv[2].ToolCallID)
assert.Equal(t, "tool output", conv[2].Content)
}

func TestGetMessagesWithSummary(t *testing.T) {
testAgent := &agent.Agent{}

Expand Down