Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions _catalog/dotnet-go-sdk-symbol-mapping.json
Original file line number Diff line number Diff line change
Expand Up @@ -20071,7 +20071,7 @@
"note": "Go binds the port and accepts wrapped requests, but has no allowWrappedRequests=false setting."
},
"BindAsExecutor(Workflow, string, ExecutorOptions) -> ExecutorBinding": {
"go": "inproc.BindSubworkflowAsExecutor(wf, id)",
"go": "inproc.BindSubworkflowAsExecutor(wf, id, inproc.SubworkflowConfig{})",
"go_symbols": [
"workflow/inproc.BindSubworkflowAsExecutor"
],
Expand Down Expand Up @@ -20310,7 +20310,7 @@
"note": "BindNewExecutorFunc covers the explicit-ID factory alias; its callback takes session ID before executor ID."
},
"ConfigureSubWorkflow(Workflow, string, ExecutorOptions) -> ExecutorBinding": {
"go": "inproc.BindSubworkflowAsExecutor(wf, id)",
"go": "inproc.BindSubworkflowAsExecutor(wf, id, inproc.SubworkflowConfig{})",
"go_symbols": [
"workflow/inproc.BindSubworkflowAsExecutor"
],
Expand Down Expand Up @@ -24002,7 +24002,7 @@
"area": "workflows",
"assembly": "Microsoft.Agents.AI.Workflows",
"mapping": {
"go": "inproc.BindSubworkflowAsExecutor(wf, id)",
"go": "inproc.BindSubworkflowAsExecutor(wf, id, inproc.SubworkflowConfig{})",
"go_symbols": [
"workflow/inproc.BindSubworkflowAsExecutor",
"workflow.ExecutorBinding"
Expand Down Expand Up @@ -24115,7 +24115,7 @@
},
"constructors": {
"SubworkflowBinding(Workflow, string, ExecutorOptions)": {
"go": "inproc.BindSubworkflowAsExecutor(wf, id)",
"go": "inproc.BindSubworkflowAsExecutor(wf, id, inproc.SubworkflowConfig{})",
"go_symbols": [
"workflow/inproc.BindSubworkflowAsExecutor"
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ func main() {
prefix := textTransformExecutor("PrefixExecutor", func(input string) string {
return "INPUT: " + input
})
textProcessingExecutor := inproc.BindSubworkflowAsExecutor(textProcessing, "TextProcessingSubWorkflow")
textProcessingExecutor := inproc.BindSubworkflowAsExecutor(textProcessing, "TextProcessingSubWorkflow", inproc.SubworkflowConfig{})
postProcess := textTransformExecutor("PostProcessExecutor", func(input string) string {
return "[FINAL] " + input + " [END]"
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ func buildPaymentWorkflow(fraudCheckWorkflow *workflow.Workflow) *workflow.Workf
demo.Assistantf(" [Payment/ValidatePayment] Validated payment for %s ($%.2f)", order.OrderID, float64(order.Amount)/100)
return order
}).Bind()
fraudCheck := inproc.BindSubworkflowAsExecutor(fraudCheckWorkflow, "FraudCheck")
fraudCheck := inproc.BindSubworkflowAsExecutor(fraudCheckWorkflow, "FraudCheck", inproc.SubworkflowConfig{})
chargePayment := workflow.NewExecutor("ChargePayment", func(order OrderInfo) OrderInfo {
order.PaymentTransactionID = "TXN-ORD-001"
demo.Assistantf(" [Payment/ChargePayment] Payment processed: %s", order.PaymentTransactionID)
Expand Down Expand Up @@ -153,8 +153,8 @@ func buildOrderProcessingWorkflow(paymentWorkflow *workflow.Workflow, shippingWo
demo.Assistantf("[OrderReceived] Processing order %q", orderID)
return OrderInfo{OrderID: orderID, Amount: 9999}
}).Bind()
payment := inproc.BindSubworkflowAsExecutor(paymentWorkflow, "Payment")
shipping := inproc.BindSubworkflowAsExecutor(shippingWorkflow, "Shipping")
payment := inproc.BindSubworkflowAsExecutor(paymentWorkflow, "Payment", inproc.SubworkflowConfig{})
shipping := inproc.BindSubworkflowAsExecutor(shippingWorkflow, "Shipping", inproc.SubworkflowConfig{})
orderCompleted := workflow.NewExecutor("OrderCompleted", func(order OrderInfo) string {
demo.Assistantf("[OrderCompleted] Payment: %s", order.PaymentTransactionID)
demo.Assistantf("[OrderCompleted] Shipping: %s - %s", order.Carrier, order.TrackingNumber)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ func buildWorkflow() *workflow.Workflow {
child := buildChild(approvePort)

const hostID = "ApprovalSubworkflow"
host := inproc.BindSubworkflowAsExecutor(child, hostID)
host := inproc.BindSubworkflowAsExecutor(child, hostID, inproc.SubworkflowConfig{})

// A nested request port is surfaced to the parent under a qualified ID of the
// form "<subworkflowID>.<portID>", so the escalation port must match it.
Expand Down
2 changes: 1 addition & 1 deletion workflow/inproc/checkpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ func TestCheckpointResume_StreamingPreservesNestedFanInMessages(t *testing.T) {
for _, environment := range checkpointTestEnvironments() {
t.Run(environment.name, func(t *testing.T) {
buildOuter := func() *workflow.Workflow {
host := inproc.BindSubworkflowAsExecutor(checkpointBarrierWorkflow(t, "InnerApproval", "InnerSink", false), "InnerSubworkflow")
host := inproc.BindSubworkflowAsExecutor(checkpointBarrierWorkflow(t, "InnerApproval", "InnerSink", false), "InnerSubworkflow", inproc.SubworkflowConfig{})
port := workflow.RequestPort{ID: "ForwardedInnerApproval", Request: reflect.TypeFor[checkpointApprovalRequest](), Response: reflect.TypeFor[checkpointApprovalReply]()}
wf, err := workflow.NewBuilder(host).AddEdge(host, port.Bind(), workflow.WithEdgeCondition(externalRequestOnly)).
AddEdge(port.Bind(), host, workflow.WithEdgeCondition(externalResponseOnly)).Build()
Expand Down
2 changes: 1 addition & 1 deletion workflow/inproc/sample_resource_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ func sampleResourceWorkflow(t *testing.T) *workflow.Workflow {
})
return pb, nil
})
host := inproc.BindSubworkflowAsExecutor(sampleResourceChild(t), "ResourceWorkflow")
host := inproc.BindSubworkflowAsExecutor(sampleResourceChild(t), "ResourceWorkflow", inproc.SubworkflowConfig{})
cache, policy := sampleResourceGate("ResourceCache", false), sampleResourceGate("QuotaPolicyEngine", true)
resourcePort := workflow.RequestPort{ID: "ResourceRequest", Request: reflect.TypeFor[sampleResourceRequest](), Response: reflect.TypeFor[sampleResourceResponse]()}
policyPort := workflow.RequestPort{ID: "PolicyCheckRequest", Request: reflect.TypeFor[samplePolicyRequest](), Response: reflect.TypeFor[samplePolicyResponse]()}
Expand Down
4 changes: 2 additions & 2 deletions workflow/inproc/sample_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ func sampleTextProcessingWorkflow(t *testing.T) *workflow.Workflow {
if err != nil {
t.Fatal(err)
}
textProcessor := inproc.BindSubworkflowAsExecutor(child, "TextProcessor")
textProcessor := inproc.BindSubworkflowAsExecutor(child, "TextProcessor", inproc.SubworkflowConfig{})
orchestrator := workflow.BindNewExecutorFunc("TextProcessingOrchestrator", func(_ string, id string) (*workflow.Executor, error) {
cache := &workflow.StatefulExecutorCache[*sampleTextState]{StateKey: "State", InitialStateFactory: func() *sampleTextState { return &sampleTextState{Pending: map[string]bool{}} }}
return &workflow.Executor{
Expand Down Expand Up @@ -734,7 +734,7 @@ func sampleEchoSubworkflow(t *testing.T) *workflow.Workflow {
if err != nil {
t.Fatal(err)
}
host := inproc.BindSubworkflowAsExecutor(child, "EchoSubworkflow")
host := inproc.BindSubworkflowAsExecutor(child, "EchoSubworkflow", inproc.SubworkflowConfig{})
parent, err := workflow.NewBuilder(host).WithOutputFrom(host).Build()
if err != nil {
t.Fatal(err)
Expand Down
42 changes: 34 additions & 8 deletions workflow/inproc/subworkflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,25 @@ const (
subworkflowImplementationID = "workflow/subworkflow.Binding"
)

// SubworkflowConfig configures how a hosted subworkflow forwards its outputs.
type SubworkflowConfig struct {
// AutoSendMessageHandlerResultObject controls whether each output of the
// subworkflow is sent as a message to the host's downstream executors.
// Nil means true.
AutoSendMessageHandlerResultObject *bool

// AutoYieldOutputHandlerResultObject controls whether each output of the
// subworkflow is also yielded as an output of the parent workflow. When
// false, the host does not declare the subworkflow's output types as yielded.
// Nil means true.
AutoYieldOutputHandlerResultObject *bool
}

// BindSubworkflowAsExecutor returns an [workflow.ExecutorBinding] that hosts wf
// as a subworkflow with the supplied workflow-unique executor ID.
func BindSubworkflowAsExecutor(wf *workflow.Workflow, id string) workflow.ExecutorBinding {
// as a subworkflow with the supplied workflow-unique executor ID. cfg controls
// whether outputs are sent to downstream executors and/or yielded as parent
// outputs; nil config fields default to true.
func BindSubworkflowAsExecutor(wf *workflow.Workflow, id string, cfg SubworkflowConfig) workflow.ExecutorBinding {
Comment thread
qmuntal marked this conversation as resolved.
Resolved
if wf == nil {
panic("subworkflow: cannot bind nil Workflow as executor")
}
Expand All @@ -42,12 +58,12 @@ func BindSubworkflowAsExecutor(wf *workflow.Workflow, id string) workflow.Execut
RawValue: wf,
SupportsConcurrentSharedExecution: true,
NewExecutorFunc: func(sessionID string) (*workflow.Executor, error) {
return newSubworkflowExecutor(id, wf, sessionID, ownershipToken)
return newSubworkflowExecutor(id, wf, sessionID, ownershipToken, cfg)
},
}
}

func newSubworkflowExecutor(id string, wf *workflow.Workflow, sessionID string, ownershipToken any) (*workflow.Executor, error) {
func newSubworkflowExecutor(id string, wf *workflow.Workflow, sessionID string, ownershipToken any, cfg SubworkflowConfig) (*workflow.Executor, error) {
protocol, err := wf.DescribeProtocol()
if err != nil {
return nil, err
Expand All @@ -58,6 +74,8 @@ func newSubworkflowExecutor(id string, wf *workflow.Workflow, sessionID string,
sessionID: sessionID,
ownershipToken: ownershipToken,
protocol: protocol,
autoSend: cfg.AutoSendMessageHandlerResultObject == nil || *cfg.AutoSendMessageHandlerResultObject,
autoYield: cfg.AutoYieldOutputHandlerResultObject == nil || *cfg.AutoYieldOutputHandlerResultObject,
}
return host.executor(), nil
}
Expand All @@ -68,6 +86,8 @@ type subworkflowHostExecutor struct {
sessionID string
ownershipToken any
protocol workflow.ProtocolDescriptor
autoSend bool
autoYield bool
joinContext *runnerContext

runner *runner
Expand Down Expand Up @@ -198,7 +218,9 @@ func (h *subworkflowHostExecutor) ensureRunSendMessage(ctx *workflow.Context, in

func (h *subworkflowHostExecutor) configureProtocol(pb *workflow.ProtocolBuilder) (*workflow.ProtocolBuilder, error) {
pb.SendsMessageType(h.protocol.Yields...)
pb.YieldsOutputType(h.protocol.Yields...)
if h.autoYield {
pb.YieldsOutputType(h.protocol.Yields...)
}
pb.RouteBuilder.AddCatchAll(h.queueExternalMessage)
return pb, nil
}
Expand Down Expand Up @@ -277,10 +299,14 @@ func (h *subworkflowHostExecutor) forwardWorkflowEvent(ctx *workflow.Context, ev
return nil
}
if h.joinContext != nil {
if err := h.joinContext.sendMessage(ctx, h.id, "", event.Output); err != nil {
return err
if h.autoSend {
if err := h.joinContext.sendMessage(ctx, h.id, "", event.Output); err != nil {
return err
}
}
if h.autoYield {
return h.yieldOutput(ctx, event.Output)
}
return h.yieldOutput(ctx, event.Output)
}
return nil
case workflow.RequestHaltEvent:
Expand Down
78 changes: 64 additions & 14 deletions workflow/inproc/subworkflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func TestSubworkflowBinding_ForwardsOutputsAsParentOutputsAndMessages(t *testing
t.Fatalf("Build child: %v", err)
}

host := inproc.BindSubworkflowAsExecutor(child, "child")
host := inproc.BindSubworkflowAsExecutor(child, "child", inproc.SubworkflowConfig{})
var gotAtSink []dataMessage
sink := workflow.NewExecutor("sink", func(msg dataMessage) {
gotAtSink = append(gotAtSink, msg)
Expand Down Expand Up @@ -61,6 +61,56 @@ func TestSubworkflowBinding_ForwardsOutputsAsParentOutputsAndMessages(t *testing
}
}

func TestSubworkflowBindingWithConfig_OutputForwardingGates(t *testing.T) {
tests := []struct {
name string
cfg inproc.SubworkflowConfig
wantSink int
wantYield int
}{
{"default", inproc.SubworkflowConfig{}, 1, 1},
{"no send", inproc.SubworkflowConfig{AutoSendMessageHandlerResultObject: new(false)}, 0, 1},
{"no yield", inproc.SubworkflowConfig{AutoYieldOutputHandlerResultObject: new(false)}, 1, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
childStart := workflow.NewExecutor("child-start", func(in textMessage) dataMessage {
return dataMessage{Bytes: []byte(in.Text)}
}).Bind()
child, err := workflow.NewBuilder(childStart).WithOutputFrom(childStart).Build()
if err != nil {
t.Fatalf("Build child: %v", err)
}

host := inproc.BindSubworkflowAsExecutor(child, "child", tt.cfg)
sinkCount := 0
sink := workflow.NewExecutor("sink", func(dataMessage) { sinkCount++ }).Bind()
parent, err := workflow.NewBuilder(host).AddEdge(host, sink).WithOutputFrom(host).Build()
if err != nil {
t.Fatalf("Build parent: %v", err)
}
Comment thread
qmuntal marked this conversation as resolved.
Resolved
descriptor, err := parent.DescribeProtocol()
if err != nil {
t.Fatalf("DescribeProtocol: %v", err)
}
if got := containsType(descriptor.Yields, reflect.TypeFor[dataMessage]()); got != (tt.wantYield != 0) {
t.Fatalf("protocol declares dataMessage output = %v, want %v", got, tt.wantYield != 0)
}

run, err := inproc.Lockstep.Run(t.Context(), parent, textMessage{Text: "abc"})
if err != nil {
t.Fatalf("Run: %v", err)
}
if sinkCount != tt.wantSink {
t.Fatalf("sink messages = %d, want %d", sinkCount, tt.wantSink)
}
if got := len(outputEvents(slicesCollect(run.OutgoingEvents()))); got != tt.wantYield {
t.Fatalf("output count = %d, want %d", got, tt.wantYield)
}
})
}
}

func TestSubworkflowBinding_ForwardsWorkflowWarningAsWorkflowWarning(t *testing.T) {
childStart := workflow.NewExecutor("child-start", func(ctx *workflow.Context, _ textMessage) error {
return ctx.AddEvent(workflow.WorkflowWarningEvent{Message: "child warning"})
Expand All @@ -70,7 +120,7 @@ func TestSubworkflowBinding_ForwardsWorkflowWarningAsWorkflowWarning(t *testing.
t.Fatalf("Build child: %v", err)
}

host := inproc.BindSubworkflowAsExecutor(child, "child")
host := inproc.BindSubworkflowAsExecutor(child, "child", inproc.SubworkflowConfig{})
parent, err := workflow.NewBuilder(host).Build()
if err != nil {
t.Fatalf("Build parent: %v", err)
Expand Down Expand Up @@ -121,7 +171,7 @@ func TestSubworkflowBinding_UsesActiveRunnerInputTypes(t *testing.T) {
t.Fatalf("Build child: %v", err)
}

host := inproc.BindSubworkflowAsExecutor(child, "child")
host := inproc.BindSubworkflowAsExecutor(child, "child", inproc.SubworkflowConfig{})
parent, err := workflow.NewBuilder(host).
WithOutputFrom(host).
Build()
Expand Down Expand Up @@ -177,7 +227,7 @@ func TestSubworkflowBinding_QualifiedRequestPortRoundTrip(t *testing.T) {
t.Fatalf("Build child: %v", err)
}

host := inproc.BindSubworkflowAsExecutor(child, "child")
host := inproc.BindSubworkflowAsExecutor(child, "child", inproc.SubworkflowConfig{})
qualifiedPort := workflow.RequestPort{
ID: "child.ask",
Request: port.Request,
Expand Down Expand Up @@ -225,7 +275,7 @@ func TestSubworkflowBinding_RejectsUnqualifiedResponseBeforeChildRun(t *testing.
if err != nil {
t.Fatal(err)
}
host := inproc.BindSubworkflowAsExecutor(child, "child")
host := inproc.BindSubworkflowAsExecutor(child, "child", inproc.SubworkflowConfig{})
response := &workflow.ExternalResponse{
PortInfo: workflow.RequestPortInfo{PortID: "other.port"},
RequestID: "request",
Expand Down Expand Up @@ -311,7 +361,7 @@ func TestSubworkflowBinding_ParentInterceptsChildRequestLocally(t *testing.T) {
}

const hostID = "approver"
host := inproc.BindSubworkflowAsExecutor(child, hostID)
host := inproc.BindSubworkflowAsExecutor(child, hostID, inproc.SubworkflowConfig{})
escalationPort := workflow.RequestPort{
ID: hostID + "." + approvePort.ID,
Request: approvePort.Request,
Expand Down Expand Up @@ -468,7 +518,7 @@ func TestSubworkflowBinding_CheckpointedEchoSampleCanResumeTwice(t *testing.T) {
t.Fatalf("Build subworkflow: %v", err)
}

host := inproc.BindSubworkflowAsExecutor(sub, "EchoSubworkflow")
host := inproc.BindSubworkflowAsExecutor(sub, "EchoSubworkflow", inproc.SubworkflowConfig{})
parent, err := workflow.NewBuilder(host).
WithOutputFrom(host).
Build()
Expand Down Expand Up @@ -512,7 +562,7 @@ func TestSubworkflowBinding_TextProcessingSample(t *testing.T) {
t.Fatalf("Build subworkflow: %v", err)
}

host := inproc.BindSubworkflowAsExecutor(sub, "TextProcessingSubWorkflow")
host := inproc.BindSubworkflowAsExecutor(sub, "TextProcessingSubWorkflow", inproc.SubworkflowConfig{})
prefix := stringTransformBinding("prefix", func(s string) string { return "INPUT: " + s })
post := stringTransformBinding("post", func(s string) string { return "[FINAL] " + s + " [END]" })
parent, err := workflow.NewBuilder(prefix).
Expand Down Expand Up @@ -551,7 +601,7 @@ func TestSubworkflowBinding_NestedOrderProcessingSample(t *testing.T) {
}

validatePayment := orderTransformBinding("ValidatePayment", func(order sampleOrderInfo) sampleOrderInfo { return order })
fraudCheckHost := inproc.BindSubworkflowAsExecutor(fraudCheck, "FraudCheck")
fraudCheckHost := inproc.BindSubworkflowAsExecutor(fraudCheck, "FraudCheck", inproc.SubworkflowConfig{})
chargePayment := orderTransformBinding("ChargePayment", func(order sampleOrderInfo) sampleOrderInfo {
order.PaymentTransactionID = "TXN-ORD-001"
return order
Expand Down Expand Up @@ -579,8 +629,8 @@ func TestSubworkflowBinding_NestedOrderProcessingSample(t *testing.T) {
}

orderReceived := orderReceivedBinding("OrderReceived")
paymentHost := inproc.BindSubworkflowAsExecutor(payment, "Payment")
shippingHost := inproc.BindSubworkflowAsExecutor(shipping, "Shipping")
paymentHost := inproc.BindSubworkflowAsExecutor(payment, "Payment", inproc.SubworkflowConfig{})
shippingHost := inproc.BindSubworkflowAsExecutor(shipping, "Shipping", inproc.SubworkflowConfig{})
orderCompleted := orderCompletedBinding("OrderCompleted")
parent, err := workflow.NewBuilder(orderReceived).
AddEdge(orderReceived, paymentHost).
Expand Down Expand Up @@ -646,7 +696,7 @@ func TestSubworkflowBinding_SharedStateWorksWithinSubworkflow(t *testing.T) {
t.Fatalf("Build subworkflow: %v", err)
}

host := inproc.BindSubworkflowAsExecutor(sub, "internalStateSubworkflow")
host := inproc.BindSubworkflowAsExecutor(sub, "internalStateSubworkflow", inproc.SubworkflowConfig{})
parent, err := workflow.NewBuilder(host).
WithOutputFrom(host).
Build()
Expand Down Expand Up @@ -700,7 +750,7 @@ func TestSubworkflowBinding_SharedStateIsIsolatedAcrossSubworkflowBoundary(t *te
}

textRead := textReadBinding("text-read")
host := inproc.BindSubworkflowAsExecutor(sub, "textTrimSubworkflow")
host := inproc.BindSubworkflowAsExecutor(sub, "textTrimSubworkflow", inproc.SubworkflowConfig{})
charCount := charCountBinding("char-count")
parent, err := workflow.NewBuilder(textRead).
AddEdge(textRead, host).
Expand Down Expand Up @@ -793,7 +843,7 @@ func createCheckpointedSubworkflowRequestWorkflow(t *testing.T) *workflow.Workfl
t.Fatalf("Build child request workflow: %v", err)
}

host := inproc.BindSubworkflowAsExecutor(child, "Subworkflow")
host := inproc.BindSubworkflowAsExecutor(child, "Subworkflow", inproc.SubworkflowConfig{})
qualifiedPort := workflow.RequestPort{
ID: "ForwardedSubworkflowRequest",
Request: innerPort.Request,
Expand Down
Loading