diff --git a/docs/dotnet-go-sdk-feature-comparison.md b/docs/dotnet-go-sdk-feature-comparison.md index 22f52a52..3e6ec42e 100644 --- a/docs/dotnet-go-sdk-feature-comparison.md +++ b/docs/dotnet-go-sdk-feature-comparison.md @@ -72,7 +72,7 @@ Within overlapping features, the main misalignments are API shape and ecosystem | RAG | Basic text RAG, custom vector store RAG, custom data source RAG, Foundry service RAG, Neo4j graph RAG samples. | No RAG package or sample found. | .NET only | Go has data/file/vector content types but no RAG workflow package or samples. | | Purview | `Microsoft.Agents.AI.Purview` models and end-to-end sample. | No equivalent package. | .NET only | No Go governance/Purview integration. | | Cosmos DB storage | Cosmos chat history provider and workflow checkpoint store. | No built-in Cosmos package. | .NET only | Go has public in-memory and JSON/file workflow checkpoint stores plus a custom store interface, but no Cosmos DB provider. | -| Agent workflow builders | Sequential, concurrent, handoff, group chat builders. | `agentworkflow.NewSequentialWorkflowBuilder`, `agentworkflow.NewConcurrentWorkflowBuilder`, `agentworkflow.NewGroupChatWorkflowBuilder`; manual builder plus `AddChain`, `AddSwitch`, direct/fan-out/fan-in edges; workflow-as-agent, group chat, and agents-in-workflows examples. | Partial | Go now has first-class sequential, concurrent, and group chat builders with explicit output designation support. Handoff and Magentic builders are not yet implemented. | +| Agent workflow builders | Sequential, concurrent, handoff, group chat builders. | `agentworkflow.NewSequentialWorkflowBuilder`, `agentworkflow.NewConcurrentWorkflowBuilder`, `agentworkflow.NewGroupChatWorkflowBuilder`, `agentworkflow.NewHandoffWorkflowBuilder`; manual builder plus `AddChain`, `AddSwitch`, direct/fan-out/fan-in edges; workflow-as-agent, group chat, and agents-in-workflows examples. | Partial | Go now has first-class sequential, concurrent, group chat, and handoff builders with explicit output designation support. The Magentic builder is not yet implemented. | | Workflow graph builder | `WorkflowBuilder`, direct edges, fan-out, fan-in barrier, labels, conditions, switch/case samples. | `workflow.Builder`, `AddEdge`, `AddDirectEdge`, `AddFanOutEdge`, `AddFanInBarrierEdge`, `WithEdgeLabel`, `WithEdgeAssigner`, `AddSwitch`. | Aligned | .NET has more overloads/extension methods; Go uses simpler methods and option functions. | | Workflow executor model | Generic `Executor` and `Executor`, function executors, aggregating executor, protocol builder. | `Executor`, `NewExecutor`, `Executor.Bind`, `Executor.Extend`, `RouteBuilder`, `StatefulExecutorCache`. | Partial | .NET has more overloads and an explicit `AggregatingExecutor`; Go mirrors .NET's executor-level cross-run declaration and binding-level concurrent-run gate, while route configuration and lifecycle hooks live on `Executor`. | | Workflow protocol description | Accepts/yields/sends/catch-all protocol descriptor and chat protocol helpers. | `ProtocolDescriptor` exposes accepted, yielded, and sent types plus catch-all acceptance; `messageworkflow.Configure` contributes chat-message protocol metadata. | Aligned | Go now exposes the same protocol shape while keeping chat helpers in the Go-specific message workflow adapter. | @@ -84,7 +84,7 @@ Within overlapping features, the main misalignments are API shape and ecosystem | Agent in workflow | `AIAgentBinding`, `AIAgentHostOptions`, response/update events, role reassignment, message forwarding, intercept user-input/function-call requests. | `workflow/agentworkflow.New` with `Config`: update/response events, message forwarding toggle (`DisableForwardIncomingMessages`), role reassignment toggle (`DisableReassignOtherAgentsAsUsers`), `InterceptUserInputRequests`, `InterceptUnterminatedFunctionCalls`. | Aligned | API shape differs: .NET uses positive-boolean defaults (`ForwardIncomingMessages = true`, `ReassignOtherAgentsAsUsers = true`); Go uses opt-out booleans (`DisableForwardIncomingMessages`, `DisableReassignOtherAgentsAsUsers`). Feature coverage is equivalent. | | Workflow as agent | Workflow host agent / `AsAIAgent`, sample. | `agentworkflow.NewAgent`. | Aligned | Go chooses in-process environment based on concurrency; .NET is integrated with `AIAgent` extensions. | | Subworkflows | `ConfigureSubWorkflow`, `BindAsExecutor`, subworkflow sample. | `inproc.BindSubworkflowAsExecutor` plus in-process subworkflow execution. | Aligned | Go exposes subworkflow binding from the in-process execution package. | -| Handoff orchestration | Handoff workflow builder with handoff instructions, tool-call filtering, return-to-previous, response/update events. | No first-class handoff builder. | .NET only | Could be modeled manually with tools/workflows, but no SDK feature. | +| Handoff orchestration | Handoff workflow builder with handoff instructions, tool-call filtering, return-to-previous, response/update events. | `agentworkflow.NewHandoffWorkflowBuilder` with `WithHandoff` handoff edges; per-agent handoff tools route the shared conversation to the chosen target, with return-to-previous supported by declaring reverse handoffs. | Aligned | Go now has a first-class handoff builder; the current turn's agent hands off by calling an injected `handoff_to_` tool, reusing the group chat host and manager plumbing. | | Group chat orchestration | Group chat manager and group chat workflow builder. | `agentworkflow.GroupChatManager`, `agentworkflow.NewRoundRobinGroupChatManager`, `agentworkflow.NewGroupChatWorkflowBuilder`. | Aligned | API shape differs, but Go now has a managed group chat abstraction with manager callbacks and workflow hosting. | | Declarative agents | YAML prompt/declarative agent packages, factories, PowerFx helpers. | No equivalent package. | .NET only | Go skills are prompt-like, but not declarative agents. | | Declarative workflows | Declarative workflow packages, Foundry/MCP declarative integrations, samples for confirm input, HTTP, code, MCP, function tools, marketing, student/teacher, etc. | No equivalent package. | .NET only | Go workflows are code-first. | diff --git a/workflow/agentworkflow/handoff.go b/workflow/agentworkflow/handoff.go new file mode 100644 index 00000000..07087d7e --- /dev/null +++ b/workflow/agentworkflow/handoff.go @@ -0,0 +1,386 @@ +// Copyright (c) Microsoft. All rights reserved. + +package agentworkflow + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + + "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/message" + "github.com/microsoft/agent-framework-go/tool" + "github.com/microsoft/agent-framework-go/tool/functool" + "github.com/microsoft/agent-framework-go/workflow" +) + +const ( + handoffToolNamePrefix = "handoff_to_" + handoffManagerStateKey = "handoff_state" +) + +// HandoffWorkflowBuilder fluently builds handoff workflows. +// +// A handoff workflow is a group chat whose next speaker is chosen by the current +// agent itself: each agent is given a handoff tool for every target it may hand +// off to, and calling that tool routes the shared conversation to the target. +// The first agent passed to [NewHandoffWorkflowBuilder] is the entry agent that +// receives the initial input. A turn in which the speaking agent calls no +// handoff tool ends the workflow and yields the accumulated conversation. +// +// Participant agents must have automatic function-tool calling enabled (the +// default; that is, [agent.Config.DisableFuncAutoCall] must be false) so the +// injected handoff tools execute and the resulting tool call is recorded in the +// conversation for routing. An agent that surfaces a handoff call without +// executing it would instead raise it as an unresolved function call and stall +// the run. +type HandoffWorkflowBuilder struct { + name string + description string + agents []*agent.Agent + entry *agent.Agent + handoffs map[*agent.Agent][]*agent.Agent + handoffOrder []*agent.Agent + outputDesignations outputDesignations + maximumIterationCount int + err error +} + +// NewHandoffWorkflowBuilder creates a builder for a handoff workflow. The first +// agent is the entry agent that receives the initial input. +func NewHandoffWorkflowBuilder(agents ...*agent.Agent) *HandoffWorkflowBuilder { + b := &HandoffWorkflowBuilder{ + agents: slices.Clone(agents), + handoffs: make(map[*agent.Agent][]*agent.Agent), + } + if len(agents) > 0 { + b.entry = agents[0] + } + return b +} + +// WithName sets the workflow name. +func (b *HandoffWorkflowBuilder) WithName(name string) *HandoffWorkflowBuilder { + if b == nil || b.err != nil { + return b + } + b.name = name + return b +} + +// WithDescription sets the workflow description. +func (b *HandoffWorkflowBuilder) WithDescription(description string) *HandoffWorkflowBuilder { + if b == nil || b.err != nil { + return b + } + b.description = description + return b +} + +// WithHandoff allows from to hand off the conversation to each of targets. It +// may be called multiple times to extend an agent's set of targets. Both from +// and every target must be participants passed to [NewHandoffWorkflowBuilder]. +func (b *HandoffWorkflowBuilder) WithHandoff(from *agent.Agent, targets ...*agent.Agent) *HandoffWorkflowBuilder { + if b == nil || b.err != nil { + return b + } + if from == nil { + b.err = fmt.Errorf("agentworkflow: HandoffWorkflowBuilder handoff source is nil") + return b + } + for index, target := range targets { + if target == nil { + b.err = fmt.Errorf("agentworkflow: HandoffWorkflowBuilder handoff target at index %d is nil", index) + return b + } + } + if _, ok := b.handoffs[from]; !ok { + b.handoffOrder = append(b.handoffOrder, from) + } + b.handoffs[from] = append(b.handoffs[from], targets...) + return b +} + +// WithMaximumIterationCount caps the number of agent turns before the workflow +// ends, guarding against handoff loops (for example two agents that keep handing +// off to each other). A non-positive value uses the default of 40. +func (b *HandoffWorkflowBuilder) WithMaximumIterationCount(count int) *HandoffWorkflowBuilder { + if b == nil || b.err != nil { + return b + } + b.maximumIterationCount = count + return b +} + +// WithOutputFrom designates agents as terminal workflow output sources. +func (b *HandoffWorkflowBuilder) WithOutputFrom(agents ...*agent.Agent) *HandoffWorkflowBuilder { + if b == nil || b.err != nil { + return b + } + b.outputDesignations, b.err = b.outputDesignations.withOutputFrom(agents...) + return b +} + +// WithIntermediateOutputFrom designates agents as intermediate workflow output sources. +func (b *HandoffWorkflowBuilder) WithIntermediateOutputFrom(agents ...*agent.Agent) *HandoffWorkflowBuilder { + if b == nil || b.err != nil { + return b + } + b.outputDesignations, b.err = b.outputDesignations.withIntermediateOutputFrom(agents...) + return b +} + +// Build builds the handoff workflow. +func (b *HandoffWorkflowBuilder) Build() (*workflow.Workflow, error) { + if b == nil { + return nil, fmt.Errorf("agentworkflow: HandoffWorkflowBuilder is nil") + } + if b.err != nil { + return nil, b.err + } + if err := validateBuilderAgents("HandoffWorkflowBuilder", b.agents); err != nil { + return nil, err + } + + members := make(map[*agent.Agent]struct{}, len(b.agents)) + for _, participant := range b.agents { + members[participant] = struct{}{} + } + + // Build, per source agent, the handoff tools it is offered, and a global + // map from handoff tool name to target agent used by the manager to route. + toolTargets := make(map[string]*agent.Agent) + handoffToolsByAgent := make(map[*agent.Agent][]tool.Tool) + for _, from := range b.handoffOrder { + if _, ok := members[from]; !ok { + return nil, fmt.Errorf("agentworkflow: handoff source %q is not a participant in this handoff workflow", agentNameForError(from)) + } + seen := make(map[*agent.Agent]struct{}) + for _, target := range b.handoffs[from] { + if target == from { + return nil, fmt.Errorf("agentworkflow: agent %q cannot hand off to itself", agentNameForError(from)) + } + if _, ok := members[target]; !ok { + return nil, fmt.Errorf("agentworkflow: handoff target %q is not a participant in this handoff workflow", agentNameForError(target)) + } + if _, dup := seen[target]; dup { + continue + } + seen[target] = struct{}{} + toolName := handoffToolName(target) + if existing, ok := toolTargets[toolName]; ok && existing != target { + return nil, fmt.Errorf("agentworkflow: handoff targets %q and %q map to the same handoff tool name %q; give the agents distinct names", agentNameForError(existing), agentNameForError(target), toolName) + } + toolTargets[toolName] = target + handoffToolsByAgent[from] = append(handoffToolsByAgent[from], newHandoffTool(target, toolName)) + } + } + + participants := b.agents + participantBindings := make([]workflow.ExecutorBinding, len(participants)) + bindingsByAgent := make(map[*agent.Agent]workflow.ExecutorBinding, len(participants)) + bindingsByAgentID := make(map[string]workflow.ExecutorBinding, len(participants)) + for index, currentAgent := range participants { + cfg := Config{DisableForwardIncomingMessages: true} + for _, handoffTool := range handoffToolsByAgent[currentAgent] { + cfg.RunOptions = append(cfg.RunOptions, agent.WithTool(handoffTool)) + } + binding := New(currentAgent, cfg) + participantBindings[index] = binding + bindingsByAgent[currentAgent] = binding + bindingsByAgentID[currentAgent.ID()] = binding + } + + entry := b.entry + maximumIterationCount := b.maximumIterationCount + // The participant set is fixed at build time, so the factory closes over the + // entry agent and handoff routing table and ignores its agents argument. + managerFactory := func([]*agent.Agent) *GroupChatManager { + return newHandoffManager(entry, toolTargets, maximumIterationCount) + } + + host := newGroupChatHostBinding(participants, participantBindings, bindingsByAgent, bindingsByAgentID, managerFactory) + builder := applyBuilderMetadata(workflow.NewBuilder(host), b.name, b.description) + for _, participant := range participantBindings { + builder = builder.AddEdge(host, participant).AddEdge(participant, host) + } + var err error + builder, err = applyOutputDesignations(builder, b.outputDesignations, bindingsByAgent, "handoff", func() { + builder = builder.WithOutputFrom(host).WithIntermediateOutputFrom(participantBindings...) + }) + if err != nil { + return nil, err + } + return builder.Build() +} + +// handoffToolName derives the handoff tool name presented to the model for a +// target agent, from its name (or ID when unnamed). +func handoffToolName(target *agent.Agent) string { + base := target.Name() + if strings.TrimSpace(base) == "" { + base = target.ID() + } + return handoffToolNamePrefix + sanitizeToolNameSegment(base) +} + +// sanitizeToolNameSegment maps an arbitrary agent identifier to the character +// set accepted for function tool names ([a-zA-Z0-9_-]). +func sanitizeToolNameSegment(s string) string { + var sb strings.Builder + for _, r := range s { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-': + sb.WriteRune(r) + default: + sb.WriteByte('_') + } + } + if sb.Len() == 0 { + return "agent" + } + return sb.String() +} + +// handoffToolArgs is the (empty) argument object for a handoff tool. The model +// calls the tool with {} to hand off; no parameters are required. +type handoffToolArgs struct{} + +func newHandoffTool(target *agent.Agent, name string) tool.Tool { + targetName := agentNameForError(target) + return functool.MustNew( + functool.Config{ + Name: name, + Description: fmt.Sprintf("Hand off the conversation to the %q agent. Call this when the request should be handled by that agent instead of you.", targetName), + }, + func(context.Context, handoffToolArgs) (string, error) { + return fmt.Sprintf("Handing off to %s.", targetName), nil + }, + ) +} + +// handoffManager is a [GroupChatManager] whose SelectNextAgent routes to the +// target of the most recent handoff tool call produced by the previous speaker. +type handoffManager struct { + manager GroupChatManager + entry *agent.Agent + toolTargets map[string]*agent.Agent + maximumIterationCount int + started bool + cursor int +} + +func newHandoffManager(entry *agent.Agent, toolTargets map[string]*agent.Agent, maximumIterationCount int) *GroupChatManager { + m := &handoffManager{entry: entry, toolTargets: toolTargets, maximumIterationCount: maximumIterationCount} + m.manager = GroupChatManager{ + SelectNextAgent: m.selectNextAgent, + ShouldTerminate: m.shouldTerminate, + Reset: m.reset, + OnCheckpoint: m.onCheckpoint, + OnCheckpointRestored: m.onCheckpointRestored, + } + return &m.manager +} + +func (m *handoffManager) shouldTerminate(_ context.Context, _ []*message.Message, iterationCount int) (bool, error) { + limit := m.maximumIterationCount + if limit <= 0 { + limit = defaultGroupChatMaximumIterations + } + return iterationCount >= limit, nil +} + +func (m *handoffManager) selectNextAgent(_ context.Context, history []*message.Message) (*agent.Agent, error) { + if m == nil { + return nil, nil + } + if !m.started { + m.started = true + m.cursor = len(history) + return m.entry, nil + } + start := m.cursor + if start < 0 { + start = 0 + } + if start > len(history) { + // A cursor beyond the history (only reachable from an inconsistent + // restore) means there are no new messages to inspect; end cleanly + // rather than rescanning the whole history and routing on a stale call. + start = len(history) + } + m.cursor = len(history) + // The most recent handoff call in the previous speaker's turn wins; a turn + // with no handoff call returns nil, ending the workflow. + var target *agent.Agent + for _, msg := range history[start:] { + if msg == nil { + continue + } + for _, content := range msg.Contents { + call, ok := content.(*message.FunctionCallContent) + if !ok { + continue + } + if next, ok := m.toolTargets[call.Name]; ok { + target = next + } + } + } + return target, nil +} + +func (m *handoffManager) reset() { + m.started = false + m.cursor = 0 +} + +type handoffManagerState struct { + Started bool + Cursor int +} + +func (m *handoffManager) onCheckpoint(ctx *workflow.Context) error { + return ctx.QueueStateUpdate(handoffManagerStateKey, "", handoffManagerState{Started: m.started, Cursor: m.cursor}) +} + +func (m *handoffManager) onCheckpointRestored(ctx *workflow.Context) error { + value, err := ctx.ReadState(handoffManagerStateKey, "") + if err != nil { + return err + } + state, err := handoffManagerStateFromAny(value) + if err != nil { + return err + } + m.started = false + m.cursor = 0 + if state != nil { + m.started = state.Started + m.cursor = state.Cursor + } + return nil +} + +func handoffManagerStateFromAny(value any) (*handoffManagerState, error) { + if value == nil { + return nil, nil + } + switch state := value.(type) { + case handoffManagerState: + return &state, nil + case *handoffManagerState: + return state, nil + } + data, err := json.Marshal(value) + if err != nil { + return nil, err + } + var state handoffManagerState + if err := json.Unmarshal(data, &state); err != nil { + return nil, err + } + return &state, nil +} diff --git a/workflow/agentworkflow/handoff_test.go b/workflow/agentworkflow/handoff_test.go new file mode 100644 index 00000000..a89a8ea2 --- /dev/null +++ b/workflow/agentworkflow/handoff_test.go @@ -0,0 +1,295 @@ +// Copyright (c) Microsoft. All rights reserved. + +package agentworkflow + +import ( + "context" + "iter" + "slices" + "testing" + + "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/message" + "github.com/microsoft/agent-framework-go/workflow" +) + +// newScriptedHandoffAgent returns a stub agent that emits, on each successive +// run, the next content set from script (repeating the final set once the +// script is exhausted). DisableFuncAutoCall keeps the emitted content verbatim, +// so a scripted handoff FunctionCallContent reaches the manager unchanged. +func newScriptedHandoffAgent(id string, name string, script [][]message.Content) *agent.Agent { + index := 0 + run := func(context.Context, []*message.Message, ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + return func(yield func(*agent.ResponseUpdate, error) bool) { + var contents []message.Content + if len(script) > 0 { + if index < len(script) { + contents = script[index] + } else { + contents = script[len(script)-1] + } + } + index++ + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + AgentID: id, + AuthorName: name, + Contents: contents, + }, nil) + } + } + return agent.New( + agent.ProviderConfig{ProviderName: "handoff-script", Run: run}, + agent.Config{ID: id, Name: name, DisableFuncAutoCall: true}, + ) +} + +func handoffTextContent(s string) message.Content { + return &message.TextContent{Text: s} +} + +// handoffTurn returns the content an agent emits to hand off to toolName: the +// handoff tool call together with its result, mirroring what an auto-executed +// handoff tool produces (a resolved call, not an unterminated one). +func handoffTurn(text string, toolName string) []message.Content { + var contents []message.Content + if text != "" { + contents = append(contents, handoffTextContent(text)) + } + callID := toolName + "_call" + return append( + contents, + &message.FunctionCallContent{CallID: callID, Name: toolName}, + &message.FunctionResultContent{CallID: callID, Result: "ok"}, + ) +} + +func answerTurn(text string) []message.Content { + return []message.Content{handoffTextContent(text)} +} + +func TestHandoffWorkflow_RoutesOnHandoffToolCall(t *testing.T) { + triage := newScriptedHandoffAgent("triage", "triage", [][]message.Content{ + handoffTurn("routing you to billing", "handoff_to_billing"), + }) + billing := newScriptedHandoffAgent("billing", "billing", [][]message.Content{ + answerTurn("billing handled it"), + }) + + wf, err := NewHandoffWorkflowBuilder(triage, billing). + WithHandoff(triage, billing). + Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + + texts := collectGroupChatOutputTexts(runGroupChatWorkflowTurn(t, wf, "I have a billing question")) + if !slices.Contains(texts, "billing handled it") { + t.Fatalf("expected billing to handle the conversation, got outputs: %v", texts) + } +} + +func TestHandoffWorkflow_Handback(t *testing.T) { + // triage hands off to billing, billing hands back to triage, triage answers. + triage := newScriptedHandoffAgent("triage", "triage", [][]message.Content{ + handoffTurn("", "handoff_to_billing"), + answerTurn("triage final answer"), + }) + billing := newScriptedHandoffAgent("billing", "billing", [][]message.Content{ + handoffTurn("", "handoff_to_triage"), + }) + + wf, err := NewHandoffWorkflowBuilder(triage, billing). + WithHandoff(triage, billing). + WithHandoff(billing, triage). + Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + + texts := collectGroupChatOutputTexts(runGroupChatWorkflowTurn(t, wf, "start")) + if !slices.Contains(texts, "triage final answer") { + t.Fatalf("expected triage to produce the final answer after handback, got outputs: %v", texts) + } +} + +func TestHandoffWorkflow_EntryAnswersWithoutHandoff(t *testing.T) { + solo := newScriptedHandoffAgent("solo", "solo", [][]message.Content{ + answerTurn("answered directly"), + }) + + wf, err := NewHandoffWorkflowBuilder(solo).Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + + texts := collectGroupChatOutputTexts(runGroupChatWorkflowTurn(t, wf, "hi")) + if !slices.Contains(texts, "answered directly") { + t.Fatalf("expected the entry agent to answer directly, got outputs: %v", texts) + } +} + +func TestHandoffWorkflow_BuildValidation(t *testing.T) { + a := newScriptedHandoffAgent("a", "a", nil) + b := newScriptedHandoffAgent("b", "b", nil) + stranger := newScriptedHandoffAgent("x", "x", nil) + + tests := []struct { + name string + build func() (*workflow.Workflow, error) + wantErr bool + }{ + {name: "no agents", build: func() (*workflow.Workflow, error) { return NewHandoffWorkflowBuilder().Build() }, wantErr: true}, + {name: "nil agent", build: func() (*workflow.Workflow, error) { return NewHandoffWorkflowBuilder(a, nil).Build() }, wantErr: true}, + {name: "nil handoff source", build: func() (*workflow.Workflow, error) { + return NewHandoffWorkflowBuilder(a, b).WithHandoff(nil, b).Build() + }, wantErr: true}, + {name: "nil handoff target", build: func() (*workflow.Workflow, error) { + return NewHandoffWorkflowBuilder(a, b).WithHandoff(a, nil).Build() + }, wantErr: true}, + {name: "non-participant source", build: func() (*workflow.Workflow, error) { + return NewHandoffWorkflowBuilder(a, b).WithHandoff(stranger, b).Build() + }, wantErr: true}, + {name: "non-participant target", build: func() (*workflow.Workflow, error) { + return NewHandoffWorkflowBuilder(a, b).WithHandoff(a, stranger).Build() + }, wantErr: true}, + {name: "self handoff", build: func() (*workflow.Workflow, error) { + return NewHandoffWorkflowBuilder(a, b).WithHandoff(a, a).Build() + }, wantErr: true}, + {name: "colliding tool names", build: func() (*workflow.Workflow, error) { + // "dup name" and "dup_name" both sanitize to handoff_to_dup_name. + dup1 := newScriptedHandoffAgent("d1", "dup name", nil) + dup2 := newScriptedHandoffAgent("d2", "dup_name", nil) + return NewHandoffWorkflowBuilder(a, dup1, dup2).WithHandoff(a, dup1, dup2).Build() + }, wantErr: true}, + {name: "valid", build: func() (*workflow.Workflow, error) { + return NewHandoffWorkflowBuilder(a, b).WithHandoff(a, b).Build() + }, wantErr: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + wf, err := tt.build() + if tt.wantErr { + if err == nil { + t.Fatalf("expected an error, got nil (wf=%v)", wf) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if wf == nil { + t.Fatal("expected a workflow, got nil") + } + }) + } +} + +func TestHandoffManager_CheckpointRestoresState(t *testing.T) { + triage := newScriptedHandoffAgent("triage", "triage", nil) + billing := newScriptedHandoffAgent("billing", "billing", nil) + toolTargets := map[string]*agent.Agent{"handoff_to_billing": billing} + manager := newHandoffManager(triage, toolTargets, 0) + + // First selection returns the entry agent and marks the manager started. + userTurn := []*message.Message{{Role: message.RoleUser, Contents: []message.Content{handoffTextContent("hi")}}} + if got, err := manager.SelectNextAgent(t.Context(), userTurn); err != nil || got != triage { + t.Fatalf("first SelectNextAgent = (%v, %v), want (triage, nil)", got, err) + } + + state := make(map[string]any) + if err := checkpointGroupChatManager(newGroupChatStateContext(t.Context(), state), manager, 2); err != nil { + t.Fatalf("checkpointGroupChatManager: %v", err) + } + if _, ok := state[groupChatManagerSubclassStateKeyPref+handoffManagerStateKey]; !ok { + t.Fatalf("missing prefixed handoff manager state key") + } + + // A fresh manager restored from the checkpoint must resume in the started + // state: the next turn's handoff call routes to the target rather than + // re-selecting the entry agent. + restored := newHandoffManager(triage, toolTargets, 0) + if _, err := restoreGroupChatManagerCheckpoint(newGroupChatStateContext(t.Context(), state), restored); err != nil { + t.Fatalf("restoreGroupChatManagerCheckpoint: %v", err) + } + history := append(slices.Clone(userTurn), &message.Message{Role: message.RoleAssistant, Contents: handoffTurn("", "handoff_to_billing")}) + next, err := restored.SelectNextAgent(t.Context(), history) + if err != nil { + t.Fatalf("SelectNextAgent restored: %v", err) + } + if next != billing { + t.Fatalf("restored manager routed to %v, want billing (checkpoint did not restore started state)", next) + } +} + +func TestHandoffWorkflow_LastHandoffCallWins(t *testing.T) { + // triage emits two handoff calls in one turn; the most recent (billing) wins. + triage := newScriptedHandoffAgent("triage", "triage", [][]message.Content{ + append(slices.Clone(handoffTurn("", "handoff_to_refunds")), handoffTurn("", "handoff_to_billing")...), + }) + billing := newScriptedHandoffAgent("billing", "billing", [][]message.Content{answerTurn("billing handled it")}) + refunds := newScriptedHandoffAgent("refunds", "refunds", [][]message.Content{answerTurn("refunds handled it")}) + + wf, err := NewHandoffWorkflowBuilder(triage, billing, refunds). + WithHandoff(triage, billing, refunds). + Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + + texts := collectGroupChatOutputTexts(runGroupChatWorkflowTurn(t, wf, "start")) + if !slices.Contains(texts, "billing handled it") { + t.Fatalf("expected the last handoff (billing) to win, got: %v", texts) + } + if slices.Contains(texts, "refunds handled it") { + t.Fatalf("did not expect refunds (the earlier handoff) to run, got: %v", texts) + } +} + +func TestHandoffWorkflow_UnknownHandoffCallIgnored(t *testing.T) { + // triage answers and emits a handoff call for a tool it was never granted; + // it must be ignored and the workflow must end with triage's answer. + triage := newScriptedHandoffAgent("triage", "triage", [][]message.Content{ + append(slices.Clone(answerTurn("triage responded")), handoffTurn("", "handoff_to_ghost")...), + }) + billing := newScriptedHandoffAgent("billing", "billing", [][]message.Content{answerTurn("billing handled it")}) + + wf, err := NewHandoffWorkflowBuilder(triage, billing). + WithHandoff(triage, billing). + Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + + texts := collectGroupChatOutputTexts(runGroupChatWorkflowTurn(t, wf, "start")) + if slices.Contains(texts, "billing handled it") { + t.Fatalf("an unknown handoff tool must not route to billing, got: %v", texts) + } + if !slices.Contains(texts, "triage responded") { + t.Fatalf("expected triage's answer in the output, got: %v", texts) + } +} + +func TestHandoffWorkflow_MaximumIterationCountCapsLoops(t *testing.T) { + // ping and pong hand off to each other forever; a cap of 1 must stop the run + // after the entry agent's single turn, before pong ever runs. + ping := newScriptedHandoffAgent("ping", "ping", [][]message.Content{handoffTurn("ping ran", "handoff_to_pong")}) + pong := newScriptedHandoffAgent("pong", "pong", [][]message.Content{handoffTurn("pong ran", "handoff_to_ping")}) + + wf, err := NewHandoffWorkflowBuilder(ping, pong). + WithHandoff(ping, pong). + WithHandoff(pong, ping). + WithMaximumIterationCount(1). + Build() + if err != nil { + t.Fatalf("Build: %v", err) + } + + texts := collectGroupChatOutputTexts(runGroupChatWorkflowTurn(t, wf, "start")) + if !slices.Contains(texts, "ping ran") { + t.Fatalf("expected ping to run once, got: %v", texts) + } + if slices.Contains(texts, "pong ran") { + t.Fatalf("iteration cap of 1 should have stopped the loop before pong ran, got: %v", texts) + } +} diff --git a/workflow/agentworkflow/hosting.go b/workflow/agentworkflow/hosting.go index f206b181..377fb77d 100644 --- a/workflow/agentworkflow/hosting.go +++ b/workflow/agentworkflow/hosting.go @@ -107,6 +107,13 @@ type Config struct { // the conversation, and the [workflow.TurnToken] propagated downstream // after the turn is held until all outstanding calls are resolved. InterceptUnterminatedFunctionCalls bool + + // RunOptions are additional [agent.Option] values passed to the hosted + // agent on every run, appended after the host's own options (session and + // streaming mode). They let a workflow builder inject per-agent behavior — + // for example the handoff tools added by [NewHandoffWorkflowBuilder] — that + // is not part of the agent's own configuration. + RunOptions []agent.Option } // New creates a workflow [workflow.ExecutorBinding] that hosts the given @@ -404,6 +411,7 @@ func (h *hostExecutor) runAgentAndDispatch(wctx *workflow.Context, messages []*m // Run the agent in streaming mode only when update events are to be emitted. agent.Stream(emitUpdates), } + runOpts = append(runOpts, h.cfg.RunOptions...) var resp agent.Response for update, err := range h.agent.Run(wctx, agentInput, runOpts...) {