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
36 changes: 15 additions & 21 deletions internal/gemini/gemini_planner.go
Original file line number Diff line number Diff line change
Expand Up @@ -341,29 +341,23 @@ func (p *geminiPlannerAgent) handleSubagentCall(ctx context.Context, conversatio
}

mappedName := strings.ReplaceAll(fc.Name, "_", "-")
var historyStr strings.Builder
for _, msg := range history {
historyStr.WriteString(fmt.Sprintf("%s: ", msg.Role))
if msg.Content != nil {
if txt := msg.Content.GetText(); txt != nil {
historyStr.WriteString(txt.Text)
}
}
historyStr.WriteString("\n")
}

// Capture Gemini's typed `history` arg as-is — it's the model's own
// summarization of the conversation. Empty string when the model
// omitted the arg.
subagentHistoryArg, _ := fc.Args["history"].(string)

// Subagents receive the planner's typed function-call arguments via
// the structured AgentStart.subagent_prompt / .subagent_history
// fields. We do NOT synthesize a fake user-role Message wrapping a
// "History Summary:\n…\nPrompt:\n…" string — Messages is left empty
// here because it's the wire surface for direct (non-planner)
// callers, not for planner-driven dispatch. Subagents read
// start.GetSubagentPrompt() directly.
subagentStart := &proto.AgentStart{
AgentId: mappedName,
Messages: []*proto.Message{
{
Role: "user",
Content: &proto.Content{
Type: &proto.Content_Text{
Text: &proto.TextContent{Text: fmt.Sprintf("History Summary:\n%s\n\nPrompt:\n%s", historyStr.String(), prompt)},
},
},
},
},
AgentId: mappedName,
SubagentPrompt: prompt,
SubagentHistory: subagentHistoryArg,
}

var subagentOutputs []*proto.Message
Expand Down
80 changes: 80 additions & 0 deletions internal/gemini/gemini_planner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,86 @@ func TestHandleSubagentCall_Success(t *testing.T) {
}
}

// TestHandleSubagentCall_PopulatesStructuredFieldsOnly asserts that the
// planner dispatches subagents via the structured AgentStart fields
// (subagent_prompt / subagent_history) and does NOT synthesize a
// "History Summary:\n…\nPrompt:\n…" envelope as messages[0].
//
// Messages remains the wire surface for direct (non-planner) callers
// — empty here because the planner is the caller and it uses the
// typed fields instead.
func TestHandleSubagentCall_PopulatesStructuredFieldsOnly(t *testing.T) {
var capturedStart *proto.AgentStart
mockExec := &mockExecutor{
execFunc: func(ctx context.Context, conversationID string, execID string, start *proto.AgentStart, o agent.OutputHandler) (proto.State, error) {
capturedStart = start
o(&proto.AgentOutputs{
Messages: []*proto.Message{
{
Role: "model",
Content: &proto.Content{
Type: &proto.Content_Text{
Text: &proto.TextContent{Text: "ok"},
},
},
},
},
})
return proto.State_STATE_COMPLETED, nil
},
}

p := &geminiPlannerAgent{
config: GeminiPlannerConfig{
GeminiConfig: &config.GeminiConfig{Model: "test-model"},
},
}

fc := &genai.FunctionCall{
Name: "test-subagent",
Args: map[string]any{
"history": "Previous history summary",
"prompt": "Current user prompt",
},
}

history := []*proto.Message{
{
Role: "user",
Content: &proto.Content{
Type: &proto.Content_Text{Text: &proto.TextContent{Text: "what's up"}},
},
},
}

handler := func(outgoing *proto.AgentOutputs) error { return nil }

if err := p.handleSubagentCall(context.Background(), "test-conv", fc, nil, history, mockExec, handler); err != nil {
t.Fatalf("handleSubagentCall failed: %v", err)
}

if capturedStart == nil {
t.Fatal("expected executor to receive an AgentStart")
}

// Structured fields populated from typed FunctionCall args.
if got, want := capturedStart.GetSubagentPrompt(), "Current user prompt"; got != want {
t.Errorf("SubagentPrompt = %q, want %q", got, want)
}
if got, want := capturedStart.GetSubagentHistory(), "Previous history summary"; got != want {
t.Errorf("SubagentHistory = %q, want %q", got, want)
}

// Messages must be empty: planner uses subagent_prompt as the
// dispatch surface, not the envelope-string Message we used to
// synthesize. Any code at any point in the stack that finds a
// "History Summary:\n…\nPrompt:\n…" string in messages[0] is a
// regression.
if n := len(capturedStart.Messages); n != 0 {
t.Errorf("expected Messages to be empty (planner uses subagent_prompt), got %d messages", n)
}
}

func TestHandleSubagentCall_MissingArgs(t *testing.T) {
p := &geminiPlannerAgent{}
fc := &genai.FunctionCall{
Expand Down
51 changes: 42 additions & 9 deletions proto/ax.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions proto/ax.proto
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,25 @@ message AgentStart {
string agent_id = 1;
bytes agent_config = 2;
repeated Message messages = 3;

// subagent_prompt is the planner's tool-call `prompt` argument passed
// through verbatim when this AgentStart is the result of a planner
// dispatching to a subagent. Modern subagents should prefer this field
// over parsing free-form text out of `messages`. Empty when the
// AgentStart did not originate from a planner subagent dispatch.
//
// Backward-compat note: the planner ALSO synthesizes a legacy
// "History Summary:\n…\nPrompt:\n…" envelope into messages[0] so
// pre-existing subagents continue to work unmodified. New subagents
// should check this field first and fall back to the message-text path
// only when it is empty.
string subagent_prompt = 4;

// subagent_history is the planner's tool-call `history` argument
// (a stringified conversation history) passed through verbatim
// alongside subagent_prompt. See subagent_prompt for the
// backward-compatibility story.
string subagent_history = 5;
}

message AgentOutputs {
Expand Down