feat(subagents): support profile fast mode
- commit
- fcf1dc2004b1827753a659a9dcab80c4f82c3ae5
- author
- brice · · 2026-08-05 03:41 UTC
- parent
- ebe86d6
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +236 -9
M docs/subagents.md +3 -1
| @@ -29,6 +29,7 @@ thinking: max | |||
| 29 | 29 | systemPromptMode: replace | |
| 30 | 30 | inheritProjectContext: false | |
| 31 | 31 | inheritSkills: false | |
| 32 | + | fastMode: false | |
| 32 | 33 | --- | |
| 33 | 34 | ||
| 34 | 35 | You are a review worker. Inspect the requested scope, report evidence-backed findings, and do not edit files. | |
| @@ -47,6 +48,7 @@ Supported metadata: | |||
| 47 | 48 | | `systemPromptMode` | `append` (default) or `replace`. | | |
| 48 | 49 | | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. | | |
| 49 | 50 | | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. | | |
| 51 | + | | `fastMode` | Optional fast-mode restriction. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` only permits fast mode when the host setting is enabled. | | |
| 50 | 52 | ||
| 51 | 53 | Other frontmatter from another agent host is ignored when zot does not have an equivalent setting. For example, `maxSubagentDepth` is currently not enforced by zot. | |
| 52 | 54 | ||
| @@ -82,4 +84,4 @@ The interactive command also supports the same selection explicitly: | |||
| 82 | 84 | /swarm new --agent implementer --reasoning high Implement the parser change | |
| 83 | 85 | ``` | |
| 84 | 86 | ||
| 85 | - | All swarm children still share the host working directory. Named profiles change the child's instructions and configuration; they do not create a worktree, branch, or security sandbox. When the host has **fast mode** enabled, every child inherits it; non-OpenAI child providers return an unsupported-provider error instead of silently ignoring the setting. | |
| 87 | + | All swarm children still share the host working directory. Named profiles change the child's instructions and configuration; they do not create a worktree, branch, or security sandbox. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` cannot enable fast mode when the host setting is off. Non-OpenAI child providers return an unsupported-provider error instead of silently ignoring an enabled setting. | |
| @@ -29,6 +29,7 @@ thinking: max | |||||
| 29 | systemPromptMode: replace | 29 | systemPromptMode: replace | ||
| 30 | inheritProjectContext: false | 30 | inheritProjectContext: false | ||
| 31 | inheritSkills: false | 31 | inheritSkills: false | ||
| 32 | + | fastMode: false | |||
| 32 | --- | 33 | --- | ||
| 33 | 34 | ||||
| 34 | You are a review worker. Inspect the requested scope, report evidence-backed findings, and do not edit files. | 35 | You are a review worker. Inspect the requested scope, report evidence-backed findings, and do not edit files. | ||
| @@ -47,6 +48,7 @@ Supported metadata: | |||||
| 47 | | `systemPromptMode` | `append` (default) or `replace`. | | 48 | | `systemPromptMode` | `append` (default) or `replace`. | | ||
| 48 | | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. | | 49 | | `inheritProjectContext` | Set to `false` to omit `AGENTS.md` context from the child. | | ||
| 49 | | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. | | 50 | | `inheritSkills` | Set to `false` to omit skill discovery and the conditional `skill` loader from the child; `--no-skill` has the same effect for a run. | | ||
| 51 | + | | `fastMode` | Optional fast-mode restriction. Omit it to inherit the host setting; `false` disables fast mode for this profile; `true` only permits fast mode when the host setting is enabled. | | |||
| 50 | 52 | ||||
| 51 | Other frontmatter from another agent host is ignored when zot does not have an equivalent setting. For example, `maxSubagentDepth` is currently not enforced by zot. | 53 | Other frontmatter from another agent host is ignored when zot does not have an equivalent setting. For example, `maxSubagentDepth` is currently not enforced by zot. | ||
| 52 | 54 | ||||
| @@ -82,4 +84,4 @@ The interactive command also supports the same selection explicitly: | |||||
| 82 | /swarm new --agent implementer --reasoning high Implement the parser change | 84 | /swarm new --agent implementer --reasoning high Implement the parser change | ||
| 83 | ``` | 85 | ``` | ||
| 84 | 86 | ||||
| 85 | - | All swarm children still share the host working directory. Named profiles change the child's instructions and configuration; they do not create a worktree, branch, or security sandbox. When the host has **fast mode** enabled, every child inherits it; non-OpenAI child providers return an unsupported-provider error instead of silently ignoring the setting. | 87 | + | All swarm children still share the host working directory. Named profiles change the child's instructions and configuration; they do not create a worktree, branch, or security sandbox. Fast mode is inherited by default. A profile with `fastMode: false` opts out, while `fastMode: true` cannot enable fast mode when the host setting is off. Non-OpenAI child providers return an unsupported-provider error instead of silently ignoring an enabled setting. |
M packages/agent/build.go +7 -0
| @@ -777,6 +777,13 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 777 | 777 | if args.FastModeSet { | |
| 778 | 778 | fastMode = args.FastMode | |
| 779 | 779 | } | |
| 780 | + | // A profile can opt out of the effective host/child fast-mode setting, | |
| 781 | + | // but it does not enable fast mode by itself. Keep this as a | |
| 782 | + | // restriction on the effective value rather than an override so an | |
| 783 | + | // explicit child --fast-mode flag cannot bypass fastMode: false. | |
| 784 | + | if selectedProfile != nil && selectedProfile.FastMode != nil { | |
| 785 | + | fastMode = fastMode && *selectedProfile.FastMode | |
| 786 | + | } | |
| 780 | 787 | ||
| 781 | 788 | max := args.MaxSteps // 0 = unlimited | |
| 782 | 789 | ||
| @@ -777,6 +777,13 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 777 | if args.FastModeSet { | 777 | if args.FastModeSet { | ||
| 778 | fastMode = args.FastMode | 778 | fastMode = args.FastMode | ||
| 779 | } | 779 | } | ||
| 780 | + | // A profile can opt out of the effective host/child fast-mode setting, | |||
| 781 | + | // but it does not enable fast mode by itself. Keep this as a | |||
| 782 | + | // restriction on the effective value rather than an override so an | |||
| 783 | + | // explicit child --fast-mode flag cannot bypass fastMode: false. | |||
| 784 | + | if selectedProfile != nil && selectedProfile.FastMode != nil { | |||
| 785 | + | fastMode = fastMode && *selectedProfile.FastMode | |||
| 786 | + | } | |||
| 780 | 787 | ||||
| 781 | max := args.MaxSteps // 0 = unlimited | 788 | max := args.MaxSteps // 0 = unlimited | ||
| 782 | 789 | ||||
M packages/agent/build_test.go +64 -0
| @@ -2,6 +2,7 @@ package agent | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "fmt" | |
| 5 | 6 | "net/http" | |
| 6 | 7 | "net/http/httptest" | |
| 7 | 8 | "os" | |
| @@ -248,6 +249,69 @@ You are a read-only reviewer. | |||
| 248 | 249 | } | |
| 249 | 250 | } | |
| 250 | 251 | ||
| 252 | + | func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) { | |
| 253 | + | cases := []struct { | |
| 254 | + | name string | |
| 255 | + | hostFastMode bool | |
| 256 | + | profileFast *bool | |
| 257 | + | argFastMode bool | |
| 258 | + | argFastSet bool | |
| 259 | + | wantFastMode bool | |
| 260 | + | }{ | |
| 261 | + | {name: "unset inherits enabled host", hostFastMode: true, wantFastMode: true}, | |
| 262 | + | {name: "unset inherits disabled host", hostFastMode: false, wantFastMode: false}, | |
| 263 | + | {name: "false disables enabled host", hostFastMode: true, profileFast: boolPtr(false), wantFastMode: false}, | |
| 264 | + | {name: "true cannot enable disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: false}, | |
| 265 | + | {name: "false stays disabled with disabled host", hostFastMode: false, profileFast: boolPtr(false), wantFastMode: false}, | |
| 266 | + | {name: "profile false cannot be bypassed", hostFastMode: true, profileFast: boolPtr(false), argFastMode: true, argFastSet: true, wantFastMode: false}, | |
| 267 | + | } | |
| 268 | + | for _, tc := range cases { | |
| 269 | + | t.Run(tc.name, func(t *testing.T) { | |
| 270 | + | root := t.TempDir() | |
| 271 | + | home := filepath.Join(root, "home") | |
| 272 | + | zotHome := filepath.Join(root, "zot-home") | |
| 273 | + | project := filepath.Join(root, "repo") | |
| 274 | + | profilesDir := filepath.Join(home, ".agents", "agents") | |
| 275 | + | if err := os.MkdirAll(profilesDir, 0o755); err != nil { | |
| 276 | + | t.Fatal(err) | |
| 277 | + | } | |
| 278 | + | if err := os.MkdirAll(project, 0o755); err != nil { | |
| 279 | + | t.Fatal(err) | |
| 280 | + | } | |
| 281 | + | t.Setenv("HOME", home) | |
| 282 | + | t.Setenv("ZOT_HOME", zotHome) | |
| 283 | + | t.Setenv("ZOT_AGENT_PROFILES", profilesDir) | |
| 284 | + | ||
| 285 | + | cfg := Config{Provider: "openai", Model: "gpt-5"} | |
| 286 | + | cfg.FastMode = &tc.hostFastMode | |
| 287 | + | if err := SaveConfig(cfg); err != nil { | |
| 288 | + | t.Fatal(err) | |
| 289 | + | } | |
| 290 | + | profile := "---\nname: reviewer\n" | |
| 291 | + | if tc.profileFast != nil { | |
| 292 | + | profile += fmt.Sprintf("fastMode: %t\n", *tc.profileFast) | |
| 293 | + | } | |
| 294 | + | profile += "---\nReview the requested scope.\n" | |
| 295 | + | if err := os.WriteFile(filepath.Join(profilesDir, "reviewer.md"), []byte(profile), 0o600); err != nil { | |
| 296 | + | t.Fatal(err) | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | r, err := Resolve(Args{ | |
| 300 | + | CWD: project, | |
| 301 | + | Subagent: "reviewer", | |
| 302 | + | FastMode: tc.argFastMode, | |
| 303 | + | FastModeSet: tc.argFastSet, | |
| 304 | + | }, false) | |
| 305 | + | if err != nil { | |
| 306 | + | t.Fatal(err) | |
| 307 | + | } | |
| 308 | + | if r.FastMode != tc.wantFastMode { | |
| 309 | + | t.Fatalf("FastMode = %v, want %v", r.FastMode, tc.wantFastMode) | |
| 310 | + | } | |
| 311 | + | }) | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | ||
| 251 | 315 | func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { | |
| 252 | 316 | files := loadAgentsContext(t.TempDir(), t.TempDir()) | |
| 253 | 317 | if len(files) != 0 { | |
| @@ -2,6 +2,7 @@ package agent | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "fmt" | |||
| 5 | "net/http" | 6 | "net/http" | ||
| 6 | "net/http/httptest" | 7 | "net/http/httptest" | ||
| 7 | "os" | 8 | "os" | ||
| @@ -248,6 +249,69 @@ You are a read-only reviewer. | |||||
| 248 | } | 249 | } | ||
| 249 | } | 250 | } | ||
| 250 | 251 | ||||
| 252 | + | func TestResolveSubagentFastModeUsesHostSettingAsAnUpperBound(t *testing.T) { | |||
| 253 | + | cases := []struct { | |||
| 254 | + | name string | |||
| 255 | + | hostFastMode bool | |||
| 256 | + | profileFast *bool | |||
| 257 | + | argFastMode bool | |||
| 258 | + | argFastSet bool | |||
| 259 | + | wantFastMode bool | |||
| 260 | + | }{ | |||
| 261 | + | {name: "unset inherits enabled host", hostFastMode: true, wantFastMode: true}, | |||
| 262 | + | {name: "unset inherits disabled host", hostFastMode: false, wantFastMode: false}, | |||
| 263 | + | {name: "false disables enabled host", hostFastMode: true, profileFast: boolPtr(false), wantFastMode: false}, | |||
| 264 | + | {name: "true cannot enable disabled host", hostFastMode: false, profileFast: boolPtr(true), wantFastMode: false}, | |||
| 265 | + | {name: "false stays disabled with disabled host", hostFastMode: false, profileFast: boolPtr(false), wantFastMode: false}, | |||
| 266 | + | {name: "profile false cannot be bypassed", hostFastMode: true, profileFast: boolPtr(false), argFastMode: true, argFastSet: true, wantFastMode: false}, | |||
| 267 | + | } | |||
| 268 | + | for _, tc := range cases { | |||
| 269 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 270 | + | root := t.TempDir() | |||
| 271 | + | home := filepath.Join(root, "home") | |||
| 272 | + | zotHome := filepath.Join(root, "zot-home") | |||
| 273 | + | project := filepath.Join(root, "repo") | |||
| 274 | + | profilesDir := filepath.Join(home, ".agents", "agents") | |||
| 275 | + | if err := os.MkdirAll(profilesDir, 0o755); err != nil { | |||
| 276 | + | t.Fatal(err) | |||
| 277 | + | } | |||
| 278 | + | if err := os.MkdirAll(project, 0o755); err != nil { | |||
| 279 | + | t.Fatal(err) | |||
| 280 | + | } | |||
| 281 | + | t.Setenv("HOME", home) | |||
| 282 | + | t.Setenv("ZOT_HOME", zotHome) | |||
| 283 | + | t.Setenv("ZOT_AGENT_PROFILES", profilesDir) | |||
| 284 | + | ||||
| 285 | + | cfg := Config{Provider: "openai", Model: "gpt-5"} | |||
| 286 | + | cfg.FastMode = &tc.hostFastMode | |||
| 287 | + | if err := SaveConfig(cfg); err != nil { | |||
| 288 | + | t.Fatal(err) | |||
| 289 | + | } | |||
| 290 | + | profile := "---\nname: reviewer\n" | |||
| 291 | + | if tc.profileFast != nil { | |||
| 292 | + | profile += fmt.Sprintf("fastMode: %t\n", *tc.profileFast) | |||
| 293 | + | } | |||
| 294 | + | profile += "---\nReview the requested scope.\n" | |||
| 295 | + | if err := os.WriteFile(filepath.Join(profilesDir, "reviewer.md"), []byte(profile), 0o600); err != nil { | |||
| 296 | + | t.Fatal(err) | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | r, err := Resolve(Args{ | |||
| 300 | + | CWD: project, | |||
| 301 | + | Subagent: "reviewer", | |||
| 302 | + | FastMode: tc.argFastMode, | |||
| 303 | + | FastModeSet: tc.argFastSet, | |||
| 304 | + | }, false) | |||
| 305 | + | if err != nil { | |||
| 306 | + | t.Fatal(err) | |||
| 307 | + | } | |||
| 308 | + | if r.FastMode != tc.wantFastMode { | |||
| 309 | + | t.Fatalf("FastMode = %v, want %v", r.FastMode, tc.wantFastMode) | |||
| 310 | + | } | |||
| 311 | + | }) | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | ||||
| 251 | func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { | 315 | func TestReadAgentsContextMissingFilesIsEmpty(t *testing.T) { | ||
| 252 | files := loadAgentsContext(t.TempDir(), t.TempDir()) | 316 | files := loadAgentsContext(t.TempDir(), t.TempDir()) | ||
| 253 | if len(files) != 0 { | 317 | if len(files) != 0 { | ||
M packages/agent/modes/swarm_slash.go +4 -1
| @@ -99,6 +99,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 99 | 99 | // Anything that isn't a recognised flag terminates parsing and | |
| 100 | 100 | // the rest becomes the task; only leading flags are consumed. | |
| 101 | 101 | model, provider, reasoning, subagent, task := parseSpawnFlags(rest) | |
| 102 | + | var fastModeOverride *bool | |
| 102 | 103 | if task == "" { | |
| 103 | 104 | i.swarmStatus("", "/swarm new: missing task (after any spawn flags)") | |
| 104 | 105 | return | |
| @@ -118,6 +119,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 118 | 119 | return | |
| 119 | 120 | } | |
| 120 | 121 | subagent = profile.Name | |
| 122 | + | fastModeOverride = profile.FastMode | |
| 121 | 123 | if model == "" && provider == "" { | |
| 122 | 124 | profileProvider, profileModel := profile.ModelSelection() | |
| 123 | 125 | if profileProvider != "" && profileModel != "" { | |
| @@ -140,7 +142,8 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||
| 140 | 142 | reasoning = normalizedReasoning | |
| 141 | 143 | } | |
| 142 | 144 | a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | |
| 143 | - | Task: task, Model: model, Provider: provider, Reasoning: reasoning, Subagent: subagent, | |
| 145 | + | Task: task, Model: model, Provider: provider, Reasoning: reasoning, | |
| 146 | + | FastMode: fastModeOverride, Subagent: subagent, | |
| 144 | 147 | }) | |
| 145 | 148 | if err != nil { | |
| 146 | 149 | i.swarmStatus("", "spawn: "+err.Error()) | |
| @@ -99,6 +99,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||||
| 99 | // Anything that isn't a recognised flag terminates parsing and | 99 | // Anything that isn't a recognised flag terminates parsing and | ||
| 100 | // the rest becomes the task; only leading flags are consumed. | 100 | // the rest becomes the task; only leading flags are consumed. | ||
| 101 | model, provider, reasoning, subagent, task := parseSpawnFlags(rest) | 101 | model, provider, reasoning, subagent, task := parseSpawnFlags(rest) | ||
| 102 | + | var fastModeOverride *bool | |||
| 102 | if task == "" { | 103 | if task == "" { | ||
| 103 | i.swarmStatus("", "/swarm new: missing task (after any spawn flags)") | 104 | i.swarmStatus("", "/swarm new: missing task (after any spawn flags)") | ||
| 104 | return | 105 | return | ||
| @@ -118,6 +119,7 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||||
| 118 | return | 119 | return | ||
| 119 | } | 120 | } | ||
| 120 | subagent = profile.Name | 121 | subagent = profile.Name | ||
| 122 | + | fastModeOverride = profile.FastMode | |||
| 121 | if model == "" && provider == "" { | 123 | if model == "" && provider == "" { | ||
| 122 | profileProvider, profileModel := profile.ModelSelection() | 124 | profileProvider, profileModel := profile.ModelSelection() | ||
| 123 | if profileProvider != "" && profileModel != "" { | 125 | if profileProvider != "" && profileModel != "" { | ||
| @@ -140,7 +142,8 @@ func (i *Interactive) runSwarm(ctx context.Context, args []string) { | |||||
| 140 | reasoning = normalizedReasoning | 142 | reasoning = normalizedReasoning | ||
| 141 | } | 143 | } | ||
| 142 | a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | 144 | a, err := i.cfg.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | ||
| 143 | - | Task: task, Model: model, Provider: provider, Reasoning: reasoning, Subagent: subagent, | 145 | + | Task: task, Model: model, Provider: provider, Reasoning: reasoning, |
| 146 | + | FastMode: fastModeOverride, Subagent: subagent, | |||
| 144 | }) | 147 | }) | ||
| 145 | if err != nil { | 148 | if err != nil { | ||
| 146 | i.swarmStatus("", "spawn: "+err.Error()) | 149 | i.swarmStatus("", "spawn: "+err.Error()) | ||
M packages/agent/modes/swarm_slash_test.go +32 -0
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "testing" | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/subagents" | |
| 10 | 11 | "github.com/patriceckhart/zot/packages/agent/swarm" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| @@ -110,6 +111,37 @@ func TestRunSwarmNewSpawnsAgent(t *testing.T) { | |||
| 110 | 111 | } | |
| 111 | 112 | } | |
| 112 | 113 | ||
| 114 | + | func TestRunSwarmNamedProfileAppliesFastModeRestriction(t *testing.T) { | |
| 115 | + | root := t.TempDir() | |
| 116 | + | profileFastMode := false | |
| 117 | + | f := swarm.New(swarm.Config{ | |
| 118 | + | Root: root, | |
| 119 | + | RepoRoot: root, | |
| 120 | + | FastMode: true, | |
| 121 | + | NewRunner: func(*swarm.Agent) swarm.Runner { | |
| 122 | + | return swarm.RunnerFunc(func(ctx context.Context, _ swarm.Sink) error { | |
| 123 | + | <-ctx.Done() | |
| 124 | + | return ctx.Err() | |
| 125 | + | }) | |
| 126 | + | }, | |
| 127 | + | }) | |
| 128 | + | defer f.StopAll() | |
| 129 | + | iv := &Interactive{swarmDialog: newSwarmDialog(), dirty: make(chan struct{}, 1)} | |
| 130 | + | iv.cfg.Swarm = f | |
| 131 | + | iv.cfg.ResolveSubagent = func(name string) (*subagents.Profile, error) { | |
| 132 | + | if name != "reviewer" { | |
| 133 | + | return nil, nil | |
| 134 | + | } | |
| 135 | + | return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | iv.runSwarm(context.Background(), []string{"new", "--agent", "reviewer", "review", "auth"}) | |
| 139 | + | agents := f.List() | |
| 140 | + | if len(agents) != 1 || agents[0].FastMode { | |
| 141 | + | t.Fatalf("agent fast mode = %#v, want disabled by profile", agents) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 113 | 145 | // TestRunSwarmSendDeliversToAgentInbox spins up a real agent with a | |
| 114 | 146 | // fake Runner whose only job is to forward inbox lines to a channel, | |
| 115 | 147 | // then asserts the /swarm send <id> <text...> path routes through | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "testing" | 7 | "testing" | ||
| 8 | "time" | 8 | "time" | ||
| 9 | 9 | ||||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/subagents" | |||
| 10 | "github.com/patriceckhart/zot/packages/agent/swarm" | 11 | "github.com/patriceckhart/zot/packages/agent/swarm" | ||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| @@ -110,6 +111,37 @@ func TestRunSwarmNewSpawnsAgent(t *testing.T) { | |||||
| 110 | } | 111 | } | ||
| 111 | } | 112 | } | ||
| 112 | 113 | ||||
| 114 | + | func TestRunSwarmNamedProfileAppliesFastModeRestriction(t *testing.T) { | |||
| 115 | + | root := t.TempDir() | |||
| 116 | + | profileFastMode := false | |||
| 117 | + | f := swarm.New(swarm.Config{ | |||
| 118 | + | Root: root, | |||
| 119 | + | RepoRoot: root, | |||
| 120 | + | FastMode: true, | |||
| 121 | + | NewRunner: func(*swarm.Agent) swarm.Runner { | |||
| 122 | + | return swarm.RunnerFunc(func(ctx context.Context, _ swarm.Sink) error { | |||
| 123 | + | <-ctx.Done() | |||
| 124 | + | return ctx.Err() | |||
| 125 | + | }) | |||
| 126 | + | }, | |||
| 127 | + | }) | |||
| 128 | + | defer f.StopAll() | |||
| 129 | + | iv := &Interactive{swarmDialog: newSwarmDialog(), dirty: make(chan struct{}, 1)} | |||
| 130 | + | iv.cfg.Swarm = f | |||
| 131 | + | iv.cfg.ResolveSubagent = func(name string) (*subagents.Profile, error) { | |||
| 132 | + | if name != "reviewer" { | |||
| 133 | + | return nil, nil | |||
| 134 | + | } | |||
| 135 | + | return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | iv.runSwarm(context.Background(), []string{"new", "--agent", "reviewer", "review", "auth"}) | |||
| 139 | + | agents := f.List() | |||
| 140 | + | if len(agents) != 1 || agents[0].FastMode { | |||
| 141 | + | t.Fatalf("agent fast mode = %#v, want disabled by profile", agents) | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | ||||
| 113 | // TestRunSwarmSendDeliversToAgentInbox spins up a real agent with a | 145 | // TestRunSwarmSendDeliversToAgentInbox spins up a real agent with a | ||
| 114 | // fake Runner whose only job is to forward inbox lines to a channel, | 146 | // fake Runner whose only job is to forward inbox lines to a channel, | ||
| 115 | // then asserts the /swarm send <id> <text...> path routes through | 147 | // then asserts the /swarm send <id> <text...> path routes through | ||
M packages/agent/subagents/profiles.go +15 -2
| @@ -30,6 +30,9 @@ type Profile struct { | |||
| 30 | 30 | Provider string | |
| 31 | 31 | Thinking string | |
| 32 | 32 | ||
| 33 | + | // Nil means the profile did not specify whether fast mode is enabled. | |
| 34 | + | FastMode *bool | |
| 35 | + | ||
| 33 | 36 | // SystemPromptMode is "append" (the default) or "replace". | |
| 34 | 37 | SystemPromptMode string | |
| 35 | 38 | ||
| @@ -125,7 +128,7 @@ func SystemPromptAddendum(profiles []*Profile) string { | |||
| 125 | 128 | } | |
| 126 | 129 | var sb strings.Builder | |
| 127 | 130 | sb.WriteString("[subagents_list]\n") | |
| 128 | - | sb.WriteString("Named subagents available to swarm_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, and tool limits are applied to the child.\n") | |
| 131 | + | sb.WriteString("Named subagents available to swarm_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, and fast-mode preference are applied to the child.\n") | |
| 129 | 132 | for _, profile := range profiles { | |
| 130 | 133 | if profile == nil { | |
| 131 | 134 | continue | |
| @@ -135,7 +138,7 @@ func SystemPromptAddendum(profiles []*Profile) string { | |||
| 135 | 138 | if description == "" { | |
| 136 | 139 | description = "(no description)" | |
| 137 | 140 | } | |
| 138 | - | metadata := make([]string, 0, 4) | |
| 141 | + | metadata := make([]string, 0, 5) | |
| 139 | 142 | if model := manifestValue(profile.Model); model != "" { | |
| 140 | 143 | metadata = append(metadata, "model="+model) | |
| 141 | 144 | } | |
| @@ -148,6 +151,9 @@ func SystemPromptAddendum(profiles []*Profile) string { | |||
| 148 | 151 | if len(profile.Tools) > 0 { | |
| 149 | 152 | metadata = append(metadata, "tools="+manifestValue(strings.Join(profile.Tools, ","))) | |
| 150 | 153 | } | |
| 154 | + | if profile.FastMode != nil { | |
| 155 | + | metadata = append(metadata, "fastMode="+strconv.FormatBool(*profile.FastMode)) | |
| 156 | + | } | |
| 151 | 157 | if len(metadata) > 0 { | |
| 152 | 158 | fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description) | |
| 153 | 159 | } else { | |
| @@ -263,6 +269,13 @@ func load(path, source string) (*Profile, error) { | |||
| 263 | 269 | } | |
| 264 | 270 | profile.InheritSkills = &value | |
| 265 | 271 | } | |
| 272 | + | if raw, ok := values["fastmode"]; ok { | |
| 273 | + | value, parsed := parseOptionalBool(raw) | |
| 274 | + | if !parsed { | |
| 275 | + | return nil, fmt.Errorf("fastMode must be true or false") | |
| 276 | + | } | |
| 277 | + | profile.FastMode = &value | |
| 278 | + | } | |
| 266 | 279 | return profile, nil | |
| 267 | 280 | } | |
| 268 | 281 | ||
| @@ -30,6 +30,9 @@ type Profile struct { | |||||
| 30 | Provider string | 30 | Provider string | ||
| 31 | Thinking string | 31 | Thinking string | ||
| 32 | 32 | ||||
| 33 | + | // Nil means the profile did not specify whether fast mode is enabled. | |||
| 34 | + | FastMode *bool | |||
| 35 | + | ||||
| 33 | // SystemPromptMode is "append" (the default) or "replace". | 36 | // SystemPromptMode is "append" (the default) or "replace". | ||
| 34 | SystemPromptMode string | 37 | SystemPromptMode string | ||
| 35 | 38 | ||||
| @@ -125,7 +128,7 @@ func SystemPromptAddendum(profiles []*Profile) string { | |||||
| 125 | } | 128 | } | ||
| 126 | var sb strings.Builder | 129 | var sb strings.Builder | ||
| 127 | sb.WriteString("[subagents_list]\n") | 130 | sb.WriteString("[subagents_list]\n") | ||
| 128 | - | sb.WriteString("Named subagents available to swarm_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, and tool limits are applied to the child.\n") | 131 | + | sb.WriteString("Named subagents available to swarm_spawn. Choose the profile whose description best matches the independent task and pass its name as the tool's agent field. The selected profile's instructions, model, thinking level, tool limits, and fast-mode preference are applied to the child.\n") |
| 129 | for _, profile := range profiles { | 132 | for _, profile := range profiles { | ||
| 130 | if profile == nil { | 133 | if profile == nil { | ||
| 131 | continue | 134 | continue | ||
| @@ -135,7 +138,7 @@ func SystemPromptAddendum(profiles []*Profile) string { | |||||
| 135 | if description == "" { | 138 | if description == "" { | ||
| 136 | description = "(no description)" | 139 | description = "(no description)" | ||
| 137 | } | 140 | } | ||
| 138 | - | metadata := make([]string, 0, 4) | 141 | + | metadata := make([]string, 0, 5) |
| 139 | if model := manifestValue(profile.Model); model != "" { | 142 | if model := manifestValue(profile.Model); model != "" { | ||
| 140 | metadata = append(metadata, "model="+model) | 143 | metadata = append(metadata, "model="+model) | ||
| 141 | } | 144 | } | ||
| @@ -148,6 +151,9 @@ func SystemPromptAddendum(profiles []*Profile) string { | |||||
| 148 | if len(profile.Tools) > 0 { | 151 | if len(profile.Tools) > 0 { | ||
| 149 | metadata = append(metadata, "tools="+manifestValue(strings.Join(profile.Tools, ","))) | 152 | metadata = append(metadata, "tools="+manifestValue(strings.Join(profile.Tools, ","))) | ||
| 150 | } | 153 | } | ||
| 154 | + | if profile.FastMode != nil { | |||
| 155 | + | metadata = append(metadata, "fastMode="+strconv.FormatBool(*profile.FastMode)) | |||
| 156 | + | } | |||
| 151 | if len(metadata) > 0 { | 157 | if len(metadata) > 0 { | ||
| 152 | fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description) | 158 | fmt.Fprintf(&sb, "- %s [%s]: %s\n", name, strings.Join(metadata, " "), description) | ||
| 153 | } else { | 159 | } else { | ||
| @@ -263,6 +269,13 @@ func load(path, source string) (*Profile, error) { | |||||
| 263 | } | 269 | } | ||
| 264 | profile.InheritSkills = &value | 270 | profile.InheritSkills = &value | ||
| 265 | } | 271 | } | ||
| 272 | + | if raw, ok := values["fastmode"]; ok { | |||
| 273 | + | value, parsed := parseOptionalBool(raw) | |||
| 274 | + | if !parsed { | |||
| 275 | + | return nil, fmt.Errorf("fastMode must be true or false") | |||
| 276 | + | } | |||
| 277 | + | profile.FastMode = &value | |||
| 278 | + | } | |||
| 266 | return profile, nil | 279 | return profile, nil | ||
| 267 | } | 280 | } | ||
| 268 | 281 | ||||
M packages/agent/subagents/profiles_test.go +13 -1
| @@ -27,6 +27,7 @@ thinking: max | |||
| 27 | 27 | systemPromptMode: replace | |
| 28 | 28 | inheritProjectContext: false | |
| 29 | 29 | inheritSkills: false | |
| 30 | + | fastMode: false | |
| 30 | 31 | --- | |
| 31 | 32 | Review the requested scope without editing it. | |
| 32 | 33 | `) | |
| @@ -66,6 +67,13 @@ Implement the requested change. | |||
| 66 | 67 | if reviewer.InheritSkills == nil || *reviewer.InheritSkills { | |
| 67 | 68 | t.Fatalf("inherit skills = %#v, want false", reviewer.InheritSkills) | |
| 68 | 69 | } | |
| 70 | + | if reviewer.FastMode == nil || *reviewer.FastMode { | |
| 71 | + | t.Fatalf("fast mode = %#v, want false", reviewer.FastMode) | |
| 72 | + | } | |
| 73 | + | implementer := Find(profiles, "implementer") | |
| 74 | + | if implementer == nil || implementer.FastMode != nil { | |
| 75 | + | t.Fatalf("unset fast mode = %#v, want nil", implementer) | |
| 76 | + | } | |
| 69 | 77 | } | |
| 70 | 78 | ||
| 71 | 79 | func TestDiscoverResolvesConfiguredRelativePathAgainstCWD(t *testing.T) { | |
| @@ -155,6 +163,7 @@ func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) { | |||
| 155 | 163 | {name: "invalid-prompt-mode", field: "systemPromptMode: merge"}, | |
| 156 | 164 | {name: "invalid-project-context", field: "inheritProjectContext: sometimes"}, | |
| 157 | 165 | {name: "invalid-skills", field: "inheritSkills: sometimes"}, | |
| 166 | + | {name: "invalid-fast-mode", field: "fastMode: sometimes"}, | |
| 158 | 167 | } | |
| 159 | 168 | for _, tc := range cases { | |
| 160 | 169 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -188,11 +197,12 @@ func TestSystemPromptAddendumIsCompactAndDoesNotExposeBodyOrPath(t *testing.T) { | |||
| 188 | 197 | SystemPrompt: "secret instructions that belong only to the child", | |
| 189 | 198 | Model: "openai-codex/gpt-5", | |
| 190 | 199 | Thinking: "max", | |
| 200 | + | FastMode: boolPtr(false), | |
| 191 | 201 | Tools: []string{"read", "bash"}, | |
| 192 | 202 | Path: "/private/profile.md", | |
| 193 | 203 | }} | |
| 194 | 204 | got := SystemPromptAddendum(profiles) | |
| 195 | - | for _, want := range []string{"[subagents_list]", "[/subagents_list]", "reviewer", "Read-only reviewer", "model=openai-codex/gpt-5", "thinking=max"} { | |
| 205 | + | for _, want := range []string{"[subagents_list]", "[/subagents_list]", "reviewer", "Read-only reviewer", "model=openai-codex/gpt-5", "thinking=max", "fastMode=false"} { | |
| 196 | 206 | if !strings.Contains(got, want) { | |
| 197 | 207 | t.Fatalf("addendum missing %q:\n%s", want, got) | |
| 198 | 208 | } | |
| @@ -204,6 +214,8 @@ func TestSystemPromptAddendumIsCompactAndDoesNotExposeBodyOrPath(t *testing.T) { | |||
| 204 | 214 | } | |
| 205 | 215 | } | |
| 206 | 216 | ||
| 217 | + | func boolPtr(value bool) *bool { return &value } | |
| 218 | + | ||
| 207 | 219 | func writeProfile(t *testing.T, path, content string) { | |
| 208 | 220 | t.Helper() | |
| 209 | 221 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | |
| @@ -27,6 +27,7 @@ thinking: max | |||||
| 27 | systemPromptMode: replace | 27 | systemPromptMode: replace | ||
| 28 | inheritProjectContext: false | 28 | inheritProjectContext: false | ||
| 29 | inheritSkills: false | 29 | inheritSkills: false | ||
| 30 | + | fastMode: false | |||
| 30 | --- | 31 | --- | ||
| 31 | Review the requested scope without editing it. | 32 | Review the requested scope without editing it. | ||
| 32 | `) | 33 | `) | ||
| @@ -66,6 +67,13 @@ Implement the requested change. | |||||
| 66 | if reviewer.InheritSkills == nil || *reviewer.InheritSkills { | 67 | if reviewer.InheritSkills == nil || *reviewer.InheritSkills { | ||
| 67 | t.Fatalf("inherit skills = %#v, want false", reviewer.InheritSkills) | 68 | t.Fatalf("inherit skills = %#v, want false", reviewer.InheritSkills) | ||
| 68 | } | 69 | } | ||
| 70 | + | if reviewer.FastMode == nil || *reviewer.FastMode { | |||
| 71 | + | t.Fatalf("fast mode = %#v, want false", reviewer.FastMode) | |||
| 72 | + | } | |||
| 73 | + | implementer := Find(profiles, "implementer") | |||
| 74 | + | if implementer == nil || implementer.FastMode != nil { | |||
| 75 | + | t.Fatalf("unset fast mode = %#v, want nil", implementer) | |||
| 76 | + | } | |||
| 69 | } | 77 | } | ||
| 70 | 78 | ||||
| 71 | func TestDiscoverResolvesConfiguredRelativePathAgainstCWD(t *testing.T) { | 79 | func TestDiscoverResolvesConfiguredRelativePathAgainstCWD(t *testing.T) { | ||
| @@ -155,6 +163,7 @@ func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) { | |||||
| 155 | {name: "invalid-prompt-mode", field: "systemPromptMode: merge"}, | 163 | {name: "invalid-prompt-mode", field: "systemPromptMode: merge"}, | ||
| 156 | {name: "invalid-project-context", field: "inheritProjectContext: sometimes"}, | 164 | {name: "invalid-project-context", field: "inheritProjectContext: sometimes"}, | ||
| 157 | {name: "invalid-skills", field: "inheritSkills: sometimes"}, | 165 | {name: "invalid-skills", field: "inheritSkills: sometimes"}, | ||
| 166 | + | {name: "invalid-fast-mode", field: "fastMode: sometimes"}, | |||
| 158 | } | 167 | } | ||
| 159 | for _, tc := range cases { | 168 | for _, tc := range cases { | ||
| 160 | t.Run(tc.name, func(t *testing.T) { | 169 | t.Run(tc.name, func(t *testing.T) { | ||
| @@ -188,11 +197,12 @@ func TestSystemPromptAddendumIsCompactAndDoesNotExposeBodyOrPath(t *testing.T) { | |||||
| 188 | SystemPrompt: "secret instructions that belong only to the child", | 197 | SystemPrompt: "secret instructions that belong only to the child", | ||
| 189 | Model: "openai-codex/gpt-5", | 198 | Model: "openai-codex/gpt-5", | ||
| 190 | Thinking: "max", | 199 | Thinking: "max", | ||
| 200 | + | FastMode: boolPtr(false), | |||
| 191 | Tools: []string{"read", "bash"}, | 201 | Tools: []string{"read", "bash"}, | ||
| 192 | Path: "/private/profile.md", | 202 | Path: "/private/profile.md", | ||
| 193 | }} | 203 | }} | ||
| 194 | got := SystemPromptAddendum(profiles) | 204 | got := SystemPromptAddendum(profiles) | ||
| 195 | - | for _, want := range []string{"[subagents_list]", "[/subagents_list]", "reviewer", "Read-only reviewer", "model=openai-codex/gpt-5", "thinking=max"} { | 205 | + | for _, want := range []string{"[subagents_list]", "[/subagents_list]", "reviewer", "Read-only reviewer", "model=openai-codex/gpt-5", "thinking=max", "fastMode=false"} { |
| 196 | if !strings.Contains(got, want) { | 206 | if !strings.Contains(got, want) { | ||
| 197 | t.Fatalf("addendum missing %q:\n%s", want, got) | 207 | t.Fatalf("addendum missing %q:\n%s", want, got) | ||
| 198 | } | 208 | } | ||
| @@ -204,6 +214,8 @@ func TestSystemPromptAddendumIsCompactAndDoesNotExposeBodyOrPath(t *testing.T) { | |||||
| 204 | } | 214 | } | ||
| 205 | } | 215 | } | ||
| 206 | 216 | ||||
| 217 | + | func boolPtr(value bool) *bool { return &value } | |||
| 218 | + | ||||
| 207 | func writeProfile(t *testing.T, path, content string) { | 219 | func writeProfile(t *testing.T, path, content string) { | ||
| 208 | t.Helper() | 220 | t.Helper() | ||
| 209 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | 221 | if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { | ||
M packages/agent/swarm/fast_mode_test.go +45 -0
| @@ -58,6 +58,51 @@ func TestSwarmFastModePropagatesToChild(t *testing.T) { | |||
| 58 | 58 | } | |
| 59 | 59 | } | |
| 60 | 60 | ||
| 61 | + | func TestSpawnRequestFastModeIsBoundByHostSetting(t *testing.T) { | |
| 62 | + | falseValue, trueValue := false, true | |
| 63 | + | cases := []struct { | |
| 64 | + | name string | |
| 65 | + | hostFast bool | |
| 66 | + | profileFast *bool | |
| 67 | + | want bool | |
| 68 | + | }{ | |
| 69 | + | {name: "unset inherits enabled host", hostFast: true, want: true}, | |
| 70 | + | {name: "unset inherits disabled host", hostFast: false, want: false}, | |
| 71 | + | {name: "false disables enabled host", hostFast: true, profileFast: &falseValue, want: false}, | |
| 72 | + | {name: "true cannot enable disabled host", hostFast: false, profileFast: &trueValue, want: false}, | |
| 73 | + | {name: "true preserves enabled host", hostFast: true, profileFast: &trueValue, want: true}, | |
| 74 | + | } | |
| 75 | + | for _, tc := range cases { | |
| 76 | + | t.Run(tc.name, func(t *testing.T) { | |
| 77 | + | root := t.TempDir() | |
| 78 | + | f := New(Config{ | |
| 79 | + | Root: root, | |
| 80 | + | RepoRoot: root, | |
| 81 | + | FastMode: tc.hostFast, | |
| 82 | + | NewRunner: func(*Agent) Runner { | |
| 83 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |
| 84 | + | }, | |
| 85 | + | }) | |
| 86 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "x", FastMode: tc.profileFast}) | |
| 87 | + | if err != nil { | |
| 88 | + | t.Fatalf("spawn: %v", err) | |
| 89 | + | } | |
| 90 | + | a.Wait() | |
| 91 | + | if a.FastMode != tc.want { | |
| 92 | + | t.Fatalf("FastMode = %v, want %v", a.FastMode, tc.want) | |
| 93 | + | } | |
| 94 | + | args := defaultChildArgs("/zot", a, "/session", "/inbox") | |
| 95 | + | wantFlag := "--no-fast-mode" | |
| 96 | + | if tc.want { | |
| 97 | + | wantFlag = "--fast-mode" | |
| 98 | + | } | |
| 99 | + | if !containsArg(args, wantFlag) { | |
| 100 | + | t.Fatalf("child args = %v, want %s", args, wantFlag) | |
| 101 | + | } | |
| 102 | + | }) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 61 | 106 | func TestSwarmReloadPreservesExplicitFastModeOff(t *testing.T) { | |
| 62 | 107 | root := t.TempDir() | |
| 63 | 108 | newSwarm := func(fastMode bool) *Swarm { | |
| @@ -58,6 +58,51 @@ func TestSwarmFastModePropagatesToChild(t *testing.T) { | |||||
| 58 | } | 58 | } | ||
| 59 | } | 59 | } | ||
| 60 | 60 | ||||
| 61 | + | func TestSpawnRequestFastModeIsBoundByHostSetting(t *testing.T) { | |||
| 62 | + | falseValue, trueValue := false, true | |||
| 63 | + | cases := []struct { | |||
| 64 | + | name string | |||
| 65 | + | hostFast bool | |||
| 66 | + | profileFast *bool | |||
| 67 | + | want bool | |||
| 68 | + | }{ | |||
| 69 | + | {name: "unset inherits enabled host", hostFast: true, want: true}, | |||
| 70 | + | {name: "unset inherits disabled host", hostFast: false, want: false}, | |||
| 71 | + | {name: "false disables enabled host", hostFast: true, profileFast: &falseValue, want: false}, | |||
| 72 | + | {name: "true cannot enable disabled host", hostFast: false, profileFast: &trueValue, want: false}, | |||
| 73 | + | {name: "true preserves enabled host", hostFast: true, profileFast: &trueValue, want: true}, | |||
| 74 | + | } | |||
| 75 | + | for _, tc := range cases { | |||
| 76 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 77 | + | root := t.TempDir() | |||
| 78 | + | f := New(Config{ | |||
| 79 | + | Root: root, | |||
| 80 | + | RepoRoot: root, | |||
| 81 | + | FastMode: tc.hostFast, | |||
| 82 | + | NewRunner: func(*Agent) Runner { | |||
| 83 | + | return RunnerFunc(func(context.Context, Sink) error { return nil }) | |||
| 84 | + | }, | |||
| 85 | + | }) | |||
| 86 | + | a, err := f.SpawnReq(context.Background(), SpawnRequest{Task: "x", FastMode: tc.profileFast}) | |||
| 87 | + | if err != nil { | |||
| 88 | + | t.Fatalf("spawn: %v", err) | |||
| 89 | + | } | |||
| 90 | + | a.Wait() | |||
| 91 | + | if a.FastMode != tc.want { | |||
| 92 | + | t.Fatalf("FastMode = %v, want %v", a.FastMode, tc.want) | |||
| 93 | + | } | |||
| 94 | + | args := defaultChildArgs("/zot", a, "/session", "/inbox") | |||
| 95 | + | wantFlag := "--no-fast-mode" | |||
| 96 | + | if tc.want { | |||
| 97 | + | wantFlag = "--fast-mode" | |||
| 98 | + | } | |||
| 99 | + | if !containsArg(args, wantFlag) { | |||
| 100 | + | t.Fatalf("child args = %v, want %s", args, wantFlag) | |||
| 101 | + | } | |||
| 102 | + | }) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 61 | func TestSwarmReloadPreservesExplicitFastModeOff(t *testing.T) { | 106 | func TestSwarmReloadPreservesExplicitFastModeOff(t *testing.T) { | ||
| 62 | root := t.TempDir() | 107 | root := t.TempDir() | ||
| 63 | newSwarm := func(fastMode bool) *Swarm { | 108 | newSwarm := func(fastMode bool) *Swarm { | ||
M packages/agent/swarm/swarm.go +10 -2
| @@ -217,13 +217,18 @@ func (f *Swarm) agentStateDir(id string) string { | |||
| 217 | 217 | // chosen model regardless of the parent's current selection. A named | |
| 218 | 218 | // Subagent is passed to the child as --subagent, where it resolves the | |
| 219 | 219 | // markdown profile and applies its instructions. Reasoning is passed | |
| 220 | - | // as --reasoning when set. | |
| 220 | + | // as --reasoning when set. FastMode restricts the host setting when a | |
| 221 | + | // selected profile declares a fastMode preference. | |
| 221 | 222 | type SpawnRequest struct { | |
| 222 | 223 | Task string | |
| 223 | 224 | Model string // optional override; child resolves default if empty | |
| 224 | 225 | Provider string // optional override; usually paired with Model | |
| 225 | 226 | Reasoning string // optional reasoning/thinking level override | |
| 226 | - | Subagent string // optional named markdown profile | |
| 227 | + | // FastMode is an optional profile restriction. A non-nil false value | |
| 228 | + | // disables fast mode for this child; true still cannot enable fast mode | |
| 229 | + | // when Config.FastMode is false. Nil inherits Config.FastMode. | |
| 230 | + | FastMode *bool | |
| 231 | + | Subagent string // optional named markdown profile | |
| 227 | 232 | } | |
| 228 | 233 | ||
| 229 | 234 | // Spawn creates a new Agent for the given task, allocates its | |
| @@ -258,6 +263,9 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) | |||
| 258 | 263 | fastMode := f.cfg.FastMode | |
| 259 | 264 | sessionID := f.activeSession | |
| 260 | 265 | f.mu.Unlock() | |
| 266 | + | if req.FastMode != nil { | |
| 267 | + | fastMode = fastMode && *req.FastMode | |
| 268 | + | } | |
| 261 | 269 | ||
| 262 | 270 | stateDir := f.agentStateDir(id) | |
| 263 | 271 | if err := os.MkdirAll(stateDir, 0o755); err != nil { | |
| @@ -217,13 +217,18 @@ func (f *Swarm) agentStateDir(id string) string { | |||||
| 217 | // chosen model regardless of the parent's current selection. A named | 217 | // chosen model regardless of the parent's current selection. A named | ||
| 218 | // Subagent is passed to the child as --subagent, where it resolves the | 218 | // Subagent is passed to the child as --subagent, where it resolves the | ||
| 219 | // markdown profile and applies its instructions. Reasoning is passed | 219 | // markdown profile and applies its instructions. Reasoning is passed | ||
| 220 | - | // as --reasoning when set. | 220 | + | // as --reasoning when set. FastMode restricts the host setting when a |
| 221 | + | // selected profile declares a fastMode preference. | |||
| 221 | type SpawnRequest struct { | 222 | type SpawnRequest struct { | ||
| 222 | Task string | 223 | Task string | ||
| 223 | Model string // optional override; child resolves default if empty | 224 | Model string // optional override; child resolves default if empty | ||
| 224 | Provider string // optional override; usually paired with Model | 225 | Provider string // optional override; usually paired with Model | ||
| 225 | Reasoning string // optional reasoning/thinking level override | 226 | Reasoning string // optional reasoning/thinking level override | ||
| 226 | - | Subagent string // optional named markdown profile | 227 | + | // FastMode is an optional profile restriction. A non-nil false value |
| 228 | + | // disables fast mode for this child; true still cannot enable fast mode | |||
| 229 | + | // when Config.FastMode is false. Nil inherits Config.FastMode. | |||
| 230 | + | FastMode *bool | |||
| 231 | + | Subagent string // optional named markdown profile | |||
| 227 | } | 232 | } | ||
| 228 | 233 | ||||
| 229 | // Spawn creates a new Agent for the given task, allocates its | 234 | // Spawn creates a new Agent for the given task, allocates its | ||
| @@ -258,6 +263,9 @@ func (f *Swarm) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, error) | |||||
| 258 | fastMode := f.cfg.FastMode | 263 | fastMode := f.cfg.FastMode | ||
| 259 | sessionID := f.activeSession | 264 | sessionID := f.activeSession | ||
| 260 | f.mu.Unlock() | 265 | f.mu.Unlock() | ||
| 266 | + | if req.FastMode != nil { | |||
| 267 | + | fastMode = fastMode && *req.FastMode | |||
| 268 | + | } | |||
| 261 | 269 | ||||
| 262 | stateDir := f.agentStateDir(id) | 270 | stateDir := f.agentStateDir(id) | ||
| 263 | if err := os.MkdirAll(stateDir, 0o755); err != nil { | 271 | if err := os.MkdirAll(stateDir, 0o755); err != nil { | ||
M packages/agent/tools/swarm_spawn.go +5 -2
| @@ -69,7 +69,7 @@ const swarmSpawnSchema = `{ | |||
| 69 | 69 | }, | |
| 70 | 70 | "agent": { | |
| 71 | 71 | "type": "string", | |
| 72 | - | "description": "Optional named markdown profile from [subagents_list]. The child applies that profile's system prompt, model, thinking level, and tool limits. Omit for a generic child." | |
| 72 | + | "description": "Optional named markdown profile from [subagents_list]. The child applies that profile's system prompt, model, thinking level, tool limits, and fast-mode preference. Omit for a generic child." | |
| 73 | 73 | }, | |
| 74 | 74 | "model": { | |
| 75 | 75 | "type": "string", | |
| @@ -95,7 +95,7 @@ const swarmSpawnSchema = `{ | |||
| 95 | 95 | ||
| 96 | 96 | func (t *SwarmSpawnTool) Name() string { return "swarm_spawn" } | |
| 97 | 97 | func (t *SwarmSpawnTool) Description() string { | |
| 98 | - | return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile with agent and set its model/provider/reasoning. Returns the sub-agent id immediately; the sub-agent keeps running while this conversation continues. The sub-agent shares this working directory." | |
| 98 | + | return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile with agent and set its model/provider/reasoning. Named profiles may restrict fast mode but cannot enable it when the host setting is off. Returns the sub-agent id immediately; the sub-agent keeps running while this conversation continues. The sub-agent shares this working directory." | |
| 99 | 99 | } | |
| 100 | 100 | func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) } | |
| 101 | 101 | ||
| @@ -117,6 +117,7 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 117 | 117 | ||
| 118 | 118 | agentName := strings.TrimSpace(a.Agent) | |
| 119 | 119 | var profile *subagents.Profile | |
| 120 | + | var fastModeOverride *bool | |
| 120 | 121 | if agentName != "" { | |
| 121 | 122 | if t.ResolveSubagent == nil { | |
| 122 | 123 | return protocolToolError("swarm_spawn: named subagent profiles are unavailable") | |
| @@ -130,6 +131,7 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 130 | 131 | return protocolToolError("swarm_spawn: unknown subagent profile " + agentName) | |
| 131 | 132 | } | |
| 132 | 133 | agentName = profile.Name | |
| 134 | + | fastModeOverride = profile.FastMode | |
| 133 | 135 | } | |
| 134 | 136 | ||
| 135 | 137 | model := strings.TrimSpace(a.Model) | |
| @@ -178,6 +180,7 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 178 | 180 | Model: model, | |
| 179 | 181 | Provider: providerID, | |
| 180 | 182 | Reasoning: reasoning, | |
| 183 | + | FastMode: fastModeOverride, | |
| 181 | 184 | Subagent: agentName, | |
| 182 | 185 | }) | |
| 183 | 186 | if err != nil { | |
| @@ -69,7 +69,7 @@ const swarmSpawnSchema = `{ | |||||
| 69 | }, | 69 | }, | ||
| 70 | "agent": { | 70 | "agent": { | ||
| 71 | "type": "string", | 71 | "type": "string", | ||
| 72 | - | "description": "Optional named markdown profile from [subagents_list]. The child applies that profile's system prompt, model, thinking level, and tool limits. Omit for a generic child." | 72 | + | "description": "Optional named markdown profile from [subagents_list]. The child applies that profile's system prompt, model, thinking level, tool limits, and fast-mode preference. Omit for a generic child." |
| 73 | }, | 73 | }, | ||
| 74 | "model": { | 74 | "model": { | ||
| 75 | "type": "string", | 75 | "type": "string", | ||
| @@ -95,7 +95,7 @@ const swarmSpawnSchema = `{ | |||||
| 95 | 95 | ||||
| 96 | func (t *SwarmSpawnTool) Name() string { return "swarm_spawn" } | 96 | func (t *SwarmSpawnTool) Name() string { return "swarm_spawn" } | ||
| 97 | func (t *SwarmSpawnTool) Description() string { | 97 | func (t *SwarmSpawnTool) Description() string { | ||
| 98 | - | return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile with agent and set its model/provider/reasoning. Returns the sub-agent id immediately; the sub-agent keeps running while this conversation continues. The sub-agent shares this working directory." | 98 | + | return "Spawn a background sub-agent to work on a parallel sub-task. Optionally select a named markdown profile with agent and set its model/provider/reasoning. Named profiles may restrict fast mode but cannot enable it when the host setting is off. Returns the sub-agent id immediately; the sub-agent keeps running while this conversation continues. The sub-agent shares this working directory." |
| 99 | } | 99 | } | ||
| 100 | func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) } | 100 | func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) } | ||
| 101 | 101 | ||||
| @@ -117,6 +117,7 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 117 | 117 | ||||
| 118 | agentName := strings.TrimSpace(a.Agent) | 118 | agentName := strings.TrimSpace(a.Agent) | ||
| 119 | var profile *subagents.Profile | 119 | var profile *subagents.Profile | ||
| 120 | + | var fastModeOverride *bool | |||
| 120 | if agentName != "" { | 121 | if agentName != "" { | ||
| 121 | if t.ResolveSubagent == nil { | 122 | if t.ResolveSubagent == nil { | ||
| 122 | return protocolToolError("swarm_spawn: named subagent profiles are unavailable") | 123 | return protocolToolError("swarm_spawn: named subagent profiles are unavailable") | ||
| @@ -130,6 +131,7 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 130 | return protocolToolError("swarm_spawn: unknown subagent profile " + agentName) | 131 | return protocolToolError("swarm_spawn: unknown subagent profile " + agentName) | ||
| 131 | } | 132 | } | ||
| 132 | agentName = profile.Name | 133 | agentName = profile.Name | ||
| 134 | + | fastModeOverride = profile.FastMode | |||
| 133 | } | 135 | } | ||
| 134 | 136 | ||||
| 135 | model := strings.TrimSpace(a.Model) | 137 | model := strings.TrimSpace(a.Model) | ||
| @@ -178,6 +180,7 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 178 | Model: model, | 180 | Model: model, | ||
| 179 | Provider: providerID, | 181 | Provider: providerID, | ||
| 180 | Reasoning: reasoning, | 182 | Reasoning: reasoning, | ||
| 183 | + | FastMode: fastModeOverride, | |||
| 181 | Subagent: agentName, | 184 | Subagent: agentName, | ||
| 182 | }) | 185 | }) | ||
| 183 | if err != nil { | 186 | if err != nil { | ||
M packages/agent/tools/swarm_spawn_test.go +38 -0
| @@ -109,6 +109,44 @@ func TestSwarmSpawnSelectsProfileAndReasoning(t *testing.T) { | |||
| 109 | 109 | } | |
| 110 | 110 | } | |
| 111 | 111 | ||
| 112 | + | func TestSwarmSpawnAppliesProfileFastModeRestriction(t *testing.T) { | |
| 113 | + | root := t.TempDir() | |
| 114 | + | profileFastMode := false | |
| 115 | + | f := swarm.New(swarm.Config{ | |
| 116 | + | Root: filepath.Join(root, "swarm"), | |
| 117 | + | RepoRoot: root, | |
| 118 | + | FastMode: true, | |
| 119 | + | NewRunner: func(*swarm.Agent) swarm.Runner { | |
| 120 | + | return noopSwarmRunner{} | |
| 121 | + | }, | |
| 122 | + | }) | |
| 123 | + | tool := &SwarmSpawnTool{ | |
| 124 | + | Swarm: f, | |
| 125 | + | Enabled: func() bool { return true }, | |
| 126 | + | ResolveSubagent: func(name string) (*subagents.Profile, error) { | |
| 127 | + | if name != "reviewer" { | |
| 128 | + | return nil, nil | |
| 129 | + | } | |
| 130 | + | return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil | |
| 131 | + | }, | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer"}`), nil) | |
| 135 | + | if err != nil { | |
| 136 | + | t.Fatal(err) | |
| 137 | + | } | |
| 138 | + | if res.IsError { | |
| 139 | + | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |
| 140 | + | } | |
| 141 | + | agents := f.List() | |
| 142 | + | if len(agents) != 1 || agents[0].FastMode { | |
| 143 | + | t.Fatalf("agent fast mode = %#v, want disabled by profile", agents) | |
| 144 | + | } | |
| 145 | + | if got := res.Details.(map[string]any)["fast_mode"]; got != false { | |
| 146 | + | t.Fatalf("fast_mode detail = %v, want false", got) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 112 | 150 | func TestSwarmSpawnUsesProfileReasoningWhenOmitted(t *testing.T) { | |
| 113 | 151 | tool := &SwarmSpawnTool{ | |
| 114 | 152 | Swarm: newTestSwarm(t), | |
| @@ -109,6 +109,44 @@ func TestSwarmSpawnSelectsProfileAndReasoning(t *testing.T) { | |||||
| 109 | } | 109 | } | ||
| 110 | } | 110 | } | ||
| 111 | 111 | ||||
| 112 | + | func TestSwarmSpawnAppliesProfileFastModeRestriction(t *testing.T) { | |||
| 113 | + | root := t.TempDir() | |||
| 114 | + | profileFastMode := false | |||
| 115 | + | f := swarm.New(swarm.Config{ | |||
| 116 | + | Root: filepath.Join(root, "swarm"), | |||
| 117 | + | RepoRoot: root, | |||
| 118 | + | FastMode: true, | |||
| 119 | + | NewRunner: func(*swarm.Agent) swarm.Runner { | |||
| 120 | + | return noopSwarmRunner{} | |||
| 121 | + | }, | |||
| 122 | + | }) | |||
| 123 | + | tool := &SwarmSpawnTool{ | |||
| 124 | + | Swarm: f, | |||
| 125 | + | Enabled: func() bool { return true }, | |||
| 126 | + | ResolveSubagent: func(name string) (*subagents.Profile, error) { | |||
| 127 | + | if name != "reviewer" { | |||
| 128 | + | return nil, nil | |||
| 129 | + | } | |||
| 130 | + | return &subagents.Profile{Name: name, FastMode: &profileFastMode}, nil | |||
| 131 | + | }, | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"review auth","agent":"reviewer"}`), nil) | |||
| 135 | + | if err != nil { | |||
| 136 | + | t.Fatal(err) | |||
| 137 | + | } | |||
| 138 | + | if res.IsError { | |||
| 139 | + | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |||
| 140 | + | } | |||
| 141 | + | agents := f.List() | |||
| 142 | + | if len(agents) != 1 || agents[0].FastMode { | |||
| 143 | + | t.Fatalf("agent fast mode = %#v, want disabled by profile", agents) | |||
| 144 | + | } | |||
| 145 | + | if got := res.Details.(map[string]any)["fast_mode"]; got != false { | |||
| 146 | + | t.Fatalf("fast_mode detail = %v, want false", got) | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | ||||
| 112 | func TestSwarmSpawnUsesProfileReasoningWhenOmitted(t *testing.T) { | 150 | func TestSwarmSpawnUsesProfileReasoningWhenOmitted(t *testing.T) { | ||
| 113 | tool := &SwarmSpawnTool{ | 151 | tool := &SwarmSpawnTool{ | ||
| 114 | Swarm: newTestSwarm(t), | 152 | Swarm: newTestSwarm(t), | ||