diff --git a/agent/hosting/workflowhosting/builders.go b/agent/hosting/workflowhosting/builders.go new file mode 100644 index 00000000..a0ee6e26 --- /dev/null +++ b/agent/hosting/workflowhosting/builders.go @@ -0,0 +1,244 @@ +// Copyright (c) Microsoft. All rights reserved. + +package workflowhosting + +import ( + "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" +) + +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. +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 index, currentAgent := range agents { + bindings[index] = New(currentAgent, cfg) + } + + bld := workflow.NewBuilder(bindings[0]).WithName(name) + previous := bindings[0] + for _, next := range bindings[1:] { + bld = bld.AddEdge(previous, next) + previous = next + } + outputMessages := newOutputMessagesBinding() + bld = bld.AddEdge(previous, outputMessages).WithOutputFrom(outputMessages) + return bld.Build() +} + +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. +// +// The name may be empty. +// +// 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("BuildConcurrentWithAggregator", agents); err != nil { + return nil, err + } + + cfg := Config{} + bindings := make([]workflow.ExecutorBinding, len(agents)) + 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) + + bld := workflow.NewBuilder(start).WithName(name) + bld = bld.AddFanOutEdge(start, bindings) + for i, binding := range bindings { + bld = bld.AddEdge(binding, accumulators[i]) + } + bld = bld.AddFanInBarrierEdge(accumulators, end) + bld = bld.WithOutputFrom(end) + return bld.Build() +} + +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 + }, + } +} + +func newAggregateTurnMessagesBinding(id string) workflow.ExecutorBinding { + return workflow.ExecutorBinding{ + ID: id, + 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: concurrentEndExecutorID, + ExecutorType: reflect.TypeFor[concurrentEndMarker](), + Spec: workflow.ExecutorSpec{ + DisableAutoSendMessageHandlerResultObject: true, + DisableAutoYieldOutputHandlerResultObject: true, + YieldTypes: []reflect.Type{reflect.TypeFor[[]*message.Message]()}, + Reset: func() error { + reset() + return nil + }, + ConfigureRoutes: func(rb *workflow.RouteBuilder) (*workflow.RouteBuilder, error) { + 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 + }, + }, + }, nil + }, + } +} + +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 new file mode 100644 index 00000000..0f6acf72 --- /dev/null +++ b/agent/hosting/workflowhosting/builders_test.go @@ -0,0 +1,562 @@ +// Copyright (c) Microsoft. All rights reserved. + +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" + "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}, + ) +} + +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() + 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() { + if err := stream.Close(ctx); err != nil { + t.Errorf("Close: %v", err) + } + }() + + 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 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 { + 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") + } +} + +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("") + 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("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" { + 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) + } + 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". + 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") + } +} + +// 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("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") + } + + 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 +// 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) + } + + events := runBuiltWorkflow(t, wf) + texts := collectOutputTexts(events) + + 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) + } + } + + 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/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 91adaf6f..8b563dde 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. | @@ -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/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..284b9af5 --- /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 msg := range msg.(iter.Seq[*message.Message]) { + messages = append(messages, msg) + } + 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 {