From 1276f83b46b38cc241614ebc4401720f5f1fc4ab Mon Sep 17 00:00:00 2001 From: Dmitry Vyukov Date: Thu, 15 Jan 2026 11:37:02 +0100 Subject: pkg/aflow: add ability to generate several candidate replies for LLM agents Add LLMAgent.Candidates parameter. If set to a value N>1, then the agent is invoked N times, and all outputs become slices. The results can be later aggregated by another agent, as shown in the test. --- pkg/aflow/flow_test.go | 450 ++++++++++++++++++++++++++++++++----- pkg/aflow/llm_agent.go | 96 ++++++-- pkg/aflow/template.go | 4 + pkg/aflow/template_test.go | 11 + pkg/aflow/trajectory/trajectory.go | 4 + pkg/aflow/verify.go | 6 + 6 files changed, 497 insertions(+), 74 deletions(-) diff --git a/pkg/aflow/flow_test.go b/pkg/aflow/flow_test.go index 19abce6b1..5c038d7c6 100644 --- a/pkg/aflow/flow_test.go +++ b/pkg/aflow/flow_test.go @@ -5,6 +5,7 @@ package aflow import ( "context" + "fmt" "path/filepath" "testing" "time" @@ -22,10 +23,13 @@ func TestWorkflow(t *testing.T) { InBaz string } type flowOutputs struct { - OutFoo string - OutBar int - OutBaz string - AgentFoo int + OutFoo string + OutBar int + OutBaz string + AgentFoo int + OutSwarm []string + SwarmInt []int + OutAggregator string } type firstFuncInputs struct { InFoo int @@ -68,11 +72,24 @@ func TestWorkflow(t *testing.T) { type tool2Results struct { ResBaz int `jsonschema:"baz"` } + type swarmOutputs struct { + SwarmInt int `jsonschema:"swarm-int"` + SwarmStr string `jsonschema:"swarm-str"` + } inputs := map[string]any{ "InFoo": 10, "InBar": "bar", "InBaz": "baz", } + expectedOutputs := map[string]any{ + "AgentFoo": 42, + "OutBar": 142, + "OutBaz": "baz", + "OutFoo": "hello, world!", + "OutSwarm": []string{"swarm candidate 1", "swarm candidate 2"}, + "SwarmInt": []int{1, 2}, + "OutAggregator": "aggregated", + } flows := make(map[string]*Flow) err := register[flowInputs, flowOutputs]("test", "description", flows, []*Flow{ { @@ -124,6 +141,29 @@ func TestWorkflow(t *testing.T) { OutBaz: "baz", }, nil }), + &LLMAgent{ + Name: "swarm", + Reply: "OutSwarm", + Candidates: 2, + Outputs: LLMOutputs[swarmOutputs](), + Temperature: 0, + Instruction: "Do something. {{.InBaz}}", + Prompt: "Prompt: {{.InBaz}}", + }, + &LLMAgent{ + Name: "aggregator", + Reply: "OutAggregator", + Temperature: 0, + Instruction: "Aggregate!", + Prompt: `Prompt: {{.InBaz}} +{{range $i, $v := .OutSwarm}}#{{$i}}: {{$v}} +{{end}} +{{range $i, $v := .SwarmInt}}#{{$i}}: {{$v}} +{{end}} +{{range $i, $v := .SwarmStr}}#{{$i}}: {{$v}} +{{end}} +`, + }, ), }, }) @@ -138,19 +178,26 @@ func TestWorkflow(t *testing.T) { }, generateContent: func(cfg *genai.GenerateContentConfig, req []*genai.Content) ( *genai.GenerateContentResponse, error) { - assert.Equal(t, cfg.SystemInstruction, genai.NewContentFromText(`You are smarty. baz - -Use set-results tool to provide results of the analysis. -It must be called exactly once before the final reply. -Ignore results of this tool. -`, genai.RoleUser)) - assert.Equal(t, cfg.Temperature, genai.Ptr[float32](0)) - assert.Equal(t, len(cfg.Tools), 3) - assert.Equal(t, cfg.Tools[0].FunctionDeclarations[0].Name, "tool1") - assert.Equal(t, cfg.Tools[0].FunctionDeclarations[0].Description, "tool 1 description") - assert.Equal(t, cfg.Tools[1].FunctionDeclarations[0].Name, "tool2") - assert.Equal(t, cfg.Tools[1].FunctionDeclarations[0].Description, "tool 2 description") - assert.Equal(t, cfg.Tools[2].FunctionDeclarations[0].Name, "set-results") + replySeq++ + if replySeq < 4 { + assert.Equal(t, cfg.SystemInstruction, genai.NewContentFromText("You are smarty. baz"+ + llmOutputsInstruction, genai.RoleUser)) + assert.Equal(t, cfg.Temperature, genai.Ptr[float32](0)) + assert.Equal(t, len(cfg.Tools), 3) + assert.Equal(t, cfg.Tools[0].FunctionDeclarations[0].Name, "tool1") + assert.Equal(t, cfg.Tools[0].FunctionDeclarations[0].Description, "tool 1 description") + assert.Equal(t, cfg.Tools[1].FunctionDeclarations[0].Name, "tool2") + assert.Equal(t, cfg.Tools[1].FunctionDeclarations[0].Description, "tool 2 description") + assert.Equal(t, cfg.Tools[2].FunctionDeclarations[0].Name, "set-results") + } else if replySeq < 8 { + assert.Equal(t, cfg.SystemInstruction, genai.NewContentFromText("Do something. baz"+ + llmOutputsInstruction, genai.RoleUser)) + assert.Equal(t, len(cfg.Tools), 1) + assert.Equal(t, cfg.Tools[0].FunctionDeclarations[0].Name, "set-results") + } else { + assert.Equal(t, cfg.SystemInstruction, genai.NewContentFromText("Aggregate!", genai.RoleUser)) + assert.Equal(t, len(cfg.Tools), 0) + } reply1 := &genai.Content{ Role: string(genai.RoleModel), @@ -239,7 +286,42 @@ Ignore results of this tool. }, }} - replySeq++ + // dupl considers makeSwarmReply/makeSwarmResp duplicates + // nolint:dupl + makeSwarmReply := func(index int) *genai.Content { + return &genai.Content{ + Role: string(genai.RoleModel), + Parts: []*genai.Part{ + { + FunctionCall: &genai.FunctionCall{ + ID: fmt.Sprintf("id%v", index), + Name: "set-results", + Args: map[string]any{ + "SwarmInt": index, + "SwarmStr": fmt.Sprintf("swarm%v", index), + }, + }, + }, + }} + } + // nolint:dupl // dupl considers makeSwarmReply/makeSwarmResp duplicates + makeSwarmResp := func(index int) *genai.Content { + return &genai.Content{ + Role: string(genai.RoleUser), + Parts: []*genai.Part{ + { + FunctionResponse: &genai.FunctionResponse{ + ID: fmt.Sprintf("id%v", index), + Name: "set-results", + Response: map[string]any{ + "SwarmInt": index, + "SwarmStr": fmt.Sprintf("swarm%v", index), + }, + }, + }, + }} + } + switch replySeq { case 1: assert.Equal(t, req, []*genai.Content{ @@ -270,7 +352,48 @@ Ignore results of this tool. Parts: []*genai.Part{ genai.NewPartFromText("hello, world!")}, }}}}, nil + case 4, 6: + index := (replySeq - 2) / 2 + assert.Equal(t, req, []*genai.Content{ + genai.NewContentFromText("Prompt: baz", genai.RoleUser), + }) + return &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{{Content: makeSwarmReply(index)}}}, nil + case 5, 7: + index := (replySeq - 3) / 2 + assert.Equal(t, req, []*genai.Content{ + genai.NewContentFromText("Prompt: baz", genai.RoleUser), + makeSwarmReply(index), + makeSwarmResp(index), + }) + return &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + {Content: &genai.Content{ + Role: string(genai.RoleUser), + Parts: []*genai.Part{ + genai.NewPartFromText(fmt.Sprintf("swarm candidate %v", index))}, + }}}}, nil + case 8: + assert.Equal(t, req, []*genai.Content{ + genai.NewContentFromText(`Prompt: baz +#0: swarm candidate 1 +#1: swarm candidate 2 + +#0: 1 +#1: 2 +#0: swarm1 +#1: swarm2 + +`, genai.RoleUser), + }) + return &genai.GenerateContentResponse{ + Candidates: []*genai.Candidate{ + {Content: &genai.Content{ + Role: string(genai.RoleUser), + Parts: []*genai.Part{ + genai.NewPartFromText("aggregated")}, + }}}}, nil default: t.Fatal("unexpected LLM calls") return nil, nil @@ -310,18 +433,13 @@ Ignore results of this tool. }, }, { - Seq: 2, - Nesting: 1, - Type: trajectory.SpanAgent, - Name: "smarty", - Started: startTime.Add(4 * time.Second), - Instruction: `You are smarty. baz - -Use set-results tool to provide results of the analysis. -It must be called exactly once before the final reply. -Ignore results of this tool. -`, - Prompt: "Prompt: baz func-output", + Seq: 2, + Nesting: 1, + Type: trajectory.SpanAgent, + Name: "smarty", + Started: startTime.Add(4 * time.Second), + Instruction: "You are smarty. baz" + llmOutputsInstruction, + Prompt: "Prompt: baz func-output", }, { Seq: 3, @@ -449,20 +567,15 @@ Ignore results of this tool. Finished: startTime.Add(16 * time.Second), }, { - Seq: 2, - Nesting: 1, - Type: trajectory.SpanAgent, - Name: "smarty", - Started: startTime.Add(4 * time.Second), - Finished: startTime.Add(17 * time.Second), - Instruction: `You are smarty. baz - -Use set-results tool to provide results of the analysis. -It must be called exactly once before the final reply. -Ignore results of this tool. -`, - Prompt: "Prompt: baz func-output", - Reply: "hello, world!", + Seq: 2, + Nesting: 1, + Type: trajectory.SpanAgent, + Name: "smarty", + Started: startTime.Add(4 * time.Second), + Finished: startTime.Add(17 * time.Second), + Instruction: "You are smarty. baz" + llmOutputsInstruction, + Prompt: "Prompt: baz func-output", + Reply: "hello, world!", Results: map[string]any{ "AgentBar": "agent-bar", "AgentFoo": 42, @@ -486,19 +599,246 @@ Ignore results of this tool. "OutBaz": "baz", }, }, + { + Seq: 10, + Nesting: 1, + Type: trajectory.SpanAgentCandidates, + Name: "swarm", + Started: startTime.Add(20 * time.Second), + }, + { + Seq: 11, + Nesting: 2, + Type: trajectory.SpanAgent, + Name: "swarm", + Started: startTime.Add(21 * time.Second), + Instruction: "Do something. baz" + llmOutputsInstruction, + Prompt: "Prompt: baz", + }, + { + Seq: 12, + Nesting: 3, + Type: trajectory.SpanLLM, + Name: "swarm", + Started: startTime.Add(22 * time.Second), + }, + { + Seq: 12, + Nesting: 3, + Type: trajectory.SpanLLM, + Name: "swarm", + Started: startTime.Add(22 * time.Second), + Finished: startTime.Add(23 * time.Second), + }, + { + Seq: 13, + Nesting: 3, + Type: trajectory.SpanTool, + Name: "set-results", + Started: startTime.Add(24 * time.Second), + Args: map[string]any{ + "SwarmInt": 1, + "SwarmStr": "swarm1", + }, + }, + { + Seq: 13, + Nesting: 3, + Type: trajectory.SpanTool, + Name: "set-results", + Started: startTime.Add(24 * time.Second), + Finished: startTime.Add(25 * time.Second), + Args: map[string]any{ + "SwarmInt": 1, + "SwarmStr": "swarm1", + }, + Results: map[string]any{ + "SwarmInt": 1, + "SwarmStr": "swarm1", + }, + }, + { + Seq: 14, + Nesting: 3, + Type: trajectory.SpanLLM, + Name: "swarm", + Started: startTime.Add(26 * time.Second), + }, + { + Seq: 14, + Nesting: 3, + Type: trajectory.SpanLLM, + Name: "swarm", + Started: startTime.Add(26 * time.Second), + Finished: startTime.Add(27 * time.Second), + }, + { + Seq: 11, + Nesting: 2, + Type: trajectory.SpanAgent, + Name: "swarm", + Started: startTime.Add(21 * time.Second), + Finished: startTime.Add(28 * time.Second), + Instruction: "Do something. baz" + llmOutputsInstruction, + Prompt: "Prompt: baz", + Reply: "swarm candidate 1", + Results: map[string]any{ + "SwarmInt": 1, + "SwarmStr": "swarm1", + }, + }, + { + Seq: 15, + Nesting: 2, + Type: trajectory.SpanAgent, + Name: "swarm", + Started: startTime.Add(29 * time.Second), + Instruction: "Do something. baz" + llmOutputsInstruction, + Prompt: "Prompt: baz", + }, + { + Seq: 16, + Nesting: 3, + Type: trajectory.SpanLLM, + Name: "swarm", + Started: startTime.Add(30 * time.Second), + }, + { + Seq: 16, + Nesting: 3, + Type: trajectory.SpanLLM, + Name: "swarm", + Started: startTime.Add(30 * time.Second), + Finished: startTime.Add(31 * time.Second), + }, + { + Seq: 17, + Nesting: 3, + Type: trajectory.SpanTool, + Name: "set-results", + Started: startTime.Add(32 * time.Second), + Args: map[string]any{ + "SwarmInt": 2, + "SwarmStr": "swarm2", + }, + }, + { + Seq: 17, + Nesting: 3, + Type: trajectory.SpanTool, + Name: "set-results", + Started: startTime.Add(32 * time.Second), + Finished: startTime.Add(33 * time.Second), + Args: map[string]any{ + "SwarmInt": 2, + "SwarmStr": "swarm2", + }, + Results: map[string]any{ + "SwarmInt": 2, + "SwarmStr": "swarm2", + }, + }, + { + Seq: 18, + Nesting: 3, + Type: trajectory.SpanLLM, + Name: "swarm", + Started: startTime.Add(34 * time.Second), + }, + { + Seq: 18, + Nesting: 3, + Type: trajectory.SpanLLM, + Name: "swarm", + Started: startTime.Add(34 * time.Second), + Finished: startTime.Add(35 * time.Second), + }, + { + Seq: 15, + Nesting: 2, + Type: trajectory.SpanAgent, + Name: "swarm", + Started: startTime.Add(29 * time.Second), + Finished: startTime.Add(36 * time.Second), + Instruction: "Do something. baz" + llmOutputsInstruction, + Prompt: "Prompt: baz", + Reply: "swarm candidate 2", + Results: map[string]any{ + "SwarmInt": 2, + "SwarmStr": "swarm2", + }, + }, + { + Seq: 10, + Nesting: 1, + Type: trajectory.SpanAgentCandidates, + Name: "swarm", + Started: startTime.Add(20 * time.Second), + Finished: startTime.Add(37 * time.Second), + }, + { + Seq: 19, + Nesting: 1, + Type: trajectory.SpanAgent, + Name: "aggregator", + Started: startTime.Add(38 * time.Second), + Instruction: "Aggregate!", + Prompt: `Prompt: baz +#0: swarm candidate 1 +#1: swarm candidate 2 + +#0: 1 +#1: 2 + +#0: swarm1 +#1: swarm2 + +`, + }, + { + Seq: 20, + Nesting: 2, + Type: trajectory.SpanLLM, + Name: "aggregator", + Started: startTime.Add(39 * time.Second), + }, + { + Seq: 20, + Nesting: 2, + Type: trajectory.SpanLLM, + Name: "aggregator", + Started: startTime.Add(39 * time.Second), + Finished: startTime.Add(40 * time.Second), + }, + { + Seq: 19, + Nesting: 1, + Type: trajectory.SpanAgent, + Name: "aggregator", + Started: startTime.Add(38 * time.Second), + Finished: startTime.Add(41 * time.Second), + Instruction: "Aggregate!", + Prompt: `Prompt: baz +#0: swarm candidate 1 +#1: swarm candidate 2 + +#0: 1 +#1: 2 + +#0: swarm1 +#1: swarm2 + +`, + Reply: "aggregated", + }, { Seq: 0, Nesting: 0, Type: trajectory.SpanFlow, Name: "test-flow", Started: startTime.Add(1 * time.Second), - Finished: startTime.Add(20 * time.Second), - Results: map[string]any{ - "AgentFoo": 42, - "OutBar": 142, - "OutBaz": "baz", - "OutFoo": "hello, world!", - }, + Finished: startTime.Add(42 * time.Second), + Results: expectedOutputs, }, } onEvent := func(span *trajectory.Span) error { @@ -509,12 +849,8 @@ Ignore results of this tool. } res, err := flows["test-flow"].Execute(ctx, "model", workdir, inputs, cache, onEvent) require.NoError(t, err) - require.Equal(t, res, map[string]any{ - "OutFoo": "hello, world!", - "OutBar": 142, - "OutBaz": "baz", - "AgentFoo": 42, - }) + require.Equal(t, replySeq, 8) + require.Equal(t, res, expectedOutputs) require.Empty(t, expected) } diff --git a/pkg/aflow/llm_agent.go b/pkg/aflow/llm_agent.go index b897643c7..c30143425 100644 --- a/pkg/aflow/llm_agent.go +++ b/pkg/aflow/llm_agent.go @@ -27,6 +27,9 @@ type LLMAgent struct { // while higher temperatures can lead to more diverse or creative results. // Must be assigned a float32 value in the range [0, 2]. Temperature any + // If set, the agent will generate that many candidates and the outputs will be arrays + // instead of scalars. + Candidates int // Instructions for the agent. // Formatted as text/template, can use "{{.Variable}}" as placeholders for dynamic content. // Variables can come from the workflow inputs, or from preceding actions outputs. @@ -51,25 +54,77 @@ func LLMOutputs[Args any]() *llmOutputs { tool: NewFuncTool("set-results", func(ctx *Context, state struct{}, args Args) (Args, error) { return args, nil }, "Use this tool to provide results of the analysis."), - provideOutputs: func(ctx *verifyContext, who string) { - provideOutputs[Args](ctx, who) + provideOutputs: func(ctx *verifyContext, who string, many bool) { + if many { + provideArrayOutputs[Args](ctx, who) + } else { + provideOutputs[Args](ctx, who) + } + }, + append: func(to, from map[string]any) { + for name, typ := range foreachFieldOf[Args]() { + if to[name] == nil { + to[name] = reflect.Zero(reflect.SliceOf(typ)).Interface() + } + to[name] = reflect.Append(reflect.ValueOf(to[name]), reflect.ValueOf(from[name])).Interface() + } }, - instruction: ` + } +} + +const llmOutputsInstruction = ` Use set-results tool to provide results of the analysis. It must be called exactly once before the final reply. Ignore results of this tool. -`, - } -} +` type llmOutputs struct { tool Tool - provideOutputs func(*verifyContext, string) - instruction string + provideOutputs func(*verifyContext, string, bool) + append func(map[string]any, map[string]any) } func (a *LLMAgent) execute(ctx *Context) error { + if a.Candidates <= 1 { + reply, outputs, err := a.executeOne(ctx) + if err != nil { + return err + } + ctx.state[a.Reply] = reply + maps.Insert(ctx.state, maps.All(outputs)) + return nil + } + span := &trajectory.Span{ + Type: trajectory.SpanAgentCandidates, + Name: a.Name, + } + if err := ctx.startSpan(span); err != nil { + return err + } + err := a.executeMany(ctx) + return ctx.finishSpan(span, err) +} + +func (a *LLMAgent) executeMany(ctx *Context) error { + var replies []string + allOutputs := map[string]any{} + for candidate := 0; candidate < a.Candidates; candidate++ { + reply, outputs, err := a.executeOne(ctx) + if err != nil { + return err + } + replies = append(replies, reply) + if a.Outputs != nil { + a.Outputs.append(allOutputs, outputs) + } + } + ctx.state[a.Reply] = replies + maps.Insert(ctx.state, maps.All(allOutputs)) + return nil +} + +func (a *LLMAgent) executeOne(ctx *Context) (string, map[string]any, error) { cfg, instruction, tools := a.config(ctx) span := &trajectory.Span{ Type: trajectory.SpanAgent, @@ -78,12 +133,14 @@ func (a *LLMAgent) execute(ctx *Context) error { Prompt: formatTemplate(a.Prompt, ctx.state), } if err := ctx.startSpan(span); err != nil { - return err + return "", nil, err } reply, outputs, err := a.chat(ctx, cfg, tools, span.Prompt) - span.Reply = reply - span.Results = outputs - return ctx.finishSpan(span, err) + if err == nil { + span.Reply = reply + span.Results = outputs + } + return reply, outputs, ctx.finishSpan(span, err) } func (a *LLMAgent) chat(ctx *Context, cfg *genai.GenerateContentConfig, tools map[string]Tool, prompt string) ( @@ -112,8 +169,6 @@ func (a *LLMAgent) chat(ctx *Context, cfg *genai.GenerateContentConfig, tools ma if a.Outputs != nil && outputs == nil { return "", nil, fmt.Errorf("LLM did not call tool to set outputs") } - ctx.state[a.Reply] = reply - maps.Insert(ctx.state, maps.All(outputs)) return reply, outputs, nil } // This is not the final reply, LLM asked to execute some tools. @@ -134,7 +189,7 @@ func (a *LLMAgent) config(ctx *Context) (*genai.GenerateContentConfig, string, m instruction := formatTemplate(a.Instruction, ctx.state) toolList := a.Tools if a.Outputs != nil { - instruction += a.Outputs.instruction + instruction += llmOutputsInstruction toolList = append(toolList, a.Outputs.tool) } toolMap := make(map[string]Tool) @@ -225,6 +280,9 @@ func (a *LLMAgent) verify(vctx *verifyContext) { if temp, ok := a.Temperature.(float32); !ok || temp < 0 || temp > 2 { vctx.errorf(a.Name, "Temperature must have a float32 value in the range [0, 2]") } + if a.Candidates < 0 || a.Candidates > 100 { + vctx.errorf(a.Name, "Candidates must be in the range [0, 100]") + } // Verify dataflow. All dynamic variables must be provided by inputs, // or preceding actions. a.verifyTemplate(vctx, "Instruction", a.Instruction) @@ -232,9 +290,13 @@ func (a *LLMAgent) verify(vctx *verifyContext) { for _, tool := range a.Tools { tool.verify(vctx) } - vctx.provideOutput(a.Name, a.Reply, reflect.TypeFor[string](), true) + replyType := reflect.TypeFor[string]() + if a.Candidates > 1 { + replyType = reflect.TypeFor[[]string]() + } + vctx.provideOutput(a.Name, a.Reply, replyType, true) if a.Outputs != nil { - a.Outputs.provideOutputs(vctx, a.Name) + a.Outputs.provideOutputs(vctx, a.Name, a.Candidates > 1) } } diff --git a/pkg/aflow/template.go b/pkg/aflow/template.go index 7b0efd194..7dd213517 100644 --- a/pkg/aflow/template.go +++ b/pkg/aflow/template.go @@ -70,6 +70,10 @@ func walkTemplate(n parse.Node, used map[string]bool, errp *error) { walkTemplate(n.Pipe, used, errp) walkTemplate(n.List, used, errp) walkTemplate(n.ElseList, used, errp) + case *parse.RangeNode: + walkTemplate(n.Pipe, used, errp) + walkTemplate(n.List, used, errp) + walkTemplate(n.ElseList, used, errp) case *parse.ActionNode: walkTemplate(n.Pipe, used, errp) case *parse.PipeNode: diff --git a/pkg/aflow/template_test.go b/pkg/aflow/template_test.go index e42ddd2c3..3b24d62f6 100644 --- a/pkg/aflow/template_test.go +++ b/pkg/aflow/template_test.go @@ -42,6 +42,17 @@ func TestTemplate(t *testing.T) { }, used: []string{"bar", "foo"}, }, + { + template: ` + {{range $i, $v := .array}} + {{$i}} {{$v}} + {{end}} + `, + vars: map[string]reflect.Type{ + "array": reflect.TypeFor[[]int](), + }, + used: []string{"array"}, + }, { template: ` {{if .bar}} diff --git a/pkg/aflow/trajectory/trajectory.go b/pkg/aflow/trajectory/trajectory.go index 12c585815..49e36933b 100644 --- a/pkg/aflow/trajectory/trajectory.go +++ b/pkg/aflow/trajectory/trajectory.go @@ -39,12 +39,16 @@ type Span struct { type SpanType string +// Note: don't change string values of these consts w/o a good reason. +// They are stored in the dashboard database as strings. const ( SpanFlow = SpanType("flow") // always the first outermost span SpanAction = SpanType("action") SpanAgent = SpanType("agent") SpanLLM = SpanType("llm") SpanTool = SpanType("tool") + // Logical grouping of several invocations of the same agent. + SpanAgentCandidates = SpanType("agent-candidates") ) func (span *Span) String() string { diff --git a/pkg/aflow/verify.go b/pkg/aflow/verify.go index d7ccbd124..5f7d16a09 100644 --- a/pkg/aflow/verify.go +++ b/pkg/aflow/verify.go @@ -91,6 +91,12 @@ func provideOutputs[T any](ctx *verifyContext, who string) { } } +func provideArrayOutputs[T any](ctx *verifyContext, who string) { + for name, typ := range foreachFieldOf[T]() { + ctx.provideOutput(who, name, reflect.SliceOf(typ), true) + } +} + func requireSchema[T any](ctx *verifyContext, who, what string) { if _, err := schemaFor[T](); err != nil { ctx.errorf(who, "%v: %v", what, err) -- cgit mrf-deployment