Skip to content

Commit a43090e

Browse files
authored
Merge pull request #1662 from dgageot/multi-turn
Multi turn (cagent exec|run|eval)
2 parents 98b8ab4 + 1ed80a6 commit a43090e

8 files changed

Lines changed: 105 additions & 59 deletions

File tree

‎cmd/root/exec.go‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,16 @@ func newExecCmd() *cobra.Command {
1111
var flags runExecFlags
1212

1313
cmd := &cobra.Command{
14-
Use: "exec <agent-file>|<registry-ref>",
14+
Use: "exec <agent-file>|<registry-ref> <message>...",
1515
Short: "Execute an agent",
16-
Long: "Execute an agent (Single user message / No TUI)",
17-
Example: ` cagent exec ./agent.yaml
18-
cagent exec ./team.yaml --agent root
19-
cagent exec ./echo.yaml "INSTRUCTIONS"
16+
Long: "Execute an agent with one or more user messages (multi-turn, No TUI)",
17+
Example: ` cagent exec ./agent.yaml "What is Go?"
18+
cagent exec ./team.yaml --agent root "First question" "Follow-up question"
2019
echo "INSTRUCTIONS" | cagent exec ./echo.yaml -
2120
cagent exec ./agent.yaml "question" --record # Records to auto-generated file`,
2221
GroupID: "core",
2322
ValidArgsFunction: completeRunExec,
24-
Args: cobra.RangeArgs(1, 2),
23+
Args: cobra.MinimumNArgs(2),
2524
RunE: flags.runExecCommand,
2625
}
2726

‎cmd/root/run.go‎

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -58,19 +58,20 @@ func newRunCmd() *cobra.Command {
5858
var flags runExecFlags
5959

6060
cmd := &cobra.Command{
61-
Use: "run [<agent-file>|<registry-ref>] [message|-]",
61+
Use: "run [<agent-file>|<registry-ref>] [message]...",
6262
Short: "Run an agent",
6363
Long: "Run an agent with the specified configuration and prompt",
6464
Example: ` cagent run ./agent.yaml
6565
cagent run ./team.yaml --agent root
6666
cagent run # built-in default agent
6767
cagent run coder # built-in coding agent
6868
cagent run ./echo.yaml "INSTRUCTIONS"
69+
cagent run ./echo.yaml "First question" "Follow-up question"
6970
echo "INSTRUCTIONS" | cagent run ./echo.yaml -
7071
cagent run ./agent.yaml --record # Records session to auto-generated file`,
7172
GroupID: "core",
7273
ValidArgsFunction: completeRunExec,
73-
Args: cobra.RangeArgs(0, 2),
74+
Args: cobra.ArbitraryArgs,
7475
RunE: flags.runRunCommand,
7576
}
7677

@@ -419,20 +420,16 @@ func (f *runExecFlags) createLocalRuntimeAndSession(ctx context.Context, loadRes
419420
}
420421

421422
func (f *runExecFlags) handleExecMode(ctx context.Context, out *cli.Printer, rt runtime.Runtime, sess *session.Session, args []string) error {
422-
execArgs := []string{"exec"}
423-
if len(args) == 2 {
424-
execArgs = append(execArgs, args[1])
425-
} else {
426-
execArgs = append(execArgs, "Please proceed.")
427-
}
423+
// args[0] is the agent file; args[1:] are user messages for multi-turn conversation
424+
userMessages := args[1:]
428425

429426
err := cli.Run(ctx, out, cli.Config{
430427
AppName: AppName,
431428
AttachmentPath: f.attachmentPath,
432429
HideToolCalls: f.hideToolCalls,
433430
OutputJSON: f.outputJSON,
434431
AutoApprove: f.autoApprove,
435-
}, rt, sess, execArgs)
432+
}, rt, sess, userMessages)
436433
var cliErr cli.RuntimeError
437434
if errors.As(err, &cliErr) {
438435
return RuntimeError{Err: cliErr.Err}
@@ -467,6 +464,9 @@ func (f *runExecFlags) handleRunMode(ctx context.Context, rt runtime.Runtime, se
467464
if firstMessage != nil {
468465
opts = append(opts, app.WithFirstMessage(*firstMessage))
469466
}
467+
if len(args) > 2 {
468+
opts = append(opts, app.WithQueuedMessages(args[2:]))
469+
}
470470
if f.attachmentPath != "" {
471471
opts = append(opts, app.WithFirstMessageAttachment(f.attachmentPath))
472472
}

‎pkg/app/app.go‎

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type App struct {
3232
session *session.Session
3333
firstMessage *string
3434
firstMessageAttach string
35+
queuedMessages []string
3536
events chan tea.Msg
3637
throttleDuration time.Duration
3738
cancel context.CancelFunc
@@ -65,6 +66,15 @@ func WithExitAfterFirstResponse() Opt {
6566
}
6667
}
6768

69+
// WithQueuedMessages sets messages to be queued after the first message is sent.
70+
// These messages will be delivered to the TUI as SendMsg events, which the
71+
// chat page will queue and process sequentially after each agent response.
72+
func WithQueuedMessages(msgs []string) Opt {
73+
return func(a *App) {
74+
a.queuedMessages = msgs
75+
}
76+
}
77+
6878
// WithTitleGenerator sets the title generator for local title generation.
6979
// If not set, title generation will be handled by the runtime (for remote) or skipped.
7080
func WithTitleGenerator(gen *sessiontitle.Generator) Opt {
@@ -123,21 +133,36 @@ func (a *App) SendFirstMessage() tea.Cmd {
123133
return nil
124134
}
125135

126-
return func() tea.Msg {
127-
// Use the shared PrepareUserMessage function for consistent attachment handling
128-
userMsg := cli.PrepareUserMessage(context.Background(), a.runtime, *a.firstMessage, a.firstMessageAttach)
136+
cmds := []tea.Cmd{
137+
func() tea.Msg {
138+
// Use the shared PrepareUserMessage function for consistent attachment handling
139+
userMsg := cli.PrepareUserMessage(context.Background(), a.runtime, *a.firstMessage, a.firstMessageAttach)
129140

130-
// If the message has multi-content (attachments), we need to handle it specially
131-
if len(userMsg.Message.MultiContent) > 0 {
132-
return messages.SendAttachmentMsg{
133-
Content: userMsg,
141+
// If the message has multi-content (attachments), we need to handle it specially
142+
if len(userMsg.Message.MultiContent) > 0 {
143+
return messages.SendAttachmentMsg{
144+
Content: userMsg,
145+
}
134146
}
135-
}
136147

137-
return messages.SendMsg{
138-
Content: userMsg.Message.Content,
139-
}
148+
return messages.SendMsg{
149+
Content: userMsg.Message.Content,
150+
}
151+
},
152+
}
153+
154+
// Queue additional messages to be sent after the first one.
155+
// The TUI's message queue will hold them until the agent finishes
156+
// processing the previous message.
157+
for _, msg := range a.queuedMessages {
158+
cmds = append(cmds, func() tea.Msg {
159+
return messages.SendMsg{
160+
Content: msg,
161+
}
162+
})
140163
}
164+
165+
return tea.Sequence(cmds...)
141166
}
142167

143168
// CurrentAgentCommands returns the commands for the active agent

‎pkg/cli/runner.go‎

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,10 @@ type Config struct {
4040
OutputJSON bool
4141
}
4242

43-
// Run executes an agent in non-TUI mode, handling user input and runtime events
44-
func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess *session.Session, args []string) error {
43+
// Run executes an agent in non-TUI mode, handling user input and runtime events.
44+
// userMessages contains the user messages to send. If a single message is "-",
45+
// input is read from stdin. If empty, an interactive prompt loop is started.
46+
func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess *session.Session, userMessages []string) error {
4547
// Create a cancellable context for this agentic loop and wire Ctrl+C to cancel it
4648
ctx, cancel := context.WithCancel(ctx)
4749
defer cancel()
@@ -193,22 +195,26 @@ func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess
193195
return nil
194196
}
195197

196-
if len(args) == 2 {
197-
if args[1] == "-" {
198-
buf, err := io.ReadAll(os.Stdin)
199-
if err != nil {
200-
return fmt.Errorf("failed to read from stdin: %w", err)
201-
}
198+
switch {
199+
case len(userMessages) == 1 && userMessages[0] == "-":
200+
// Single "-" argument: read from stdin
201+
buf, err := io.ReadAll(os.Stdin)
202+
if err != nil {
203+
return fmt.Errorf("failed to read from stdin: %w", err)
204+
}
202205

203-
if err := oneLoop(string(buf), os.Stdin); err != nil {
204-
return err
205-
}
206-
} else {
207-
if err := oneLoop(args[1], os.Stdin); err != nil {
206+
if err := oneLoop(string(buf), os.Stdin); err != nil {
207+
return err
208+
}
209+
case len(userMessages) > 0:
210+
// One or more messages: multi-turn conversation
211+
for _, msg := range userMessages {
212+
if err := oneLoop(msg, os.Stdin); err != nil {
208213
return err
209214
}
210215
}
211-
} else {
216+
default:
217+
// No messages: interactive prompt loop
212218
out.PrintWelcomeMessage(cfg.AppName)
213219
firstQuestion := true
214220
for {

‎pkg/evaluation/eval.go‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ func (r *Runner) runSingleEval(ctx context.Context, evalSess *InputSession) (Res
292292
result := Result{
293293
InputPath: evalSess.SourcePath,
294294
Title: evalSess.Title,
295-
Question: getFirstUserMessage(evalSess.Session),
295+
Question: strings.Join(getUserMessages(evalSess.Session), "\n"),
296296
SizeExpected: evals.Size,
297297
RelevanceExpected: float64(len(evals.Relevance)),
298298
}
@@ -309,7 +309,7 @@ func (r *Runner) runSingleEval(ctx context.Context, evalSess *InputSession) (Res
309309
return result, fmt.Errorf("building eval image: %w", err)
310310
}
311311

312-
events, err := r.runCagentInContainer(ctx, imageID, result.Question)
312+
events, err := r.runCagentInContainer(ctx, imageID, getUserMessages(evalSess.Session))
313313
if err != nil {
314314
return result, fmt.Errorf("running cagent in container: %w", err)
315315
}
@@ -322,7 +322,7 @@ func (r *Runner) runSingleEval(ctx context.Context, evalSess *InputSession) (Res
322322
result.Size = getResponseSize(result.Response)
323323

324324
// Build session from events for database storage
325-
result.Session = SessionFromEvents(events, evalSess.Title, result.Question)
325+
result.Session = SessionFromEvents(events, evalSess.Title, getUserMessages(evalSess.Session))
326326
result.Session.Evals = evals
327327

328328
if len(expectedToolCalls) > 0 || len(actualToolCalls) > 0 {
@@ -346,7 +346,7 @@ func (r *Runner) runSingleEval(ctx context.Context, evalSess *InputSession) (Res
346346
return result, nil
347347
}
348348

349-
func (r *Runner) runCagentInContainer(ctx context.Context, imageID, question string) ([]map[string]any, error) {
349+
func (r *Runner) runCagentInContainer(ctx context.Context, imageID string, questions []string) ([]map[string]any, error) {
350350
agentDir := r.agentSource.ParentDir()
351351
agentFile := filepath.Base(r.agentSource.Name())
352352
containerName := fmt.Sprintf("cagent-eval-%d", uuid.New().ID())
@@ -396,7 +396,8 @@ func (r *Runner) runCagentInContainer(ctx context.Context, imageID, question str
396396
}
397397
}
398398

399-
args = append(args, imageID, "/configs/"+agentFile, question)
399+
args = append(args, imageID, "/configs/"+agentFile)
400+
args = append(args, questions...)
400401

401402
cmd := exec.CommandContext(ctx, "docker", args...)
402403
cmd.Env = append(env, os.Environ()...)

‎pkg/evaluation/save.go‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,28 @@ func SaveRunSessions(ctx context.Context, run *EvalRun, outputDir string) (strin
5555
// SessionFromEvents reconstructs a session from raw container output events.
5656
// This parses the JSON events emitted by cagent --json and builds a session
5757
// with the conversation history.
58-
func SessionFromEvents(events []map[string]any, title, question string) *session.Session {
58+
func SessionFromEvents(events []map[string]any, title string, questions []string) *session.Session {
5959
sess := session.New(
6060
session.WithTitle(title),
6161
session.WithToolsApproved(true),
6262
)
6363

64-
// Add the user question as the first message
65-
if question != "" {
66-
sess.AddMessage(session.UserMessage(question))
64+
// Add user questions as initial messages.
65+
// For multi-turn evals, these are interleaved with agent responses
66+
// as they appear in the event stream. The first question is added
67+
// upfront; subsequent questions are inserted when a stream_stopped
68+
// event indicates the agent finished processing the previous turn.
69+
questionIdx := 0
70+
addNextQuestion := func() {
71+
if questionIdx < len(questions) {
72+
sess.AddMessage(session.UserMessage(questions[questionIdx]))
73+
questionIdx++
74+
}
6775
}
6876

77+
// Add the first question
78+
addNextQuestion()
79+
6980
// Track current assistant message being built
7081
var currentContent strings.Builder
7182
var currentReasoningContent strings.Builder
@@ -225,6 +236,9 @@ func SessionFromEvents(events []map[string]any, title, question string) *session
225236
case "stream_stopped":
226237
// Flush final assistant message
227238
flushAssistantMessage()
239+
240+
// In multi-turn evals, add the next user question after each turn
241+
addNextQuestion()
228242
}
229243
}
230244

‎pkg/evaluation/save_test.go‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,7 @@ func TestSessionFromEvents(t *testing.T) {
341341
t.Run(tt.name, func(t *testing.T) {
342342
t.Parallel()
343343

344-
sess := SessionFromEvents(tt.events, tt.title, tt.question)
344+
sess := SessionFromEvents(tt.events, tt.title, []string{tt.question})
345345

346346
assert.Equal(t, tt.title, sess.Title)
347347
assert.Len(t, sess.Messages, tt.wantMessages)
@@ -377,7 +377,7 @@ func TestSessionFromEventsTokenUsage(t *testing.T) {
377377
{"type": "stream_stopped"},
378378
}
379379

380-
sess := SessionFromEvents(events, "test", "question")
380+
sess := SessionFromEvents(events, "test", []string{"question"})
381381

382382
assert.Equal(t, int64(100), sess.InputTokens)
383383
assert.Equal(t, int64(50), sess.OutputTokens)
@@ -461,7 +461,7 @@ func TestSessionFromEventsWithToolDefinitions(t *testing.T) {
461461
{"type": "stream_stopped"},
462462
}
463463

464-
sess := SessionFromEvents(events, "test", "read the file")
464+
sess := SessionFromEvents(events, "test", []string{"read the file"})
465465

466466
// Find the assistant message with tool calls
467467
var assistantMsg *session.Message
@@ -498,7 +498,7 @@ func TestSessionFromEventsWithReasoningContent(t *testing.T) {
498498
{"type": "stream_stopped"},
499499
}
500500

501-
sess := SessionFromEvents(events, "test", "complex question")
501+
sess := SessionFromEvents(events, "test", []string{"complex question"})
502502

503503
// Find the assistant message
504504
var assistantMsg *session.Message
@@ -537,7 +537,7 @@ func TestSessionFromEventsWithPerMessageUsage(t *testing.T) {
537537
{"type": "stream_stopped"},
538538
}
539539

540-
sess := SessionFromEvents(events, "test", "hi")
540+
sess := SessionFromEvents(events, "test", []string{"hi"})
541541

542542
// Check session-level usage
543543
assert.Equal(t, int64(100), sess.InputTokens)
@@ -571,7 +571,7 @@ func TestSessionFromEventsWithError(t *testing.T) {
571571
{"type": "stream_stopped"},
572572
}
573573

574-
sess := SessionFromEvents(events, "test", "do something")
574+
sess := SessionFromEvents(events, "test", []string{"do something"})
575575

576576
// Should have: user message, assistant message, error message
577577
assert.Len(t, sess.Messages, 3)
@@ -593,7 +593,7 @@ func TestSessionFromEventsWithSessionTitle(t *testing.T) {
593593
}
594594

595595
// Start with a default title
596-
sess := SessionFromEvents(events, "default-title", "hi")
596+
sess := SessionFromEvents(events, "default-title", []string{"hi"})
597597

598598
// Title should be updated from the event
599599
assert.Equal(t, "Auto-generated title", sess.Title)

‎pkg/evaluation/types.go‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,14 @@ type Config struct {
122122

123123
// Session helper functions
124124

125-
func getFirstUserMessage(sess *session.Session) string {
125+
func getUserMessages(sess *session.Session) []string {
126+
var messages []string
126127
for _, msg := range sess.GetAllMessages() {
127128
if msg.Message.Role == "user" {
128-
return msg.Message.Content
129+
messages = append(messages, msg.Message.Content)
129130
}
130131
}
131-
return ""
132+
return messages
132133
}
133134

134135
func extractToolCalls(items []session.Item) []string {

0 commit comments

Comments
 (0)