Skip to content

Commit f525b49

Browse files
committed
subagents works :-)
1 parent aa7bbd1 commit f525b49

File tree

5 files changed

+160
-9
lines changed

5 files changed

+160
-9
lines changed

internal/core/runtime/command_executor_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ func TestCommandExecutorExecuteBuiltinApplyPatch(t *testing.T) {
149149
}
150150

151151
executor := NewCommandExecutor()
152-
if err := registerBuiltinInternalCommands(executor); err != nil {
152+
if err := registerBuiltinInternalCommands(nil, executor); err != nil {
153153
t.Fatalf("failed to register builtins: %v", err)
154154
}
155155

internal/core/runtime/internal_command_apply_patch.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,9 +163,12 @@ func parseApplyPatchOptions(commandLine, cwd string) (patch.FilesystemOptions, e
163163
return opts, nil
164164
}
165165

166-
func registerBuiltinInternalCommands(executor *CommandExecutor) error {
166+
func registerBuiltinInternalCommands(rt *Runtime, executor *CommandExecutor) error {
167167
if executor == nil {
168168
return errors.New("nil executor")
169169
}
170-
return executor.RegisterInternalCommand(applyPatchCommandName, newApplyPatchCommand())
170+
if err := executor.RegisterInternalCommand(applyPatchCommandName, newApplyPatchCommand()); err != nil {
171+
return err
172+
}
173+
return executor.RegisterInternalCommand(runResearchCommandName, newRunResearchCommand(rt))
171174
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package runtime
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"fmt"
8+
"strings"
9+
)
10+
11+
const runResearchCommandName = "run_research"
12+
13+
func newRunResearchCommand(rt *Runtime) InternalCommandHandler {
14+
return func(ctx context.Context, req InternalCommandRequest) (PlanObservationPayload, error) {
15+
payload := PlanObservationPayload{}
16+
17+
// 1. Parse the research spec from the raw command
18+
type researchSpec struct {
19+
Goal string `json:"goal"`
20+
Turns int `json:"turns"`
21+
}
22+
var rs researchSpec
23+
jsonInput := strings.TrimSpace(strings.TrimPrefix(req.Raw, runResearchCommandName))
24+
if err := json.Unmarshal([]byte(jsonInput), &rs); err != nil {
25+
return failApplyPatch(&payload, "internal command: run_research invalid JSON"), err
26+
}
27+
rs.Goal = strings.TrimSpace(rs.Goal)
28+
if rs.Goal == "" {
29+
return failApplyPatch(&payload, "internal command: run_research requires non-empty goal"), errors.New("run_research: missing goal")
30+
}
31+
if rs.Turns <= 0 {
32+
rs.Turns = 10 // Default to 10 turns if not specified or invalid
33+
}
34+
35+
// 2. Configure new runtime options for the sub-agent
36+
subOptions := rt.options
37+
subOptions.HandsFree = true
38+
subOptions.HandsFreeTopic = rs.Goal
39+
subOptions.MaxPasses = rs.Turns
40+
subOptions.HandsFreeAutoReply = fmt.Sprintf("Please continue to work on the set goal. No human available. Goal: %s", rs.Goal)
41+
subOptions.DisableInputReader = true
42+
subOptions.DisableOutputForwarding = true
43+
44+
// 3. Create and run the sub-agent
45+
subAgent, err := NewRuntime(subOptions)
46+
if err != nil {
47+
return failApplyPatch(&payload, "failed to create sub-agent"), err
48+
}
49+
50+
runCtx, cancel := context.WithCancel(ctx)
51+
defer cancel()
52+
go func() { _ = subAgent.Run(runCtx) }()
53+
54+
// 4. Capture the output of the sub-agent
55+
var lastAssistant string
56+
var success bool
57+
for evt := range subAgent.Outputs() {
58+
switch evt.Type {
59+
case EventTypeAssistantMessage:
60+
if m := strings.TrimSpace(evt.Message); m != "" {
61+
lastAssistant = m
62+
}
63+
case EventTypeStatus:
64+
if strings.Contains(evt.Message, "Hands-free session complete") {
65+
success = true
66+
}
67+
}
68+
}
69+
70+
// 5. Populate the payload with the result
71+
if success {
72+
payload.Stdout = lastAssistant
73+
zero := 0
74+
payload.ExitCode = &zero
75+
} else {
76+
payload.Stderr = lastAssistant
77+
one := 1
78+
payload.ExitCode = &one
79+
}
80+
81+
return payload, nil
82+
}
83+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package runtime
2+
3+
import (
4+
"context"
5+
"os"
6+
"strconv"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func TestRunResearchCommand(t *testing.T) {
12+
t.Skip("Skipping test that requires OpenAI API key")
13+
t.Parallel()
14+
15+
// 1. Setup a mock runtime
16+
options := RuntimeOptions{
17+
APIKey: os.Getenv("OPENAI_API_KEY"),
18+
Model: "gpt-4o",
19+
DisableOutputForwarding: true,
20+
UseStreaming: true,
21+
}
22+
rt, err := NewRuntime(options)
23+
if err != nil {
24+
t.Fatalf("failed to create runtime: %v", err)
25+
}
26+
27+
// 2. Create a request for the run_research command
28+
researchGoal := "write a haiku about testing"
29+
researchTurns := 5
30+
rawCommand := `{"goal":"` + researchGoal + `","turns":` + strconv.Itoa(researchTurns) + `}`
31+
req := InternalCommandRequest{
32+
Name: runResearchCommandName,
33+
Raw: rawCommand,
34+
Step: PlanStep{ID: "step-1", Command: CommandDraft{Shell: agentShell, Run: rawCommand}},
35+
}
36+
37+
// 3. Execute the command
38+
39+
payload, err := newRunResearchCommand(rt)(context.Background(), req)
40+
if err != nil {
41+
t.Fatalf("handler returned error: %v", err)
42+
}
43+
44+
// 4. Assert the results
45+
if payload.ExitCode == nil || *payload.ExitCode != 0 {
46+
t.Fatalf("expected exit code 0, got %+v", payload.ExitCode)
47+
}
48+
49+
if !strings.Contains(payload.Stdout, "test") {
50+
t.Fatalf("expected stdout to contain 'test', got %q", payload.Stdout)
51+
}
52+
}

internal/core/runtime/runtime.go

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,23 +57,22 @@ func NewRuntime(options RuntimeOptions) (*Runtime, error) {
5757
Pass: 0,
5858
}}
5959

60-
executor := NewCommandExecutor()
61-
if err := registerBuiltinInternalCommands(executor); err != nil {
62-
return nil, err
63-
}
64-
6560
rt := &Runtime{
6661
options: options,
6762
inputs: make(chan InputEvent, options.InputBuffer),
6863
outputs: make(chan RuntimeEvent, options.OutputBuffer),
6964
closed: make(chan struct{}),
7065
plan: NewPlanManager(),
7166
client: client,
72-
executor: executor,
7367
history: initialHistory,
7468
agentName: "main",
7569
contextBudget: ContextBudget{MaxTokens: options.MaxContextTokens, CompactWhenPercent: options.CompactWhenPercent},
7670
}
71+
executor := NewCommandExecutor()
72+
if err := registerBuiltinInternalCommands(rt, executor); err != nil {
73+
return nil, err
74+
}
75+
rt.executor = executor
7776

7877
for name, handler := range options.InternalCommands {
7978
if err := rt.executor.RegisterInternalCommand(name, handler); err != nil {
@@ -263,6 +262,20 @@ apply_patch [--respect-whitespace|--ignore-whitespace]
263262
'''
264263
The executor parses this JSON, notices the "openagent" shell, and forwards the run string to the apply_patch handler which consumes the embedded diff.
265264
265+
### run_research
266+
Use this command to spawn a sub-agent to perform research. The sub-agent will run in a hands-free loop for a fixed number of turns.
267+
- Set the plan step's command shell to "openagent" so the runtime routes the request to the internal handler instead of the OS shell.
268+
- The payload sent in the plan step's "run" field must be a JSON object of the following shape:
269+
'''
270+
{"goal":"some goal","turns":20}
271+
'''
272+
- The 'goal' is the research topic for the sub-agent.
273+
- The 'turns' is the maximum number of passes the sub-agent will make.
274+
- Example plan step payload (escaped for this Go string literal):
275+
'''
276+
{"id":"step-42","command":{"shell":"openagent","cwd":"/workspace/project","run":"run_research {\"goal\":\"code review the last 2 commits in git, anything good? bad?\",\"turns\":20}"}}
277+
'''
278+
266279
## execution environment and sandbox
267280
You are not in a sandbox, you have full access to run any command.
268281

0 commit comments

Comments
 (0)