aboutsummaryrefslogtreecommitdiffstats
path: root/pkg
diff options
context:
space:
mode:
authorDmitry Vyukov <dvyukov@google.com>2026-01-15 11:37:02 +0100
committerDmitry Vyukov <dvyukov@google.com>2026-01-19 09:21:15 +0000
commit1276f83b46b38cc241614ebc4401720f5f1fc4ab (patch)
treeedf8e8d9c9ac313d9457cebf678aea9334804f05 /pkg
parenta9fc52269b8aab60248b6e4c5366216bc2191101 (diff)
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.
Diffstat (limited to 'pkg')
-rw-r--r--pkg/aflow/flow_test.go450
-rw-r--r--pkg/aflow/llm_agent.go96
-rw-r--r--pkg/aflow/template.go4
-rw-r--r--pkg/aflow/template_test.go11
-rw-r--r--pkg/aflow/trajectory/trajectory.go4
-rw-r--r--pkg/aflow/verify.go6
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,
@@ -487,18 +600,245 @@ Ignore results of this tool.
},
},
{
+ 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
@@ -44,6 +44,17 @@ func TestTemplate(t *testing.T) {
},
{
template: `
+ {{range $i, $v := .array}}
+ {{$i}} {{$v}}
+ {{end}}
+ `,
+ vars: map[string]reflect.Type{
+ "array": reflect.TypeFor[[]int](),
+ },
+ used: []string{"array"},
+ },
+ {
+ template: `
{{if .bar}}
{{.foo}}
{{end}}
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)