From 19cc7b1f5b54c016b6eae4c0e52b0efa49dde519 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 10:46:34 +0000 Subject: [PATCH 1/3] feat(workflowhosting): add BuildSequential and BuildConcurrent helpers Port .NET's AgentWorkflowBuilder.BuildSequential and AgentWorkflowBuilder.BuildConcurrent convenience functions to Go. BuildSequential chains agents in order using the default Config (message forwarding and role reassignment enabled), matching .NET's pipeline behavior. BuildConcurrent fans the input to all agents simultaneously using a lightweight forwarding executor at the start, with each agent configured to not re-forward the shared input. Tests cover single-agent and multi-agent cases for both builders, plus error cases for empty input. Closes the "sequential/concurrent can be composed manually" gap in the feature comparison doc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- agent/hosting/workflowhosting/builders.go | 107 +++++++++++ .../hosting/workflowhosting/builders_test.go | 181 ++++++++++++++++++ docs/dotnet-go-sdk-feature-comparison.md | 2 +- 3 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 agent/hosting/workflowhosting/builders.go create mode 100644 agent/hosting/workflowhosting/builders_test.go diff --git a/agent/hosting/workflowhosting/builders.go b/agent/hosting/workflowhosting/builders.go new file mode 100644 index 00000000..62bcaf42 --- /dev/null +++ b/agent/hosting/workflowhosting/builders.go @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft. All rights reserved. + +package workflowhosting + +import ( + "errors" + "reflect" + + "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/message" + "github.com/microsoft/agent-framework-go/workflow" +) + +// BuildSequential builds a [workflow.Workflow] that runs agents in order: +// each agent's output (and incoming messages) is forwarded as input to the +// next, forming a linear pipeline. +// +// Agents are hosted with the default [Config], which enables incoming-message +// forwarding and role reassignment so that each agent in the chain sees the +// full conversation in the correct roles. +// +// This matches the behaviour of .NET's AgentWorkflowBuilder.BuildSequential. +func BuildSequential(agents ...*agent.Agent) (*workflow.Workflow, error) { + if len(agents) == 0 { + return nil, errors.New("workflowhosting: BuildSequential requires at least one agent") + } + + // Default Config: message forwarding and role reassignment are both + // enabled (zero-value booleans = false means "do NOT disable"). + cfg := Config{} + bindings := make([]workflow.ExecutorBinding, len(agents)) + for i, a := range agents { + bindings[i] = New(a, cfg) + } + + bld := workflow.NewBuilder(bindings[0]) + if len(bindings) > 1 { + bld = bld.AddChain(bindings[0], bindings[1:], false) + } + bld = bld.WithOutputFrom(bindings[len(bindings)-1]) + return bld.Build() +} + +// BuildConcurrent builds a [workflow.Workflow] that fans a single input out to +// all agents simultaneously. Each agent runs independently; their outputs are +// emitted as workflow [workflow.OutputEvent]s as they are produced. +// +// Agents are hosted with [Config.DisableMessageForwarding] set to true so that +// each agent only forwards its own output rather than re-broadcasting the +// shared input. Role reassignment remains enabled. +// +// This mirrors .NET's AgentWorkflowBuilder.BuildConcurrent. Note that unlike +// the .NET version, Go does not currently batch per-agent outputs via an +// aggregating executor; messages are emitted as they arrive from each agent. +func BuildConcurrent(agents ...*agent.Agent) (*workflow.Workflow, error) { + if len(agents) == 0 { + return nil, errors.New("workflowhosting: BuildConcurrent requires at least one agent") + } + + // Agents must not re-forward the shared input; only their own output should + // propagate. Role reassignment is kept enabled so each agent sees the other + // agents' messages as "user" messages. + cfg := Config{DisableMessageForwarding: true} + bindings := make([]workflow.ExecutorBinding, len(agents)) + for i, a := range agents { + bindings[i] = New(a, cfg) + } + + start := newForwardingBinding("concurrent_start") + bld := workflow.NewBuilder(start) + bld = bld.AddFanOutEdge(start, bindings) + bld = bld.WithOutputFrom(bindings...) + return bld.Build() +} + +// forwardingMarker is a private sentinel type used as the ExecutorType for +// forwardingBinding so it never collides with user-created bindings. +type forwardingMarker struct{} + +// newForwardingBinding creates an executor binding that forwards every +// received message to all connected executors unchanged. It is used as the +// fan-out start node in [BuildConcurrent]. +func newForwardingBinding(id string) workflow.ExecutorBinding { + return workflow.ExecutorBinding{ + ID: id, + ExecutorType: reflect.TypeFor[forwardingMarker](), + SupportsConcurrentSharedExecution: true, + NewExecutorFunc: func(_ string) (*workflow.Executor, error) { + return &workflow.Executor{ + ID: id, + Spec: workflow.ExecutorSpec{ + DisableAutoSendMessageHandlerResultObject: true, + DisableAutoYieldOutputHandlerResultObject: true, + SendTypes: []reflect.Type{ + reflect.TypeFor[[]*message.Message](), + reflect.TypeFor[workflow.TurnToken](), + }, + ConfigureRoutes: func(rb *workflow.RouteBuilder) (*workflow.RouteBuilder, error) { + return rb.AddCatchAll(func(ctx *workflow.Context, msg workflow.PortableValue) (any, error) { + return nil, ctx.SendMessage("", msg.Any()) + }), nil + }, + }, + }, nil + }, + } +} diff --git a/agent/hosting/workflowhosting/builders_test.go b/agent/hosting/workflowhosting/builders_test.go new file mode 100644 index 00000000..b46b4dea --- /dev/null +++ b/agent/hosting/workflowhosting/builders_test.go @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft. All rights reserved. + +package workflowhosting_test + +import ( + "context" + "iter" + "testing" + + "github.com/microsoft/agent-framework-go/agent" + "github.com/microsoft/agent-framework-go/agent/hosting/workflowhosting" + "github.com/microsoft/agent-framework-go/message" + "github.com/microsoft/agent-framework-go/workflow" + "github.com/microsoft/agent-framework-go/workflow/inproc" +) + +// newLabeledEchoAgent returns a deterministic agent that emits a single text +// update with the given label regardless of its input. +func newLabeledEchoAgent(id, name, label string) *agent.Agent { + run := func(_ context.Context, _ []*message.Message, _ ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + return func(yield func(*agent.ResponseUpdate, error) bool) { + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + AgentID: id, + AuthorName: name, + Contents: []message.Content{&message.TextContent{Text: label}}, + }, nil) + } + } + return agent.New( + agent.ProviderConfig{ProviderName: "echo", Run: run}, + agent.Config{ID: id, Name: name, DisableFuncAutoCall: true}, + ) +} + +// runBuiltWorkflow is a test helper that runs a pre-built workflow for one +// turn and collects all emitted events. +func runBuiltWorkflow(t *testing.T, wf *workflow.Workflow) []workflow.Event { + t.Helper() + ctx := context.Background() + stream, err := inproc.Lockstep.RunStreaming(ctx, wf, nil) + if err != nil { + t.Fatalf("RunStreaming: %v", err) + } + defer func() { _ = stream.CancelRun() }() + + userMsg := []*message.Message{ + {Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: "hello"}}}, + } + if err := stream.SendMessage(ctx, userMsg); err != nil { + t.Fatalf("SendMessage user msg: %v", err) + } + if err := stream.SendMessage(ctx, workflow.TurnToken{EmitEvents: boolPtr(true)}); err != nil { + t.Fatalf("SendMessage turn token: %v", err) + } + + var events []workflow.Event + for evt, err := range stream.WatchStream(ctx) { + if err != nil { + t.Fatalf("WatchStream: %v", err) + } + events = append(events, evt) + } + return events +} + +// collectOutputTexts returns the text labels emitted as OutputEvents carrying +// *agent.ResponseUpdate payloads. +func collectOutputTexts(events []workflow.Event) []string { + var texts []string + for _, evt := range events { + out, ok := evt.(workflow.OutputEvent) + if !ok { + continue + } + upd, ok := out.Output.(*agent.ResponseUpdate) + if !ok { + continue + } + for _, c := range upd.Contents { + if tc, ok := c.(*message.TextContent); ok { + texts = append(texts, tc.Text) + } + } + } + return texts +} + +// TestBuildSequential_ReturnsErrorForNoAgents checks that BuildSequential +// rejects an empty agent list. +func TestBuildSequential_ReturnsErrorForNoAgents(t *testing.T) { + _, err := workflowhosting.BuildSequential() + if err == nil { + t.Fatal("expected error for zero agents, got nil") + } +} + +// TestBuildConcurrent_ReturnsErrorForNoAgents checks that BuildConcurrent +// rejects an empty agent list. +func TestBuildConcurrent_ReturnsErrorForNoAgents(t *testing.T) { + _, err := workflowhosting.BuildConcurrent() + if err == nil { + t.Fatal("expected error for zero agents, got nil") + } +} + +// TestBuildSequential_SingleAgent verifies that a single-agent sequential +// workflow builds successfully and emits the agent's output. +func TestBuildSequential_SingleAgent(t *testing.T) { + a := newLabeledEchoAgent("a", "A", "from-a") + wf, err := workflowhosting.BuildSequential(a) + if err != nil { + t.Fatalf("BuildSequential: %v", err) + } + + texts := collectOutputTexts(runBuiltWorkflow(t, wf)) + if len(texts) != 1 || texts[0] != "from-a" { + t.Errorf("got texts %v, want [from-a]", texts) + } +} + +// TestBuildSequential_MultiAgent verifies that a multi-agent sequential +// workflow builds successfully and that the last agent's output is emitted. +func TestBuildSequential_MultiAgent(t *testing.T) { + a := newLabeledEchoAgent("a", "A", "from-a") + b := newLabeledEchoAgent("b", "B", "from-b") + c := newLabeledEchoAgent("c", "C", "from-c") + wf, err := workflowhosting.BuildSequential(a, b, c) + if err != nil { + t.Fatalf("BuildSequential: %v", err) + } + + texts := collectOutputTexts(runBuiltWorkflow(t, wf)) + // Only the last agent (c) is the output node, so we expect "from-c". + if len(texts) == 0 { + t.Fatal("expected at least one output text") + } + last := texts[len(texts)-1] + if last != "from-c" { + t.Errorf("last output text = %q, want %q", last, "from-c") + } +} + +// TestBuildConcurrent_SingleAgent verifies that a single-agent concurrent +// workflow builds and emits the agent's output. +func TestBuildConcurrent_SingleAgent(t *testing.T) { + a := newLabeledEchoAgent("a", "A", "from-a") + wf, err := workflowhosting.BuildConcurrent(a) + if err != nil { + t.Fatalf("BuildConcurrent: %v", err) + } + + texts := collectOutputTexts(runBuiltWorkflow(t, wf)) + if len(texts) != 1 || texts[0] != "from-a" { + t.Errorf("got texts %v, want [from-a]", texts) + } +} + +// TestBuildConcurrent_MultiAgent verifies that a multi-agent concurrent +// workflow builds and emits output from all agents. +func TestBuildConcurrent_MultiAgent(t *testing.T) { + a := newLabeledEchoAgent("a", "A", "from-a") + b := newLabeledEchoAgent("b", "B", "from-b") + wf, err := workflowhosting.BuildConcurrent(a, b) + if err != nil { + t.Fatalf("BuildConcurrent: %v", err) + } + + texts := collectOutputTexts(runBuiltWorkflow(t, wf)) + + want := map[string]bool{"from-a": true, "from-b": true} + got := map[string]bool{} + for _, text := range texts { + got[text] = true + } + for label := range want { + if !got[label] { + t.Errorf("missing output %q; got all texts: %v", label, texts) + } + } +} diff --git a/docs/dotnet-go-sdk-feature-comparison.md b/docs/dotnet-go-sdk-feature-comparison.md index 91adaf6f..b3867582 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 only exposes in-memory workflow checkpointing publicly. | -| Agent workflow builders | Sequential, concurrent, handoff, group chat builders. | Manual builder plus `AddChain`, `AddSwitch`, direct/fan-out/fan-in edges; workflow-as-agent and agents-in-workflows examples. | Partial | Go lacks first-class handoff and group chat builders; sequential/concurrent can be composed manually. | +| Agent workflow builders | Sequential, concurrent, handoff, group chat builders. | `workflowhosting.BuildSequential`, `workflowhosting.BuildConcurrent`; manual builder plus `AddChain`, `AddSwitch`, direct/fan-out/fan-in edges; workflow-as-agent and agents-in-workflows examples. | Partial | Go now has first-class sequential and concurrent builders matching .NET's `AgentWorkflowBuilder.BuildSequential`/`BuildConcurrent`. Handoff and group chat builders are 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` with instance-level `CrossRunShareable`, `ExecutorSpec`, `ExecutorSpec.Extend`, `RouteBuilder`, `BindExecutor`, `BindFunc`, `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 in `ExecutorSpec`. | | 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. | From a4525a0e27f9a87f1562c284c8952f1c78749e87 Mon Sep 17 00:00:00 2001 From: qmuntal Date: Wed, 20 May 2026 09:48:53 +0200 Subject: [PATCH 2/3] Add agent workflow builder helpers --- agent/hosting/workflowhosting/builders.go | 233 +++++++--- .../hosting/workflowhosting/builders_test.go | 403 +++++++++++++++++- .../03_agent_workflow_patterns/main.go | 69 +-- message/messageworkflow/messageforwarding.go | 73 ++++ .../messageworkflow/messageforwarding_test.go | 213 +++++++++ .../messageworkflow/messageworkflow_test.go | 2 + workflow/executor.go | 2 +- workflow/executor_test.go | 6 +- workflow/workflow.go | 11 +- 9 files changed, 914 insertions(+), 98 deletions(-) create mode 100644 message/messageworkflow/messageforwarding.go create mode 100644 message/messageworkflow/messageforwarding_test.go diff --git a/agent/hosting/workflowhosting/builders.go b/agent/hosting/workflowhosting/builders.go index 62bcaf42..618d61db 100644 --- a/agent/hosting/workflowhosting/builders.go +++ b/agent/hosting/workflowhosting/builders.go @@ -3,101 +3,228 @@ package workflowhosting import ( - "errors" + "context" + "fmt" "reflect" "github.com/microsoft/agent-framework-go/agent" "github.com/microsoft/agent-framework-go/message" + "github.com/microsoft/agent-framework-go/message/messageworkflow" "github.com/microsoft/agent-framework-go/workflow" ) -// BuildSequential builds a [workflow.Workflow] that runs agents in order: +const ( + aggregateTurnMessagesStateKey = "AggregateTurnMessagesExecutor.State" + concurrentEndExecutorID = "ConcurrentEnd" + outputMessagesExecutorID = "OutputMessages" + outputMessagesStateKey = "OutputMessagesExecutor.State" +) + +// BuildSequential builds a [workflow.Workflow] with the given name that runs agents in order: // each agent's output (and incoming messages) is forwarded as input to the // next, forming a linear pipeline. // +// The name may be empty. +// // Agents are hosted with the default [Config], which enables incoming-message // forwarding and role reassignment so that each agent in the chain sees the // full conversation in the correct roles. -// -// This matches the behaviour of .NET's AgentWorkflowBuilder.BuildSequential. -func BuildSequential(agents ...*agent.Agent) (*workflow.Workflow, error) { - if len(agents) == 0 { - return nil, errors.New("workflowhosting: BuildSequential requires at least one agent") +func BuildSequential(name string, agents ...*agent.Agent) (*workflow.Workflow, error) { + if err := validateBuilderAgents("BuildSequential", agents); err != nil { + return nil, err } // Default Config: message forwarding and role reassignment are both // enabled (zero-value booleans = false means "do NOT disable"). cfg := Config{} bindings := make([]workflow.ExecutorBinding, len(agents)) - for i, a := range agents { - bindings[i] = New(a, cfg) + for index, currentAgent := range agents { + bindings[index] = New(currentAgent, cfg) } - bld := workflow.NewBuilder(bindings[0]) - if len(bindings) > 1 { - bld = bld.AddChain(bindings[0], bindings[1:], false) + bld := workflow.NewBuilder(bindings[0]).WithName(name) + previous := bindings[0] + for _, next := range bindings[1:] { + bld = bld.AddEdge(previous, next) + previous = next } - bld = bld.WithOutputFrom(bindings[len(bindings)-1]) + outputMessages := newOutputMessagesBinding() + bld = bld.AddEdge(previous, outputMessages).WithOutputFrom(outputMessages) return bld.Build() } -// BuildConcurrent builds a [workflow.Workflow] that fans a single input out to -// all agents simultaneously. Each agent runs independently; their outputs are -// emitted as workflow [workflow.OutputEvent]s as they are produced. +func validateBuilderAgents(builderName string, agents []*agent.Agent) error { + if len(agents) == 0 { + return fmt.Errorf("workflowhosting: %s requires at least one agent", builderName) + } + for index, currentAgent := range agents { + if currentAgent == nil { + return fmt.Errorf("workflowhosting: %s agent at index %d is nil", builderName, index) + } + } + return nil +} + +// MessageAggregator combines per-agent message batches into a single batch. +type MessageAggregator func(context.Context, [][]*message.Message) []*message.Message + +// BuildConcurrent builds a [workflow.Workflow] with the given name that fans a +// single input out to all agents simultaneously. Each agent runs independently; +// the workflow output is the last message in each non-empty per-agent batch. // -// Agents are hosted with [Config.DisableMessageForwarding] set to true so that -// each agent only forwards its own output rather than re-broadcasting the -// shared input. Role reassignment remains enabled. +// The name may be empty. // -// This mirrors .NET's AgentWorkflowBuilder.BuildConcurrent. Note that unlike -// the .NET version, Go does not currently batch per-agent outputs via an -// aggregating executor; messages are emitted as they arrive from each agent. -func BuildConcurrent(agents ...*agent.Agent) (*workflow.Workflow, error) { - if len(agents) == 0 { - return nil, errors.New("workflowhosting: BuildConcurrent requires at least one agent") +// Agents are hosted with the default [Config], which enables incoming-message +// forwarding and role reassignment. +func BuildConcurrent(name string, agents ...*agent.Agent) (*workflow.Workflow, error) { + return BuildConcurrentWithAggregator(name, nil, agents...) +} + +// BuildConcurrentWithAggregator builds a concurrent agent workflow using +// aggregator to combine each agent's turn-message batch into the final workflow +// output. If aggregator is nil, the default behavior returns the last message +// in each non-empty batch. +func BuildConcurrentWithAggregator(name string, aggregator MessageAggregator, agents ...*agent.Agent) (*workflow.Workflow, error) { + if err := validateBuilderAgents("BuildConcurrent", agents); err != nil { + return nil, err } - // Agents must not re-forward the shared input; only their own output should - // propagate. Role reassignment is kept enabled so each agent sees the other - // agents' messages as "user" messages. - cfg := Config{DisableMessageForwarding: true} + cfg := Config{} bindings := make([]workflow.ExecutorBinding, len(agents)) - for i, a := range agents { - bindings[i] = New(a, cfg) + for index, currentAgent := range agents { + bindings[index] = New(currentAgent, cfg) + } + + start := newMessageForwardingBinding("Start") + accumulators := make([]workflow.ExecutorBinding, len(bindings)) + for i, binding := range bindings { + accumulators[i] = newAggregateTurnMessagesBinding("Batcher/" + binding.ID) } + end := newConcurrentEndBinding(len(bindings), aggregator) - start := newForwardingBinding("concurrent_start") - bld := workflow.NewBuilder(start) + bld := workflow.NewBuilder(start).WithName(name) bld = bld.AddFanOutEdge(start, bindings) - bld = bld.WithOutputFrom(bindings...) + for i, binding := range bindings { + bld = bld.AddEdge(binding, accumulators[i]) + } + bld = bld.AddFanInBarrierEdge(accumulators, end) + bld = bld.WithOutputFrom(end) return bld.Build() } -// forwardingMarker is a private sentinel type used as the ExecutorType for -// forwardingBinding so it never collides with user-created bindings. -type forwardingMarker struct{} +type aggregateTurnMessagesMarker struct{} + +type messageForwardingMarker struct{} + +type outputMessagesMarker struct{} + +func newMessageForwardingBinding(id string) workflow.ExecutorBinding { + return workflow.ExecutorBinding{ + ID: id, + ExecutorType: reflect.TypeFor[messageForwardingMarker](), + SupportsConcurrentSharedExecution: true, + NewExecutorFunc: func(_ string) (*workflow.Executor, error) { + spec := workflow.ExecutorSpec{} + messageworkflow.ConfigureForwarding(&spec, nil) + return &workflow.Executor{ + ID: id, + ExecutorType: reflect.TypeFor[messageForwardingMarker](), + Spec: spec, + }, nil + }, + } +} -// newForwardingBinding creates an executor binding that forwards every -// received message to all connected executors unchanged. It is used as the -// fan-out start node in [BuildConcurrent]. -func newForwardingBinding(id string) workflow.ExecutorBinding { +func newAggregateTurnMessagesBinding(id string) workflow.ExecutorBinding { return workflow.ExecutorBinding{ ID: id, - ExecutorType: reflect.TypeFor[forwardingMarker](), + ExecutorType: reflect.TypeFor[aggregateTurnMessagesMarker](), + SupportsConcurrentSharedExecution: true, + NewExecutorFunc: func(_ string) (*workflow.Executor, error) { + spec := workflow.ExecutorSpec{ + SendTypes: []reflect.Type{reflect.TypeFor[[]*message.Message]()}, + } + messageworkflow.Configure(&spec, &messageworkflow.Options{ + StateKey: aggregateTurnMessagesStateKey, + DisableAutoSendTurnToken: true, + TakeTurnHandler: func(ctx *workflow.Context, _ workflow.TurnToken, messages []*message.Message) error { + return ctx.SendMessage("", messages) + }, + }) + return &workflow.Executor{ + ID: id, + ExecutorType: reflect.TypeFor[aggregateTurnMessagesMarker](), + Spec: spec, + }, nil + }, + } +} + +func newOutputMessagesBinding() workflow.ExecutorBinding { + return workflow.ExecutorBinding{ + ID: outputMessagesExecutorID, + ExecutorType: reflect.TypeFor[outputMessagesMarker](), + SupportsConcurrentSharedExecution: true, + NewExecutorFunc: func(_ string) (*workflow.Executor, error) { + spec := workflow.ExecutorSpec{ + YieldTypes: []reflect.Type{reflect.TypeFor[[]*message.Message]()}, + } + messageworkflow.Configure(&spec, &messageworkflow.Options{ + StateKey: outputMessagesStateKey, + DisableAutoSendTurnToken: true, + TakeTurnHandler: func(ctx *workflow.Context, _ workflow.TurnToken, messages []*message.Message) error { + return ctx.YieldOutput(messages) + }, + }) + return &workflow.Executor{ + ID: outputMessagesExecutorID, + ExecutorType: reflect.TypeFor[outputMessagesMarker](), + Spec: spec, + }, nil + }, + } +} + +type concurrentEndMarker struct{} + +func newConcurrentEndBinding(expectedInputs int, aggregator MessageAggregator) workflow.ExecutorBinding { + if aggregator == nil { + aggregator = defaultConcurrentMessageAggregator + } + return workflow.ExecutorBinding{ + ID: concurrentEndExecutorID, + ExecutorType: reflect.TypeFor[concurrentEndMarker](), SupportsConcurrentSharedExecution: true, NewExecutorFunc: func(_ string) (*workflow.Executor, error) { + allResults := make([][]*message.Message, 0, expectedInputs) + remaining := expectedInputs + reset := func() { + allResults = make([][]*message.Message, 0, expectedInputs) + remaining = expectedInputs + } return &workflow.Executor{ - ID: id, + ID: concurrentEndExecutorID, + ExecutorType: reflect.TypeFor[concurrentEndMarker](), Spec: workflow.ExecutorSpec{ DisableAutoSendMessageHandlerResultObject: true, DisableAutoYieldOutputHandlerResultObject: true, - SendTypes: []reflect.Type{ - reflect.TypeFor[[]*message.Message](), - reflect.TypeFor[workflow.TurnToken](), + YieldTypes: []reflect.Type{reflect.TypeFor[[]*message.Message]()}, + Reset: func() error { + reset() + return nil }, ConfigureRoutes: func(rb *workflow.RouteBuilder) (*workflow.RouteBuilder, error) { - return rb.AddCatchAll(func(ctx *workflow.Context, msg workflow.PortableValue) (any, error) { - return nil, ctx.SendMessage("", msg.Any()) + return rb.AddHandlerRaw(reflect.TypeFor[[]*message.Message](), nil, func(ctx *workflow.Context, msg any) (any, error) { + allResults = append(allResults, msg.([]*message.Message)) + remaining-- + if remaining == 0 { + results := allResults + reset() + if err := ctx.YieldOutput(aggregator(ctx, results)); err != nil { + return nil, err + } + } + return struct{}{}, nil }), nil }, }, @@ -105,3 +232,13 @@ func newForwardingBinding(id string) workflow.ExecutorBinding { }, } } + +func defaultConcurrentMessageAggregator(_ context.Context, lists [][]*message.Message) []*message.Message { + results := make([]*message.Message, 0, len(lists)) + for _, list := range lists { + if len(list) > 0 { + results = append(results, list[len(list)-1]) + } + } + return results +} diff --git a/agent/hosting/workflowhosting/builders_test.go b/agent/hosting/workflowhosting/builders_test.go index b46b4dea..0f6acf72 100644 --- a/agent/hosting/workflowhosting/builders_test.go +++ b/agent/hosting/workflowhosting/builders_test.go @@ -4,8 +4,13 @@ package workflowhosting_test import ( "context" + "fmt" "iter" + "slices" + "strings" + "sync" "testing" + "time" "github.com/microsoft/agent-framework-go/agent" "github.com/microsoft/agent-framework-go/agent/hosting/workflowhosting" @@ -33,19 +38,115 @@ func newLabeledEchoAgent(id, name, label string) *agent.Agent { ) } +func newDoubleEchoAgent(id string) *agent.Agent { + run := func(_ context.Context, messages []*message.Message, _ ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + return func(yield func(*agent.ResponseUpdate, error) bool) { + inputText := concatenateMessageText(messages) + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + AgentID: id, + AuthorName: id, + Contents: []message.Content{ + &message.TextContent{Text: id + inputText + inputText}, + }, + }, nil) + } + } + return agent.New( + agent.ProviderConfig{ProviderName: "double-echo", Run: run}, + agent.Config{ID: id, Name: id, DisableFuncAutoCall: true}, + ) +} + +type runBarrier struct { + mu sync.Mutex + remaining int + ready chan struct{} +} + +func (b *runBarrier) reset(count int) { + b.mu.Lock() + defer b.mu.Unlock() + b.remaining = count + b.ready = make(chan struct{}) +} + +func (b *runBarrier) wait(ctx context.Context) error { + b.mu.Lock() + ready := b.ready + b.remaining-- + if b.remaining == 0 { + close(ready) + } + b.mu.Unlock() + + select { + case <-ready: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func newBarrierDoubleEchoAgent(id string, barrier *runBarrier) *agent.Agent { + run := func(ctx context.Context, messages []*message.Message, _ ...agent.Option) iter.Seq2[*agent.ResponseUpdate, error] { + return func(yield func(*agent.ResponseUpdate, error) bool) { + if err := barrier.wait(ctx); err != nil { + yield(nil, err) + return + } + inputText := concatenateMessageText(messages) + yield(&agent.ResponseUpdate{ + Role: message.RoleAssistant, + AgentID: id, + AuthorName: id, + Contents: []message.Content{ + &message.TextContent{Text: id + inputText + inputText}, + }, + }, nil) + } + } + return agent.New( + agent.ProviderConfig{ProviderName: "barrier-double-echo", Run: run}, + agent.Config{ID: id, Name: id, DisableFuncAutoCall: true}, + ) +} + +func concatenateMessageText(messages []*message.Message) string { + var builder strings.Builder + for _, msg := range messages { + for _, content := range msg.Contents { + if textContent, ok := content.(*message.TextContent); ok { + builder.WriteString(textContent.Text) + } + } + } + return builder.String() +} + // runBuiltWorkflow is a test helper that runs a pre-built workflow for one // turn and collects all emitted events. func runBuiltWorkflow(t *testing.T, wf *workflow.Workflow) []workflow.Event { t.Helper() - ctx := context.Background() + return runBuiltWorkflowWithText(t, wf, "hello") +} + +func runBuiltWorkflowWithText(t *testing.T, wf *workflow.Workflow, inputText string) []workflow.Event { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() stream, err := inproc.Lockstep.RunStreaming(ctx, wf, nil) if err != nil { t.Fatalf("RunStreaming: %v", err) } - defer func() { _ = stream.CancelRun() }() + defer func() { + if err := stream.Close(ctx); err != nil { + t.Errorf("Close: %v", err) + } + }() userMsg := []*message.Message{ - {Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: "hello"}}}, + {Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: inputText}}}, } if err := stream.SendMessage(ctx, userMsg); err != nil { t.Fatalf("SendMessage user msg: %v", err) @@ -64,6 +165,54 @@ func runBuiltWorkflow(t *testing.T, wf *workflow.Workflow) []workflow.Event { return events } +func runStreamingWorkflowTurn(t *testing.T, ctx context.Context, stream *inproc.StreamingRun, inputText string) []workflow.Event { + t.Helper() + userMsg := []*message.Message{ + {Role: message.RoleUser, Contents: []message.Content{&message.TextContent{Text: inputText}}}, + } + if err := stream.SendMessage(ctx, userMsg); err != nil { + t.Fatalf("SendMessage user msg: %v", err) + } + if err := stream.SendMessage(ctx, workflow.TurnToken{EmitEvents: boolPtr(true)}); err != nil { + t.Fatalf("SendMessage turn token: %v", err) + } + + var events []workflow.Event + for evt, err := range stream.WatchStream(ctx) { + if err != nil { + t.Fatalf("WatchStream: %v", err) + } + events = append(events, evt) + } + return events +} + +func collectOutputMessages(events []workflow.Event) []*message.Message { + var messages []*message.Message + for _, evt := range events { + out, ok := evt.(workflow.OutputEvent) + if !ok { + continue + } + if outputMessages, ok := out.Output.([]*message.Message); ok { + messages = outputMessages + } + } + return messages +} + +func collectMessageTexts(messages []*message.Message) []string { + texts := make([]string, 0, len(messages)) + for _, msg := range messages { + for _, content := range msg.Contents { + if textContent, ok := content.(*message.TextContent); ok { + texts = append(texts, textContent.Text) + } + } + } + return texts +} + // collectOutputTexts returns the text labels emitted as OutputEvents carrying // *agent.ResponseUpdate payloads. func collectOutputTexts(events []workflow.Event) []string { @@ -89,29 +238,74 @@ func collectOutputTexts(events []workflow.Event) []string { // TestBuildSequential_ReturnsErrorForNoAgents checks that BuildSequential // rejects an empty agent list. func TestBuildSequential_ReturnsErrorForNoAgents(t *testing.T) { - _, err := workflowhosting.BuildSequential() + _, err := workflowhosting.BuildSequential("") if err == nil { t.Fatal("expected error for zero agents, got nil") } } +func TestBuildSequential_ReturnsErrorForNilAgent(t *testing.T) { + validAgent := newLabeledEchoAgent("a", "A", "from-a") + for _, tt := range []struct { + name string + agents []*agent.Agent + }{ + {name: "only_nil", agents: []*agent.Agent{nil}}, + {name: "second_nil", agents: []*agent.Agent{validAgent, nil}}, + } { + t.Run(tt.name, func(t *testing.T) { + _, err := workflowhosting.BuildSequential("", tt.agents...) + if err == nil { + t.Fatal("expected error for nil agent, got nil") + } + if !strings.Contains(err.Error(), "nil") { + t.Fatalf("error = %q, want it to mention nil", err.Error()) + } + }) + } +} + // TestBuildConcurrent_ReturnsErrorForNoAgents checks that BuildConcurrent // rejects an empty agent list. func TestBuildConcurrent_ReturnsErrorForNoAgents(t *testing.T) { - _, err := workflowhosting.BuildConcurrent() + _, err := workflowhosting.BuildConcurrent("") if err == nil { t.Fatal("expected error for zero agents, got nil") } } +func TestBuildConcurrent_ReturnsErrorForNilAgent(t *testing.T) { + validAgent := newLabeledEchoAgent("a", "A", "from-a") + for _, tt := range []struct { + name string + agents []*agent.Agent + }{ + {name: "only_nil", agents: []*agent.Agent{nil}}, + {name: "second_nil", agents: []*agent.Agent{validAgent, nil}}, + } { + t.Run(tt.name, func(t *testing.T) { + _, err := workflowhosting.BuildConcurrent("", tt.agents...) + if err == nil { + t.Fatal("expected error for nil agent, got nil") + } + if !strings.Contains(err.Error(), "nil") { + t.Fatalf("error = %q, want it to mention nil", err.Error()) + } + }) + } +} + // TestBuildSequential_SingleAgent verifies that a single-agent sequential // workflow builds successfully and emits the agent's output. func TestBuildSequential_SingleAgent(t *testing.T) { a := newLabeledEchoAgent("a", "A", "from-a") - wf, err := workflowhosting.BuildSequential(a) + wf, err := workflowhosting.BuildSequential("single-agent", a) if err != nil { t.Fatalf("BuildSequential: %v", err) } + if wf.Name != "single-agent" { + t.Fatalf("workflow name = %q, want %q", wf.Name, "single-agent") + } texts := collectOutputTexts(runBuiltWorkflow(t, wf)) if len(texts) != 1 || texts[0] != "from-a" { @@ -125,10 +319,13 @@ func TestBuildSequential_MultiAgent(t *testing.T) { a := newLabeledEchoAgent("a", "A", "from-a") b := newLabeledEchoAgent("b", "B", "from-b") c := newLabeledEchoAgent("c", "C", "from-c") - wf, err := workflowhosting.BuildSequential(a, b, c) + wf, err := workflowhosting.BuildSequential("", a, b, c) if err != nil { t.Fatalf("BuildSequential: %v", err) } + if wf.Name != "" { + t.Fatalf("workflow name = %q, want empty", wf.Name) + } texts := collectOutputTexts(runBuiltWorkflow(t, wf)) // Only the last agent (c) is the output node, so we expect "from-c". @@ -141,19 +338,88 @@ func TestBuildSequential_MultiAgent(t *testing.T) { } } +// TestBuildSequential_AgentsRunInOrder verifies that each agent receives the +// accumulated conversation produced by the agents before it. +func TestBuildSequential_AgentsRunInOrder(t *testing.T) { + for _, numAgents := range []int{1, 2, 3, 4, 5} { + t.Run(fmt.Sprintf("%d_agents", numAgents), func(t *testing.T) { + agents := make([]*agent.Agent, 0, numAgents) + for agentNumber := 1; agentNumber <= numAgents; agentNumber++ { + agents = append(agents, newDoubleEchoAgent(fmt.Sprintf("agent%d", agentNumber))) + } + + wf, err := workflowhosting.BuildSequential("", agents...) + if err != nil { + t.Fatalf("BuildSequential: %v", err) + } + + for range 3 { + const inputText = "abc" + events := runBuiltWorkflowWithText(t, wf, inputText) + texts := collectOutputTexts(events) + want := expectedSequentialDoubleEchoOutputs(numAgents, inputText) + if !slices.Equal(texts, want) { + t.Fatalf("output texts = %v, want %v", texts, want) + } + + resultMessages := collectOutputMessages(events) + wantResultTexts := append([]string{inputText}, want...) + if gotResultTexts := collectMessageTexts(resultMessages); !slices.Equal(gotResultTexts, wantResultTexts) { + t.Fatalf("result texts = %v, want %v", gotResultTexts, wantResultTexts) + } + if len(resultMessages) != numAgents+1 { + t.Fatalf("result count = %d, want %d", len(resultMessages), numAgents+1) + } + if resultMessages[0].Role != message.RoleUser { + t.Fatalf("result[0].Role = %q, want %q", resultMessages[0].Role, message.RoleUser) + } + for resultIndex, resultMessage := range resultMessages[1:] { + wantAuthorName := fmt.Sprintf("agent%d", resultIndex+1) + if resultMessage.Role != message.RoleAssistant { + t.Fatalf("result[%d].Role = %q, want %q", resultIndex+1, resultMessage.Role, message.RoleAssistant) + } + if resultMessage.AuthorName != wantAuthorName { + t.Fatalf("result[%d].AuthorName = %q, want %q", resultIndex+1, resultMessage.AuthorName, wantAuthorName) + } + } + } + }) + } +} + +func expectedSequentialDoubleEchoOutputs(numAgents int, inputText string) []string { + transcript := inputText + outputs := make([]string, 0, numAgents) + for agentNumber := 1; agentNumber <= numAgents; agentNumber++ { + agentID := fmt.Sprintf("agent%d", agentNumber) + outputText := agentID + transcript + transcript + outputs = append(outputs, outputText) + transcript += outputText + } + return outputs +} + // TestBuildConcurrent_SingleAgent verifies that a single-agent concurrent // workflow builds and emits the agent's output. func TestBuildConcurrent_SingleAgent(t *testing.T) { a := newLabeledEchoAgent("a", "A", "from-a") - wf, err := workflowhosting.BuildConcurrent(a) + wf, err := workflowhosting.BuildConcurrent("single-concurrent", a) if err != nil { t.Fatalf("BuildConcurrent: %v", err) } + if wf.Name != "single-concurrent" { + t.Fatalf("workflow name = %q, want %q", wf.Name, "single-concurrent") + } - texts := collectOutputTexts(runBuiltWorkflow(t, wf)) + events := runBuiltWorkflow(t, wf) + texts := collectOutputTexts(events) if len(texts) != 1 || texts[0] != "from-a" { t.Errorf("got texts %v, want [from-a]", texts) } + resultTexts := collectMessageTexts(collectOutputMessages(events)) + if !slices.Equal(resultTexts, []string{"from-a"}) { + t.Errorf("result texts = %v, want [from-a]", resultTexts) + } } // TestBuildConcurrent_MultiAgent verifies that a multi-agent concurrent @@ -161,12 +427,13 @@ func TestBuildConcurrent_SingleAgent(t *testing.T) { func TestBuildConcurrent_MultiAgent(t *testing.T) { a := newLabeledEchoAgent("a", "A", "from-a") b := newLabeledEchoAgent("b", "B", "from-b") - wf, err := workflowhosting.BuildConcurrent(a, b) + wf, err := workflowhosting.BuildConcurrent("", a, b) if err != nil { t.Fatalf("BuildConcurrent: %v", err) } - texts := collectOutputTexts(runBuiltWorkflow(t, wf)) + events := runBuiltWorkflow(t, wf) + texts := collectOutputTexts(events) want := map[string]bool{"from-a": true, "from-b": true} got := map[string]bool{} @@ -178,4 +445,118 @@ func TestBuildConcurrent_MultiAgent(t *testing.T) { t.Errorf("missing output %q; got all texts: %v", label, texts) } } + + resultTexts := collectMessageTexts(collectOutputMessages(events)) + if len(resultTexts) != 2 { + t.Fatalf("result texts = %v, want two messages", resultTexts) + } + for label := range want { + if !slices.Contains(resultTexts, label) { + t.Errorf("missing result %q; got result texts: %v", label, resultTexts) + } + } +} + +func TestBuildConcurrentWithAggregator_UsesCustomAggregator(t *testing.T) { + a := newLabeledEchoAgent("a", "A", "from-a") + b := newLabeledEchoAgent("b", "B", "from-b") + contextWasPresent := false + wf, err := workflowhosting.BuildConcurrentWithAggregator("", func(ctx context.Context, lists [][]*message.Message) []*message.Message { + contextWasPresent = ctx != nil + return []*message.Message{{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.TextContent{Text: fmt.Sprintf("batches:%d", len(lists))}, + }, + }} + }, a, b) + if err != nil { + t.Fatalf("BuildConcurrentWithAggregator: %v", err) + } + + resultTexts := collectMessageTexts(collectOutputMessages(runBuiltWorkflow(t, wf))) + if !slices.Equal(resultTexts, []string{"batches:2"}) { + t.Fatalf("result texts = %v, want [batches:2]", resultTexts) + } + if !contextWasPresent { + t.Fatal("expected aggregator to receive a context") + } +} + +func TestBuildConcurrentWithAggregator_ResetsEndExecutorBetweenTurns(t *testing.T) { + a := newLabeledEchoAgent("a", "A", "from-a") + b := newLabeledEchoAgent("b", "B", "from-b") + callCount := 0 + wf, err := workflowhosting.BuildConcurrentWithAggregator("", func(_ context.Context, lists [][]*message.Message) []*message.Message { + callCount++ + return []*message.Message{{ + Role: message.RoleAssistant, + Contents: []message.Content{ + &message.TextContent{Text: fmt.Sprintf("turn:%d batches:%d", callCount, len(lists))}, + }, + }} + }, a, b) + if err != nil { + t.Fatalf("BuildConcurrentWithAggregator: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + stream, err := inproc.Lockstep.RunStreaming(ctx, wf, nil) + if err != nil { + t.Fatalf("RunStreaming: %v", err) + } + defer func() { + if err := stream.Close(ctx); err != nil { + t.Errorf("Close: %v", err) + } + }() + + first := collectMessageTexts(collectOutputMessages(runStreamingWorkflowTurn(t, ctx, stream, "first"))) + if !slices.Equal(first, []string{"turn:1 batches:2"}) { + t.Fatalf("first result texts = %v, want [turn:1 batches:2]", first) + } + second := collectMessageTexts(collectOutputMessages(runStreamingWorkflowTurn(t, ctx, stream, "second"))) + if !slices.Equal(second, []string{"turn:2 batches:2"}) { + t.Fatalf("second result texts = %v, want [turn:2 batches:2]", second) + } +} + +func TestBuildConcurrent_AgentsRunInParallel(t *testing.T) { + barrier := &runBarrier{} + wf, err := workflowhosting.BuildConcurrent("", + newBarrierDoubleEchoAgent("agent1", barrier), + newBarrierDoubleEchoAgent("agent2", barrier), + ) + if err != nil { + t.Fatalf("BuildConcurrent: %v", err) + } + + for range 3 { + barrier.reset(2) + events := runBuiltWorkflowWithText(t, wf, "abc") + updateText := strings.Join(collectOutputTexts(events), "") + if updateText == "" { + t.Fatal("expected non-empty update text") + } + if count := strings.Count(updateText, "agent1"); count != 1 { + t.Fatalf("agent1 update count = %d in %q, want 1", count, updateText) + } + if count := strings.Count(updateText, "agent2"); count != 1 { + t.Fatalf("agent2 update count = %d in %q, want 1", count, updateText) + } + if count := strings.Count(updateText, "abc"); count != 4 { + t.Fatalf("abc update count = %d in %q, want 4", count, updateText) + } + + resultTexts := collectMessageTexts(collectOutputMessages(events)) + if len(resultTexts) != 2 { + t.Fatalf("result texts = %v, want 2 messages", resultTexts) + } + for _, want := range []string{"agent1abcabc", "agent2abcabc"} { + if !slices.Contains(resultTexts, want) { + t.Fatalf("result texts = %v, missing %q", resultTexts, want) + } + } + } } diff --git a/examples/03-workflows/_start-here/03_agent_workflow_patterns/main.go b/examples/03-workflows/_start-here/03_agent_workflow_patterns/main.go index 98e184e9..54f26799 100644 --- a/examples/03-workflows/_start-here/03_agent_workflow_patterns/main.go +++ b/examples/03-workflows/_start-here/03_agent_workflow_patterns/main.go @@ -10,7 +10,7 @@ import ( "github.com/microsoft/agent-framework-go/agent" "github.com/microsoft/agent-framework-go/agent/hosting/workflowhosting" - "github.com/microsoft/agent-framework-go/agent/provider/openaichatagent" + "github.com/microsoft/agent-framework-go/agent/provider/openaiagent" "github.com/microsoft/agent-framework-go/examples/internal/demo" "github.com/microsoft/agent-framework-go/message" "github.com/microsoft/agent-framework-go/workflow" @@ -38,49 +38,64 @@ func main() { demo.Panic(err) } - run, err := inproc.Default.RunStreaming(context.Background(), wf, message.NewText("Hello, world!")) - if err != nil { + if _, err := runWorkflow(context.Background(), wf, []*message.Message{message.NewText("Hello, world!")}); err != nil { demo.Panic(err) } - defer func() { _ = run.Close(context.Background()) }() +} + +func runWorkflow(ctx context.Context, wf *workflow.Workflow, messages []*message.Message) ([]*message.Message, error) { + run, err := inproc.Default.RunStreaming(ctx, wf, messages) + if err != nil { + return nil, err + } + defer func() { _ = run.Close(ctx) }() emitEvents := true - if err := run.SendMessage(context.Background(), workflow.TurnToken{EmitEvents: &emitEvents}); err != nil { - demo.Panic(err) + if err := run.SendMessage(ctx, workflow.TurnToken{EmitEvents: &emitEvents}); err != nil { + return nil, err } - for evt, err := range run.WatchStream(context.Background()) { + + lastExecutorID := "" + for evt, err := range run.WatchStream(ctx) { if err != nil { - demo.Panic(err) + return nil, err } switch e := evt.(type) { case workflow.OutputEvent: if update, ok := e.Output.(*agent.ResponseUpdate); ok { - demo.Assistantf("%s: %s", e.ExecutorID, update.String()) - } else { - demo.Assistantf("Output: %v", e.Output) + if e.ExecutorID != lastExecutorID { + lastExecutorID = e.ExecutorID + demo.Assistantf("%s", e.ExecutorID) + } + if updateText := update.String(); updateText != "" { + demo.Assistantf("%s", updateText) + } + continue } + if outputMessages, ok := e.Output.([]*message.Message); ok { + return outputMessages, nil + } + case workflow.ErrorEvent: + return nil, e.Error + case workflow.ExecutorFailedEvent: + return nil, fmt.Errorf("executor %q failed: %v", e.ExecutorID, e.Error) } } + return nil, nil } func buildPattern(pattern string) (*workflow.Workflow, error) { - cfg := workflowhosting.Config{DisableMessageForwarding: true, DisableRoleReassignment: true} - french := workflowhosting.New(newTranslationAgent("French"), cfg) - spanish := workflowhosting.New(newTranslationAgent("Spanish"), cfg) - english := workflowhosting.New(newTranslationAgent("English"), cfg) + agents := []*agent.Agent{ + newTranslationAgent("French"), + newTranslationAgent("Spanish"), + newTranslationAgent("English"), + } switch pattern { case "sequential": - return workflow.NewBuilder(french). - AddEdge(french, spanish). - AddEdge(spanish, english). - WithOutputFrom(english). - Build() + return workflowhosting.BuildSequential("", agents...) case "concurrent": - return workflow.NewBuilder(french). - AddFanOutEdge(french, []workflow.ExecutorBinding{spanish, english}). - WithOutputFrom(spanish, english). - Build() + return workflowhosting.BuildConcurrent("", agents...) default: return nil, fmt.Errorf("unknown WORKFLOW_PATTERN %q; use sequential or concurrent", pattern) } @@ -88,14 +103,14 @@ func buildPattern(pattern string) (*workflow.Workflow, error) { func newTranslationAgent(language string) *agent.Agent { token := demo.AzureTokenCredential() - return openaichatagent.New( + return openaiagent.NewChatCompletions( openai.NewClient( azure.WithEndpoint(endpoint, apiVersion), azure.WithTokenCredential(token), ), - openaichatagent.Config{ + openaiagent.Config{ Model: deployment, - Instructions: fmt.Sprintf("Translate the user's text to %s. Return only the translation.", language), + Instructions: fmt.Sprintf("You are a translation assistant who only responds in %s. Respond to any input by outputting the name of the input language and then translating the input to %s.", language, language), Config: agent.Config{ Name: language, Middlewares: []agent.Middleware{logger}, diff --git a/message/messageworkflow/messageforwarding.go b/message/messageworkflow/messageforwarding.go new file mode 100644 index 00000000..54c3fddc --- /dev/null +++ b/message/messageworkflow/messageforwarding.go @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +package messageworkflow + +import ( + "iter" + "reflect" + + "github.com/microsoft/agent-framework-go/message" + "github.com/microsoft/agent-framework-go/workflow" +) + +// ForwardingOptions configures [ConfigureForwarding]. +type ForwardingOptions struct { + // StringMessageRole, when set, enables string input and forwards each string + // as a [message.Message] with this role. + StringMessageRole message.Role +} + +// ConfigureForwarding extends spec with forwarding behavior for messages and +// turn tokens. The configured spec accepts +// [*message.Message], []*message.Message, iter.Seq[*message.Message], and +// [workflow.TurnToken]. If options.StringMessageRole is set, it also accepts +// string and forwards it as a single text message with that role. +func ConfigureForwarding(spec *workflow.ExecutorSpec, options *ForwardingOptions) { + if spec == nil { + panic("messageworkflow: spec is required") + } + + var stringMessageRole message.Role + if options != nil { + stringMessageRole = options.StringMessageRole + } + + forwardingSpec := workflow.ExecutorSpec{ + DisableAutoSendMessageHandlerResultObject: true, + DisableAutoYieldOutputHandlerResultObject: true, + SendTypes: []reflect.Type{ + reflect.TypeFor[*message.Message](), + reflect.TypeFor[[]*message.Message](), + reflect.TypeFor[workflow.TurnToken](), + }, + Reset: func() error { return nil }, + ConfigureRoutes: func(rb *workflow.RouteBuilder) (*workflow.RouteBuilder, error) { + if stringMessageRole != "" { + rb = rb.AddHandlerRaw(reflect.TypeFor[string](), nil, func(ctx *workflow.Context, msg any) (any, error) { + return struct{}{}, ctx.SendMessage("", &message.Message{ + Role: stringMessageRole, + Contents: []message.Content{&message.TextContent{Text: msg.(string)}}, + }) + }) + } + return rb. + AddHandlerRaw(reflect.TypeFor[*message.Message](), nil, func(ctx *workflow.Context, msg any) (any, error) { + return struct{}{}, ctx.SendMessage("", msg.(*message.Message)) + }). + AddHandlerRaw(reflect.TypeFor[[]*message.Message](), nil, func(ctx *workflow.Context, msg any) (any, error) { + return struct{}{}, ctx.SendMessage("", msg.([]*message.Message)) + }). + AddHandlerRaw(reflect.TypeFor[iter.Seq[*message.Message]](), nil, func(ctx *workflow.Context, msg any) (any, error) { + messages := make([]*message.Message, 0) + for message := range msg.(iter.Seq[*message.Message]) { + messages = append(messages, message) + } + return struct{}{}, ctx.SendMessage("", messages) + }). + AddHandlerRaw(reflect.TypeFor[workflow.TurnToken](), nil, func(ctx *workflow.Context, msg any) (any, error) { + return struct{}{}, ctx.SendMessage("", msg.(workflow.TurnToken)) + }), nil + }, + } + spec.Extend(forwardingSpec) +} diff --git a/message/messageworkflow/messageforwarding_test.go b/message/messageworkflow/messageforwarding_test.go new file mode 100644 index 00000000..632fe1d8 --- /dev/null +++ b/message/messageworkflow/messageforwarding_test.go @@ -0,0 +1,213 @@ +// Copyright (c) Microsoft. All rights reserved. + +package messageworkflow_test + +import ( + "iter" + "reflect" + "slices" + "testing" + + "github.com/microsoft/agent-framework-go/message" + "github.com/microsoft/agent-framework-go/message/messageworkflow" + "github.com/microsoft/agent-framework-go/workflow" +) + +const ( + testMessageContent = "TestMessageContent" + customRoleName = "CustomChatRole" +) + +func newForwardingExecutorForTest(options *messageworkflow.ForwardingOptions) *workflow.Executor { + spec := workflow.ExecutorSpec{} + messageworkflow.ConfigureForwarding(&spec, options) + return &workflow.Executor{ID: "start", Spec: spec} +} + +func runForwardMessageTest(t *testing.T, executor *workflow.Executor, msg any) []any { + t.Helper() + var sent []any + ctx := &workflow.Context{ + Context: t.Context(), + AddEvent: func(workflow.Event) error { return nil }, + SendMessage: func(_ string, msg any) error { sent = append(sent, msg); return nil }, + } + + result, err := executor.Execute(ctx, msg) + if err != nil { + t.Fatalf("Execute(%T): %v", msg, err) + } + if result != nil { + t.Fatalf("Execute(%T) result = %#v, want nil", msg, result) + } + return sent +} + +func TestConfigureForwarding_DescribesForwardedTypes(t *testing.T) { + executor := newForwardingExecutorForTest(nil) + protocol := executor.DescribeProtocol() + + wantAccepts := []reflect.Type{ + reflect.TypeFor[*message.Message](), + reflect.TypeFor[[]*message.Message](), + reflect.TypeFor[iter.Seq[*message.Message]](), + reflect.TypeFor[workflow.TurnToken](), + } + for _, typ := range wantAccepts { + if !slices.Contains(protocol.Accepts, typ) { + t.Errorf("Accepts missing %v; got %v", typ, protocol.Accepts) + } + } + if slices.Contains(protocol.Accepts, reflect.TypeFor[string]()) { + t.Errorf("Accepts includes string without StringMessageRole: %v", protocol.Accepts) + } + + wantSends := []reflect.Type{ + reflect.TypeFor[*message.Message](), + reflect.TypeFor[[]*message.Message](), + reflect.TypeFor[workflow.TurnToken](), + } + for _, typ := range wantSends { + if !slices.Contains(protocol.Sends, typ) { + t.Errorf("Sends missing %v; got %v", typ, protocol.Sends) + } + } +} + +func TestConfigureForwarding_DoesNotForwardStringByDefault(t *testing.T) { + executor := newForwardingExecutorForTest(nil) + ctx := &workflow.Context{ + Context: t.Context(), + AddEvent: func(workflow.Event) error { return nil }, + SendMessage: func(_ string, _ any) error { return nil }, + } + + if _, err := executor.Execute(ctx, testMessageContent); err == nil { + t.Fatal("expected string execution to fail when StringMessageRole is not configured") + } +} + +func TestConfigureForwarding_ForwardsStringIfConfigured(t *testing.T) { + tests := []struct { + name string + role message.Role + wantError bool + }{ + {name: "none", wantError: true}, + {name: "user", role: message.RoleUser}, + {name: "assistant", role: message.RoleAssistant}, + {name: "custom", role: message.Role(customRoleName)}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + executor := newForwardingExecutorForTest(&messageworkflow.ForwardingOptions{ + StringMessageRole: test.role, + }) + ctx := &workflow.Context{ + Context: t.Context(), + AddEvent: func(workflow.Event) error { return nil }, + SendMessage: func(_ string, _ any) error { return nil }, + } + + if test.wantError { + if _, err := executor.Execute(ctx, testMessageContent); err == nil { + t.Fatal("expected string execution to fail") + } + return + } + + sent := runForwardMessageTest(t, executor, testMessageContent) + if len(sent) != 1 { + t.Fatalf("sent count = %d, want 1", len(sent)) + } + got, ok := sent[0].(*message.Message) + if !ok { + t.Fatalf("sent[0] = %T, want *message.Message", sent[0]) + } + if got.Role != test.role { + t.Fatalf("role = %q, want %q", got.Role, test.role) + } + if len(got.Contents) != 1 || got.Contents[0].(*message.TextContent).Text != testMessageContent { + t.Fatalf("contents = %#v, want text %q", got.Contents, testMessageContent) + } + }) + } +} + +func TestConfigureForwarding_ForwardsMessageUnmodified(t *testing.T) { + executor := newForwardingExecutorForTest(nil) + testMessage := &message.Message{ + Role: message.Role(customRoleName), + Contents: []message.Content{&message.TextContent{Text: testMessageContent}}, + } + + sent := runForwardMessageTest(t, executor, testMessage) + if len(sent) != 1 { + t.Fatalf("sent count = %d, want 1", len(sent)) + } + if sent[0] != testMessage { + t.Fatalf("sent[0] = %#v, want original message", sent[0]) + } +} + +func TestConfigureForwarding_ForwardsMessageSliceUnmodified(t *testing.T) { + executor := newForwardingExecutorForTest(nil) + testMessages := []*message.Message{ + {Role: message.Role(customRoleName), Contents: []message.Content{&message.TextContent{Text: testMessageContent}}}, + {Role: message.RoleAssistant, Contents: []message.Content{&message.TextContent{Text: "ResponseMessage"}}}, + } + + sent := runForwardMessageTest(t, executor, testMessages) + if len(sent) != 1 { + t.Fatalf("sent count = %d, want 1", len(sent)) + } + got, ok := sent[0].([]*message.Message) + if !ok || !slices.Equal(got, testMessages) { + t.Fatalf("sent[0] = %#v, want original message slice", sent[0]) + } +} + +func TestConfigureForwarding_ForwardsMessageSequenceAsSlice(t *testing.T) { + executor := newForwardingExecutorForTest(nil) + testMessages := []*message.Message{ + {Role: message.Role(customRoleName), Contents: []message.Content{&message.TextContent{Text: testMessageContent}}}, + {Role: message.RoleAssistant, Contents: []message.Content{&message.TextContent{Text: "ResponseMessage"}}}, + } + seq := iter.Seq[*message.Message](func(yield func(*message.Message) bool) { + for _, msg := range testMessages { + if !yield(msg) { + return + } + } + }) + + sent := runForwardMessageTest(t, executor, seq) + if len(sent) != 1 { + t.Fatalf("sent count = %d, want 1", len(sent)) + } + got, ok := sent[0].([]*message.Message) + if !ok || !slices.Equal(got, testMessages) { + t.Fatalf("sent[0] = %#v, want collected message slice", sent[0]) + } + if len(got) > 0 && &got[0] == &testMessages[0] { + t.Fatalf("sent[0] shares slice storage with input sequence") + } +} + +func TestConfigureForwarding_ForwardsTurnTokenUnmodified(t *testing.T) { + for _, emitEvents := range []*bool{nil, boolPtr(false), boolPtr(true)} { + executor := newForwardingExecutorForTest(nil) + token := workflow.TurnToken{EmitEvents: emitEvents} + + sent := runForwardMessageTest(t, executor, token) + if len(sent) != 1 { + t.Fatalf("sent count = %d, want 1", len(sent)) + } + if !reflect.DeepEqual(sent[0], token) { + t.Fatalf("sent[0] = %#v, want %#v", sent[0], token) + } + } +} + +func boolPtr(value bool) *bool { return &value } diff --git a/message/messageworkflow/messageworkflow_test.go b/message/messageworkflow/messageworkflow_test.go index 22aba1b9..9054760e 100644 --- a/message/messageworkflow/messageworkflow_test.go +++ b/message/messageworkflow/messageworkflow_test.go @@ -3,6 +3,7 @@ package messageworkflow_test import ( + "context" "iter" "reflect" "slices" @@ -39,6 +40,7 @@ func createExecutorWithSent(options *messageworkflow.Options) (*workflow.Executo var sent []any ctx := &workflow.Context{ + Context: context.Background(), SendMessage: func(targetID string, message any) error { sent = append(sent, message) return nil diff --git a/workflow/executor.go b/workflow/executor.go index 6812cf1d..6dab71ae 100644 --- a/workflow/executor.go +++ b/workflow/executor.go @@ -276,7 +276,7 @@ func (e *Executor) Execute(ctx *Context, message any) (result any, err error) { telemetry := ctx.telemetry() messageType := NewTypeID(reflect.TypeOf(message)) spanCtx, span := telemetry.StartExecutorProcess( - ctx.GetContext(), + ctx, e.ID, observability.TypeName(e.ExecutorType), messageType.TypeName, diff --git a/workflow/executor_test.go b/workflow/executor_test.go index 42e26593..7f28865c 100644 --- a/workflow/executor_test.go +++ b/workflow/executor_test.go @@ -15,6 +15,7 @@ import ( func TestExecutorSpec_ExtendRoutesAndLifecycleInOrder(t *testing.T) { var calls []string ctx := &workflow.Context{ + Context: t.Context(), AddEvent: func(workflow.Event) error { return nil }, } spec := workflow.ExecutorSpec{ @@ -150,7 +151,7 @@ func TestExecutorSpec_ExtendFinishedRunsAllHooksAndReturnsFirstError(t *testing. }, }) - if err := spec.OnMessageDeliveryFinished(&workflow.Context{}); !errors.Is(err, firstErr) { + if err := spec.OnMessageDeliveryFinished(&workflow.Context{Context: t.Context()}); !errors.Is(err, firstErr) { t.Fatalf("OnMessageDeliveryFinished error = %v, want %v", err, firstErr) } want := []string{"first", "second"} @@ -392,6 +393,7 @@ func TestAddHandlerRaw_WithHandlerOverwrite(t *testing.T) { }, } ctx := &workflow.Context{ + Context: t.Context(), AddEvent: func(workflow.Event) error { return nil }, } @@ -422,6 +424,7 @@ func TestAddCatchAll_WithHandlerOverwrite(t *testing.T) { }, } ctx := &workflow.Context{ + Context: t.Context(), AddEvent: func(workflow.Event) error { return nil }, } @@ -449,6 +452,7 @@ func TestExecutorExecute_HandlerPanicReportsFailure(t *testing.T) { } var events []workflow.Event ctx := &workflow.Context{ + Context: t.Context(), AddEvent: func(evt workflow.Event) error { events = append(events, evt) return nil diff --git a/workflow/workflow.go b/workflow/workflow.go index 0c95e5c7..8847b970 100644 --- a/workflow/workflow.go +++ b/workflow/workflow.go @@ -471,17 +471,8 @@ type Context struct { ConcurrentRunsEnabled bool } -// GetContext returns the underlying context or [context.Background] when ctx or -// its embedded context is nil. -func (ctx *Context) GetContext() context.Context { - if ctx == nil || ctx.Context == nil { - return context.Background() - } - return ctx.Context -} - func (ctx *Context) telemetry() *observability.Context { - return observability.FromContext(ctx.GetContext()) + return observability.FromContext(ctx) } func (ctx *Context) traceContextStrings() map[string]string { From f5945485ad8f236d29345fd93c8d7d90e3ced4db Mon Sep 17 00:00:00 2001 From: qmuntal Date: Wed, 20 May 2026 10:14:44 +0200 Subject: [PATCH 3/3] codereview comments --- agent/hosting/workflowhosting/builders.go | 2 +- agent/hosting/workflowhosting/workflow.go | 12 ++++++------ agent/hosting/workflowhosting/workflow_test.go | 6 +++--- docs/dotnet-go-sdk-feature-comparison.md | 2 +- .../_start-here/02_agents_in_workflows/main.go | 4 ++-- message/messageworkflow/messageforwarding.go | 4 ++-- 6 files changed, 15 insertions(+), 15 deletions(-) diff --git a/agent/hosting/workflowhosting/builders.go b/agent/hosting/workflowhosting/builders.go index 618d61db..a0ee6e26 100644 --- a/agent/hosting/workflowhosting/builders.go +++ b/agent/hosting/workflowhosting/builders.go @@ -85,7 +85,7 @@ func BuildConcurrent(name string, agents ...*agent.Agent) (*workflow.Workflow, e // output. If aggregator is nil, the default behavior returns the last message // in each non-empty batch. func BuildConcurrentWithAggregator(name string, aggregator MessageAggregator, agents ...*agent.Agent) (*workflow.Workflow, error) { - if err := validateBuilderAgents("BuildConcurrent", agents); err != nil { + if err := validateBuilderAgents("BuildConcurrentWithAggregator", agents); err != nil { return nil, err } diff --git a/agent/hosting/workflowhosting/workflow.go b/agent/hosting/workflowhosting/workflow.go index 32b6df0d..78638594 100644 --- a/agent/hosting/workflowhosting/workflow.go +++ b/agent/hosting/workflowhosting/workflow.go @@ -52,20 +52,20 @@ type Config struct { // emitted at the end of each turn. EmitResponseEvents bool - // DisableMessageForwarding disables forwarding of incoming messages + // DisableForwardIncomingMessages disables forwarding of incoming messages // downstream before the agent runs. By default (zero value), incoming // messages are forwarded so downstream nodes observe the full // conversation. Set to true for strict pipelines where each node should // only forward its own output. - DisableMessageForwarding bool + DisableForwardIncomingMessages bool - // DisableRoleReassignment disables rewriting incoming + // DisableReassignOtherAgentsAsUsers disables rewriting incoming // [message.RoleAssistant] messages whose [message.Message.AuthorName] // does not match this agent to [message.RoleUser]. By default (zero // value), such messages are reassigned so the conversation between // agents appears, to each agent, as messages from "the user". Set to // true to preserve original roles. - DisableRoleReassignment bool + DisableReassignOtherAgentsAsUsers bool // InterceptUserInputRequests controls how [message.ToolApprovalRequestContent] // produced by the agent is dispatched. @@ -376,14 +376,14 @@ func (h *hostExecutor) handleExternalResponse(wctx *workflow.Context, resp *work // messages, dispatches outputs and any requests, and propagates the held // TurnToken downstream when no outstanding requests remain. func (h *hostExecutor) runAgentAndDispatch(wctx *workflow.Context, messages []*message.Message) error { - if !h.cfg.DisableMessageForwarding && len(messages) > 0 { + if !h.cfg.DisableForwardIncomingMessages && len(messages) > 0 { if err := wctx.SendMessage("", messages); err != nil { return err } } agentInput := messages - if !h.cfg.DisableRoleReassignment { + if !h.cfg.DisableReassignOtherAgentsAsUsers { agentInput = reassignOtherAgentsAsUsers(messages, agentNameOrID(h.agent)) } diff --git a/agent/hosting/workflowhosting/workflow_test.go b/agent/hosting/workflowhosting/workflow_test.go index 1c16065f..f6c5d235 100644 --- a/agent/hosting/workflowhosting/workflow_test.go +++ b/agent/hosting/workflowhosting/workflow_test.go @@ -276,7 +276,7 @@ func collectForwardedResponseMessages(t *testing.T, a *agent.Agent, cfg workflow } hostCfg := cfg - hostCfg.DisableMessageForwarding = true + hostCfg.DisableForwardIncomingMessages = true binding := workflowhosting.New(a, hostCfg) wf, err := workflow.NewBuilder(binding). AddEdge(binding, sink). @@ -514,7 +514,7 @@ func TestHostedAgent_ReassignsRolesIfConfigured(t *testing.T) { name := fmt.Sprintf("reassign=%v/u=%v/s=%v/o=%v", tc.reassign, tc.includeUser, tc.includeSelf, tc.includeOther) t.Run(name, func(t *testing.T) { cfg := workflowhosting.Config{ - DisableRoleReassignment: !tc.reassign, + DisableReassignOtherAgentsAsUsers: !tc.reassign, } var msgs []*message.Message if tc.includeUser { @@ -608,7 +608,7 @@ func TestHostedAgent_ForwardsIncomingMessages(t *testing.T) { }, nil } - binding := workflowhosting.New(newReplayAgent(), workflowhosting.Config{DisableMessageForwarding: disable}) + binding := workflowhosting.New(newReplayAgent(), workflowhosting.Config{DisableForwardIncomingMessages: disable}) wf, err := workflow.NewBuilder(binding). AddEdge(binding, sink). Build() diff --git a/docs/dotnet-go-sdk-feature-comparison.md b/docs/dotnet-go-sdk-feature-comparison.md index b3867582..8b563dde 100644 --- a/docs/dotnet-go-sdk-feature-comparison.md +++ b/docs/dotnet-go-sdk-feature-comparison.md @@ -81,7 +81,7 @@ Within overlapping features, the main misalignments are API shape and ecosystem | Workflow checkpointing | In-memory and JSON checkpoint managers, custom stores, Cosmos store, checkpoint restore, checkpoint hooks. | In-memory checkpoint manager (`checkpoint.NewInMemoryManager`), JSON+file checkpoint manager (`checkpoint.NewJSONManager` with `checkpoint.FileSystemJSONStore`), custom store interface (`checkpoint.Store[json.RawMessage]`), `WithCheckpointing`, checkpoint restore, checkpoint hooks, resume pending request republish, checkpoint-and-rehydrate example. | Partial | Go lacks a Cosmos store. Custom durable stores can be implemented via the public `checkpoint.Store[json.RawMessage]` interface. | | Workflow state | Shared/private scoped state, state update lifecycle, stateful executors. | Scoped state, `ReadState`, `ReadOrInitState`, `ReadStateKeys`, `QueueStateUpdate`, `ScopeID`, `ScopeKey`, state checkpointing. | Aligned | API naming and state store extensibility differ. | | Workflow external requests/HITL | Request ports, external requests/responses, human-in-the-loop samples, wrapped request support. | `RequestPort`, `ExternalRequest`, `ExternalResponse`, `PostRequest`, `BindRequestPort`, HITL sample, pending request republish. | Partial | Go supports the core flow but lacks .NET's broader wrapped-request/host integration surface. | -| Agent in workflow | `AIAgentBinding`, `AIAgentHostOptions`, response/update events, role reassignment, message forwarding, intercept user-input/function-call requests. | `agent/hosting/workflowhosting.New` with `Config`: update/response events, message forwarding toggle (`DisableMessageForwarding`), role reassignment toggle (`DisableRoleReassignment`), `InterceptUserInputRequests`, `InterceptUnterminatedFunctionCalls`. | Aligned | API shape differs: .NET uses positive-boolean defaults (`ForwardIncomingMessages = true`, `ReassignOtherAgentsAsUsers = true`); Go uses opt-out booleans (`DisableMessageForwarding`, `DisableRoleReassignment`). Feature coverage is equivalent. | +| Agent in workflow | `AIAgentBinding`, `AIAgentHostOptions`, response/update events, role reassignment, message forwarding, intercept user-input/function-call requests. | `agent/hosting/workflowhosting.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. | `agent/provider/workflowprovider.New`. | Aligned | Go chooses in-process environment based on concurrency; .NET is integrated with `AIAgent` extensions. | | Subworkflows | `ConfigureSubWorkflow`, `BindAsExecutor`, subworkflow sample. | Internal subworkflow execution mode; no public binding helper found. | .NET only | Go has implementation pieces but no comparable public feature. | | 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. | diff --git a/examples/03-workflows/_start-here/02_agents_in_workflows/main.go b/examples/03-workflows/_start-here/02_agents_in_workflows/main.go index da456c2d..d6cdef92 100644 --- a/examples/03-workflows/_start-here/02_agents_in_workflows/main.go +++ b/examples/03-workflows/_start-here/02_agents_in_workflows/main.go @@ -33,8 +33,8 @@ var logger = demo.NewLogger( func main() { cfg := workflowhosting.Config{ - DisableMessageForwarding: true, - DisableRoleReassignment: true, + DisableForwardIncomingMessages: true, + DisableReassignOtherAgentsAsUsers: true, } french := workflowhosting.New(newTranslationAgent("French"), cfg) spanish := workflowhosting.New(newTranslationAgent("Spanish"), cfg) diff --git a/message/messageworkflow/messageforwarding.go b/message/messageworkflow/messageforwarding.go index 54c3fddc..284b9af5 100644 --- a/message/messageworkflow/messageforwarding.go +++ b/message/messageworkflow/messageforwarding.go @@ -59,8 +59,8 @@ func ConfigureForwarding(spec *workflow.ExecutorSpec, options *ForwardingOptions }). AddHandlerRaw(reflect.TypeFor[iter.Seq[*message.Message]](), nil, func(ctx *workflow.Context, msg any) (any, error) { messages := make([]*message.Message, 0) - for message := range msg.(iter.Seq[*message.Message]) { - messages = append(messages, message) + for msg := range msg.(iter.Seq[*message.Message]) { + messages = append(messages, msg) } return struct{}{}, ctx.SendMessage("", messages) }).