fix(agent): preserve prompt ownership and profile validation
- commit
- 86d3d2d42b283bc0c5374f189c183076ba3d8d8a
- author
- brice · · 2026-08-04 04:54 UTC
- parent
- 2830302
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +110 -34
M packages/agent/build_test.go +2 -0
| @@ -91,6 +91,7 @@ func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) { | |||
| 91 | 91 | } | |
| 92 | 92 | t.Setenv("HOME", home) | |
| 93 | 93 | t.Setenv("ZOT_HOME", zotHome) | |
| 94 | + | t.Setenv("ZOT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents")) | |
| 94 | 95 | enabled := true | |
| 95 | 96 | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSwarmEnabled: &enabled}); err != nil { | |
| 96 | 97 | t.Fatal(err) | |
| @@ -130,6 +131,7 @@ func TestResolveAppliesSelectedSubagentProfile(t *testing.T) { | |||
| 130 | 131 | } | |
| 131 | 132 | t.Setenv("HOME", home) | |
| 132 | 133 | t.Setenv("ZOT_HOME", zotHome) | |
| 134 | + | t.Setenv("ZOT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents")) | |
| 133 | 135 | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", Reasoning: "high"}); err != nil { | |
| 134 | 136 | t.Fatal(err) | |
| 135 | 137 | } | |
| @@ -91,6 +91,7 @@ func TestResolveIncludesNamedSubagentsListWhenAutoSwarmIsEnabled(t *testing.T) { | |||||
| 91 | } | 91 | } | ||
| 92 | t.Setenv("HOME", home) | 92 | t.Setenv("HOME", home) | ||
| 93 | t.Setenv("ZOT_HOME", zotHome) | 93 | t.Setenv("ZOT_HOME", zotHome) | ||
| 94 | + | t.Setenv("ZOT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents")) | |||
| 94 | enabled := true | 95 | enabled := true | ||
| 95 | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSwarmEnabled: &enabled}); err != nil { | 96 | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", AutoSwarmEnabled: &enabled}); err != nil { | ||
| 96 | t.Fatal(err) | 97 | t.Fatal(err) | ||
| @@ -130,6 +131,7 @@ func TestResolveAppliesSelectedSubagentProfile(t *testing.T) { | |||||
| 130 | } | 131 | } | ||
| 131 | t.Setenv("HOME", home) | 132 | t.Setenv("HOME", home) | ||
| 132 | t.Setenv("ZOT_HOME", zotHome) | 133 | t.Setenv("ZOT_HOME", zotHome) | ||
| 134 | + | t.Setenv("ZOT_AGENT_PROFILES", filepath.Join(home, ".agents", "agents")) | |||
| 133 | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", Reasoning: "high"}); err != nil { | 135 | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5", Reasoning: "high"}); err != nil { | ||
| 134 | t.Fatal(err) | 136 | t.Fatal(err) | ||
| 135 | } | 137 | } | ||
M packages/agent/cli.go +2 -2
| @@ -1153,7 +1153,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1153 | 1153 | newAg, newProvider, newModel, berr := buildAgent() | |
| 1154 | 1154 | if berr != nil { | |
| 1155 | 1155 | rollback() | |
| 1156 | - | return fmt.Errorf("rebuild agent: %v", berr) | |
| 1156 | + | return fmt.Errorf("rebuild agent: %w", berr) | |
| 1157 | 1157 | } | |
| 1158 | 1158 | ||
| 1159 | 1159 | // Fresh session in the new cwd's bucket. We bypass | |
| @@ -1168,7 +1168,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1168 | 1168 | newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version) | |
| 1169 | 1169 | if serr != nil { | |
| 1170 | 1170 | rollback() | |
| 1171 | - | return fmt.Errorf("open session in %s: %v", absPath, serr) | |
| 1171 | + | return fmt.Errorf("open session in %s: %w", absPath, serr) | |
| 1172 | 1172 | } | |
| 1173 | 1173 | } | |
| 1174 | 1174 | ||
| @@ -1153,7 +1153,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1153 | newAg, newProvider, newModel, berr := buildAgent() | 1153 | newAg, newProvider, newModel, berr := buildAgent() | ||
| 1154 | if berr != nil { | 1154 | if berr != nil { | ||
| 1155 | rollback() | 1155 | rollback() | ||
| 1156 | - | return fmt.Errorf("rebuild agent: %v", berr) | 1156 | + | return fmt.Errorf("rebuild agent: %w", berr) |
| 1157 | } | 1157 | } | ||
| 1158 | 1158 | ||||
| 1159 | // Fresh session in the new cwd's bucket. We bypass | 1159 | // Fresh session in the new cwd's bucket. We bypass | ||
| @@ -1168,7 +1168,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1168 | newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version) | 1168 | newSess, serr = core.NewSession(newRoot, absPath, newProvider, newModel, version) | ||
| 1169 | if serr != nil { | 1169 | if serr != nil { | ||
| 1170 | rollback() | 1170 | rollback() | ||
| 1171 | - | return fmt.Errorf("open session in %s: %v", absPath, serr) | 1171 | + | return fmt.Errorf("open session in %s: %w", absPath, serr) |
| 1172 | } | 1172 | } | ||
| 1173 | } | 1173 | } | ||
| 1174 | 1174 | ||||
M packages/agent/modes/auto_swarm_test.go +22 -0
| @@ -36,6 +36,28 @@ func TestAutoSwarmSystemPromptTogglesProfileManifestWithDelegationGuidance(t *te | |||
| 36 | 36 | } | |
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | + | func TestAutoSwarmTogglePreservesMatchingBasePromptBlocks(t *testing.T) { | |
| 40 | + | manifest := "[subagents_list]\n- reviewer\n[/subagents_list]" | |
| 41 | + | guidance := "auto-swarm guidance" | |
| 42 | + | base := "base system\n\n" + manifest + "\n\n" + guidance | |
| 43 | + | iv := &Interactive{ | |
| 44 | + | agent: &core.Agent{System: base}, | |
| 45 | + | cfg: InteractiveConfig{ | |
| 46 | + | AutoSwarmSystemAddendum: guidance, | |
| 47 | + | SubagentsSystemAddendum: manifest, | |
| 48 | + | }, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | iv.applyAutoSwarmSystemPrompt(true) | |
| 52 | + | if strings.Count(iv.agent.System, manifest) != 2 || strings.Count(iv.agent.System, guidance) != 2 { | |
| 53 | + | t.Fatalf("enabling should append owned duplicate blocks: %q", iv.agent.System) | |
| 54 | + | } | |
| 55 | + | iv.applyAutoSwarmSystemPrompt(false) | |
| 56 | + | if got := strings.TrimSpace(iv.agent.System); got != base { | |
| 57 | + | t.Fatalf("disabling changed matching base prompt: %q; want %q", got, base) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 39 | 61 | func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) { | |
| 40 | 62 | response := "first response line\n" + strings.Repeat("result ", 150) + "final marker" | |
| 41 | 63 | mgr := swarm.New(swarm.Config{ | |
| @@ -36,6 +36,28 @@ func TestAutoSwarmSystemPromptTogglesProfileManifestWithDelegationGuidance(t *te | |||||
| 36 | } | 36 | } | ||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | + | func TestAutoSwarmTogglePreservesMatchingBasePromptBlocks(t *testing.T) { | |||
| 40 | + | manifest := "[subagents_list]\n- reviewer\n[/subagents_list]" | |||
| 41 | + | guidance := "auto-swarm guidance" | |||
| 42 | + | base := "base system\n\n" + manifest + "\n\n" + guidance | |||
| 43 | + | iv := &Interactive{ | |||
| 44 | + | agent: &core.Agent{System: base}, | |||
| 45 | + | cfg: InteractiveConfig{ | |||
| 46 | + | AutoSwarmSystemAddendum: guidance, | |||
| 47 | + | SubagentsSystemAddendum: manifest, | |||
| 48 | + | }, | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | iv.applyAutoSwarmSystemPrompt(true) | |||
| 52 | + | if strings.Count(iv.agent.System, manifest) != 2 || strings.Count(iv.agent.System, guidance) != 2 { | |||
| 53 | + | t.Fatalf("enabling should append owned duplicate blocks: %q", iv.agent.System) | |||
| 54 | + | } | |||
| 55 | + | iv.applyAutoSwarmSystemPrompt(false) | |||
| 56 | + | if got := strings.TrimSpace(iv.agent.System); got != base { | |||
| 57 | + | t.Fatalf("disabling changed matching base prompt: %q; want %q", got, base) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 39 | func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) { | 61 | func TestAutoSwarmSummaryIncludesCompleteFinalResponseAfterLongTask(t *testing.T) { | ||
| 40 | response := "first response line\n" + strings.Repeat("result ", 150) + "final marker" | 62 | response := "first response line\n" + strings.Repeat("result ", 150) + "final marker" | ||
| 41 | mgr := swarm.New(swarm.Config{ | 63 | mgr := swarm.New(swarm.Config{ | ||
M packages/agent/modes/interactive.go +59 -22
| @@ -368,10 +368,14 @@ type Interactive struct { | |||
| 368 | 368 | ed *tui.Editor | |
| 369 | 369 | rend *tui.Renderer | |
| 370 | 370 | ||
| 371 | - | mu sync.Mutex | |
| 372 | - | agent *core.Agent | |
| 373 | - | streaming strings.Builder // what's currently painted on screen | |
| 374 | - | streamOn bool | |
| 371 | + | mu sync.Mutex | |
| 372 | + | agent *core.Agent | |
| 373 | + | // managedAutoSwarmAddenda records exact prompt blocks appended by | |
| 374 | + | // auto-swarm. Disable only removes these owned occurrences, leaving | |
| 375 | + | // identical text that came from the user's base prompt untouched. | |
| 376 | + | managedAutoSwarmAddenda []string | |
| 377 | + | streaming strings.Builder // what's currently painted on screen | |
| 378 | + | streamOn bool | |
| 375 | 379 | ||
| 376 | 380 | // streamPending is the runes buffered after each EvTextDelta that | |
| 377 | 381 | // haven't yet been promoted into `streaming` for rendering. It | |
| @@ -666,6 +670,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 666 | 670 | baseURL, _, err := cfg.LlamaCPPConfig() | |
| 667 | 671 | i.llamaConfigured = err == nil && baseURL != "" | |
| 668 | 672 | } | |
| 673 | + | if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled { | |
| 674 | + | i.managedAutoSwarmAddenda = autoSwarmAddenda(cfg) | |
| 675 | + | } | |
| 669 | 676 | if cfg.Agent != nil { | |
| 670 | 677 | i.agent = cfg.Agent | |
| 671 | 678 | i.view.Messages = cfg.Agent.Messages() | |
| @@ -3070,6 +3077,11 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||
| 3070 | 3077 | i.agent = ag | |
| 3071 | 3078 | i.cfg.CWD = cwd | |
| 3072 | 3079 | i.cfg.SubagentsSystemAddendum = subagentsAddendum | |
| 3080 | + | if i.autoSwarmEnabled() { | |
| 3081 | + | i.managedAutoSwarmAddenda = autoSwarmAddenda(i.cfg) | |
| 3082 | + | } else { | |
| 3083 | + | i.managedAutoSwarmAddenda = nil | |
| 3084 | + | } | |
| 3073 | 3085 | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | |
| 3074 | 3086 | i.view.StartupContextPaths = nil | |
| 3075 | 3087 | if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup { | |
| @@ -6501,6 +6513,39 @@ func truncateForSummary(s string, n int) string { | |||
| 6501 | 6513 | return s[:n-3] + "..." | |
| 6502 | 6514 | } | |
| 6503 | 6515 | ||
| 6516 | + | // autoSwarmAddenda returns the prompt blocks owned by the auto-swarm | |
| 6517 | + | // toggle, in the same order Resolve appends them to a new agent. | |
| 6518 | + | func autoSwarmAddenda(cfg InteractiveConfig) []string { | |
| 6519 | + | addenda := make([]string, 0, 2) | |
| 6520 | + | if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" { | |
| 6521 | + | addenda = append(addenda, addendum) | |
| 6522 | + | } | |
| 6523 | + | if addendum := strings.TrimSpace(cfg.AutoSwarmSystemAddendum); addendum != "" { | |
| 6524 | + | addenda = append(addenda, addendum) | |
| 6525 | + | } | |
| 6526 | + | return addenda | |
| 6527 | + | } | |
| 6528 | + | ||
| 6529 | + | func containsAutoSwarmAddendum(addenda []string, want string) bool { | |
| 6530 | + | for _, addendum := range addenda { | |
| 6531 | + | if addendum == want { | |
| 6532 | + | return true | |
| 6533 | + | } | |
| 6534 | + | } | |
| 6535 | + | return false | |
| 6536 | + | } | |
| 6537 | + | ||
| 6538 | + | // removeLastAutoSwarmAddendum removes one occurrence of a block known to | |
| 6539 | + | // have been appended by auto-swarm. Resolve appends owned blocks at the | |
| 6540 | + | // end, so removing the last occurrence preserves an identical base block. | |
| 6541 | + | func removeLastAutoSwarmAddendum(system, addendum string) (string, bool) { | |
| 6542 | + | idx := strings.LastIndex(system, addendum) | |
| 6543 | + | if idx < 0 { | |
| 6544 | + | return system, false | |
| 6545 | + | } | |
| 6546 | + | return system[:idx] + system[idx+len(addendum):], true | |
| 6547 | + | } | |
| 6548 | + | ||
| 6504 | 6549 | // applyAutoSwarmSystemPrompt appends (active=true) or strips | |
| 6505 | 6550 | // (active=false) the auto-swarm prompt blocks on the running agent. | |
| 6506 | 6551 | // The profile manifest and delegation guidance are managed together so | |
| @@ -6509,39 +6554,31 @@ func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) { | |||
| 6509 | 6554 | if i.agent == nil { | |
| 6510 | 6555 | return | |
| 6511 | 6556 | } | |
| 6512 | - | addenda := make([]string, 0, 2) | |
| 6513 | - | if addendum := strings.TrimSpace(i.cfg.SubagentsSystemAddendum); addendum != "" { | |
| 6514 | - | addenda = append(addenda, addendum) | |
| 6515 | - | } | |
| 6516 | - | if addendum := strings.TrimSpace(i.cfg.AutoSwarmSystemAddendum); addendum != "" { | |
| 6517 | - | addenda = append(addenda, addendum) | |
| 6518 | - | } | |
| 6519 | - | if len(addenda) == 0 { | |
| 6520 | - | return | |
| 6521 | - | } | |
| 6522 | - | ||
| 6523 | - | sys := i.agent.System | |
| 6557 | + | addenda := autoSwarmAddenda(i.cfg) | |
| 6524 | 6558 | if active { | |
| 6559 | + | sys := i.agent.System | |
| 6525 | 6560 | for _, addendum := range addenda { | |
| 6526 | - | if strings.Contains(sys, addendum) { | |
| 6561 | + | if containsAutoSwarmAddendum(i.managedAutoSwarmAddenda, addendum) { | |
| 6527 | 6562 | continue | |
| 6528 | 6563 | } | |
| 6529 | 6564 | if sys != "" && !strings.HasSuffix(sys, "\n\n") { | |
| 6530 | 6565 | sys += "\n\n" | |
| 6531 | 6566 | } | |
| 6532 | 6567 | sys += addendum | |
| 6568 | + | i.managedAutoSwarmAddenda = append(i.managedAutoSwarmAddenda, addendum) | |
| 6533 | 6569 | } | |
| 6534 | 6570 | i.agent.System = sys | |
| 6535 | 6571 | return | |
| 6536 | 6572 | } | |
| 6537 | 6573 | ||
| 6574 | + | sys := i.agent.System | |
| 6538 | 6575 | changed := false | |
| 6539 | - | for _, addendum := range addenda { | |
| 6540 | - | if strings.Contains(sys, addendum) { | |
| 6541 | - | changed = true | |
| 6542 | - | sys = strings.ReplaceAll(sys, addendum, "") | |
| 6543 | - | } | |
| 6576 | + | for idx := len(i.managedAutoSwarmAddenda) - 1; idx >= 0; idx-- { | |
| 6577 | + | var removed bool | |
| 6578 | + | sys, removed = removeLastAutoSwarmAddendum(sys, i.managedAutoSwarmAddenda[idx]) | |
| 6579 | + | changed = changed || removed | |
| 6544 | 6580 | } | |
| 6581 | + | i.managedAutoSwarmAddenda = nil | |
| 6545 | 6582 | if changed { | |
| 6546 | 6583 | i.agent.System = strings.TrimRight(sys, "\n") + "\n" | |
| 6547 | 6584 | } | |
| @@ -368,10 +368,14 @@ type Interactive struct { | |||||
| 368 | ed *tui.Editor | 368 | ed *tui.Editor | ||
| 369 | rend *tui.Renderer | 369 | rend *tui.Renderer | ||
| 370 | 370 | ||||
| 371 | - | mu sync.Mutex | 371 | + | mu sync.Mutex |
| 372 | - | agent *core.Agent | 372 | + | agent *core.Agent |
| 373 | - | streaming strings.Builder // what's currently painted on screen | 373 | + | // managedAutoSwarmAddenda records exact prompt blocks appended by |
| 374 | - | streamOn bool | 374 | + | // auto-swarm. Disable only removes these owned occurrences, leaving |
| 375 | + | // identical text that came from the user's base prompt untouched. | |||
| 376 | + | managedAutoSwarmAddenda []string | |||
| 377 | + | streaming strings.Builder // what's currently painted on screen | |||
| 378 | + | streamOn bool | |||
| 375 | 379 | ||||
| 376 | // streamPending is the runes buffered after each EvTextDelta that | 380 | // streamPending is the runes buffered after each EvTextDelta that | ||
| 377 | // haven't yet been promoted into `streaming` for rendering. It | 381 | // haven't yet been promoted into `streaming` for rendering. It | ||
| @@ -666,6 +670,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 666 | baseURL, _, err := cfg.LlamaCPPConfig() | 670 | baseURL, _, err := cfg.LlamaCPPConfig() | ||
| 667 | i.llamaConfigured = err == nil && baseURL != "" | 671 | i.llamaConfigured = err == nil && baseURL != "" | ||
| 668 | } | 672 | } | ||
| 673 | + | if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled { | |||
| 674 | + | i.managedAutoSwarmAddenda = autoSwarmAddenda(cfg) | |||
| 675 | + | } | |||
| 669 | if cfg.Agent != nil { | 676 | if cfg.Agent != nil { | ||
| 670 | i.agent = cfg.Agent | 677 | i.agent = cfg.Agent | ||
| 671 | i.view.Messages = cfg.Agent.Messages() | 678 | i.view.Messages = cfg.Agent.Messages() | ||
| @@ -3070,6 +3077,11 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||||
| 3070 | i.agent = ag | 3077 | i.agent = ag | ||
| 3071 | i.cfg.CWD = cwd | 3078 | i.cfg.CWD = cwd | ||
| 3072 | i.cfg.SubagentsSystemAddendum = subagentsAddendum | 3079 | i.cfg.SubagentsSystemAddendum = subagentsAddendum | ||
| 3080 | + | if i.autoSwarmEnabled() { | |||
| 3081 | + | i.managedAutoSwarmAddenda = autoSwarmAddenda(i.cfg) | |||
| 3082 | + | } else { | |||
| 3083 | + | i.managedAutoSwarmAddenda = nil | |||
| 3084 | + | } | |||
| 3073 | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | 3085 | i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...) | ||
| 3074 | i.view.StartupContextPaths = nil | 3086 | i.view.StartupContextPaths = nil | ||
| 3075 | if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup { | 3087 | if i.cfg.ShowInstructionsAtStartup != nil && *i.cfg.ShowInstructionsAtStartup { | ||
| @@ -6501,6 +6513,39 @@ func truncateForSummary(s string, n int) string { | |||||
| 6501 | return s[:n-3] + "..." | 6513 | return s[:n-3] + "..." | ||
| 6502 | } | 6514 | } | ||
| 6503 | 6515 | ||||
| 6516 | + | // autoSwarmAddenda returns the prompt blocks owned by the auto-swarm | |||
| 6517 | + | // toggle, in the same order Resolve appends them to a new agent. | |||
| 6518 | + | func autoSwarmAddenda(cfg InteractiveConfig) []string { | |||
| 6519 | + | addenda := make([]string, 0, 2) | |||
| 6520 | + | if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" { | |||
| 6521 | + | addenda = append(addenda, addendum) | |||
| 6522 | + | } | |||
| 6523 | + | if addendum := strings.TrimSpace(cfg.AutoSwarmSystemAddendum); addendum != "" { | |||
| 6524 | + | addenda = append(addenda, addendum) | |||
| 6525 | + | } | |||
| 6526 | + | return addenda | |||
| 6527 | + | } | |||
| 6528 | + | ||||
| 6529 | + | func containsAutoSwarmAddendum(addenda []string, want string) bool { | |||
| 6530 | + | for _, addendum := range addenda { | |||
| 6531 | + | if addendum == want { | |||
| 6532 | + | return true | |||
| 6533 | + | } | |||
| 6534 | + | } | |||
| 6535 | + | return false | |||
| 6536 | + | } | |||
| 6537 | + | ||||
| 6538 | + | // removeLastAutoSwarmAddendum removes one occurrence of a block known to | |||
| 6539 | + | // have been appended by auto-swarm. Resolve appends owned blocks at the | |||
| 6540 | + | // end, so removing the last occurrence preserves an identical base block. | |||
| 6541 | + | func removeLastAutoSwarmAddendum(system, addendum string) (string, bool) { | |||
| 6542 | + | idx := strings.LastIndex(system, addendum) | |||
| 6543 | + | if idx < 0 { | |||
| 6544 | + | return system, false | |||
| 6545 | + | } | |||
| 6546 | + | return system[:idx] + system[idx+len(addendum):], true | |||
| 6547 | + | } | |||
| 6548 | + | ||||
| 6504 | // applyAutoSwarmSystemPrompt appends (active=true) or strips | 6549 | // applyAutoSwarmSystemPrompt appends (active=true) or strips | ||
| 6505 | // (active=false) the auto-swarm prompt blocks on the running agent. | 6550 | // (active=false) the auto-swarm prompt blocks on the running agent. | ||
| 6506 | // The profile manifest and delegation guidance are managed together so | 6551 | // The profile manifest and delegation guidance are managed together so | ||
| @@ -6509,39 +6554,31 @@ func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) { | |||||
| 6509 | if i.agent == nil { | 6554 | if i.agent == nil { | ||
| 6510 | return | 6555 | return | ||
| 6511 | } | 6556 | } | ||
| 6512 | - | addenda := make([]string, 0, 2) | 6557 | + | addenda := autoSwarmAddenda(i.cfg) |
| 6513 | - | if addendum := strings.TrimSpace(i.cfg.SubagentsSystemAddendum); addendum != "" { | |||
| 6514 | - | addenda = append(addenda, addendum) | |||
| 6515 | - | } | |||
| 6516 | - | if addendum := strings.TrimSpace(i.cfg.AutoSwarmSystemAddendum); addendum != "" { | |||
| 6517 | - | addenda = append(addenda, addendum) | |||
| 6518 | - | } | |||
| 6519 | - | if len(addenda) == 0 { | |||
| 6520 | - | return | |||
| 6521 | - | } | |||
| 6522 | - | ||||
| 6523 | - | sys := i.agent.System | |||
| 6524 | if active { | 6558 | if active { | ||
| 6559 | + | sys := i.agent.System | |||
| 6525 | for _, addendum := range addenda { | 6560 | for _, addendum := range addenda { | ||
| 6526 | - | if strings.Contains(sys, addendum) { | 6561 | + | if containsAutoSwarmAddendum(i.managedAutoSwarmAddenda, addendum) { |
| 6527 | continue | 6562 | continue | ||
| 6528 | } | 6563 | } | ||
| 6529 | if sys != "" && !strings.HasSuffix(sys, "\n\n") { | 6564 | if sys != "" && !strings.HasSuffix(sys, "\n\n") { | ||
| 6530 | sys += "\n\n" | 6565 | sys += "\n\n" | ||
| 6531 | } | 6566 | } | ||
| 6532 | sys += addendum | 6567 | sys += addendum | ||
| 6568 | + | i.managedAutoSwarmAddenda = append(i.managedAutoSwarmAddenda, addendum) | |||
| 6533 | } | 6569 | } | ||
| 6534 | i.agent.System = sys | 6570 | i.agent.System = sys | ||
| 6535 | return | 6571 | return | ||
| 6536 | } | 6572 | } | ||
| 6537 | 6573 | ||||
| 6574 | + | sys := i.agent.System | |||
| 6538 | changed := false | 6575 | changed := false | ||
| 6539 | - | for _, addendum := range addenda { | 6576 | + | for idx := len(i.managedAutoSwarmAddenda) - 1; idx >= 0; idx-- { |
| 6540 | - | if strings.Contains(sys, addendum) { | 6577 | + | var removed bool |
| 6541 | - | changed = true | 6578 | + | sys, removed = removeLastAutoSwarmAddendum(sys, i.managedAutoSwarmAddenda[idx]) |
| 6542 | - | sys = strings.ReplaceAll(sys, addendum, "") | 6579 | + | changed = changed || removed |
| 6543 | - | } | |||
| 6544 | } | 6580 | } | ||
| 6581 | + | i.managedAutoSwarmAddenda = nil | |||
| 6545 | if changed { | 6582 | if changed { | ||
| 6546 | i.agent.System = strings.TrimRight(sys, "\n") + "\n" | 6583 | i.agent.System = strings.TrimRight(sys, "\n") + "\n" | ||
| 6547 | } | 6584 | } | ||
M packages/agent/modes/swarm_slash.go +4 -4
| @@ -276,7 +276,7 @@ func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task strin | |||
| 276 | 276 | f := fields[i] | |
| 277 | 277 | switch { | |
| 278 | 278 | case f == "--agent": | |
| 279 | - | if i+1 < len(fields) { | |
| 279 | + | if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") { | |
| 280 | 280 | subagent = fields[i+1] | |
| 281 | 281 | i += 2 | |
| 282 | 282 | } else { | |
| @@ -291,7 +291,7 @@ func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task strin | |||
| 291 | 291 | // Consume the flag even when no value follows so a | |
| 292 | 292 | // dangling "--model" doesn't leak into the task. The | |
| 293 | 293 | // caller surfaces "missing task" instead. | |
| 294 | - | if i+1 < len(fields) { | |
| 294 | + | if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") { | |
| 295 | 295 | model = fields[i+1] | |
| 296 | 296 | i += 2 | |
| 297 | 297 | } else { | |
| @@ -303,7 +303,7 @@ func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task strin | |||
| 303 | 303 | i++ | |
| 304 | 304 | continue | |
| 305 | 305 | case f == "--provider": | |
| 306 | - | if i+1 < len(fields) { | |
| 306 | + | if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") { | |
| 307 | 307 | provider = fields[i+1] | |
| 308 | 308 | i += 2 | |
| 309 | 309 | } else { | |
| @@ -315,7 +315,7 @@ func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task strin | |||
| 315 | 315 | i++ | |
| 316 | 316 | continue | |
| 317 | 317 | case f == "--reasoning" || f == "--thinking": | |
| 318 | - | if i+1 < len(fields) { | |
| 318 | + | if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") { | |
| 319 | 319 | reasoning = fields[i+1] | |
| 320 | 320 | i += 2 | |
| 321 | 321 | } else { | |
| @@ -276,7 +276,7 @@ func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task strin | |||||
| 276 | f := fields[i] | 276 | f := fields[i] | ||
| 277 | switch { | 277 | switch { | ||
| 278 | case f == "--agent": | 278 | case f == "--agent": | ||
| 279 | - | if i+1 < len(fields) { | 279 | + | if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") { |
| 280 | subagent = fields[i+1] | 280 | subagent = fields[i+1] | ||
| 281 | i += 2 | 281 | i += 2 | ||
| 282 | } else { | 282 | } else { | ||
| @@ -291,7 +291,7 @@ func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task strin | |||||
| 291 | // Consume the flag even when no value follows so a | 291 | // Consume the flag even when no value follows so a | ||
| 292 | // dangling "--model" doesn't leak into the task. The | 292 | // dangling "--model" doesn't leak into the task. The | ||
| 293 | // caller surfaces "missing task" instead. | 293 | // caller surfaces "missing task" instead. | ||
| 294 | - | if i+1 < len(fields) { | 294 | + | if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") { |
| 295 | model = fields[i+1] | 295 | model = fields[i+1] | ||
| 296 | i += 2 | 296 | i += 2 | ||
| 297 | } else { | 297 | } else { | ||
| @@ -303,7 +303,7 @@ func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task strin | |||||
| 303 | i++ | 303 | i++ | ||
| 304 | continue | 304 | continue | ||
| 305 | case f == "--provider": | 305 | case f == "--provider": | ||
| 306 | - | if i+1 < len(fields) { | 306 | + | if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") { |
| 307 | provider = fields[i+1] | 307 | provider = fields[i+1] | ||
| 308 | i += 2 | 308 | i += 2 | ||
| 309 | } else { | 309 | } else { | ||
| @@ -315,7 +315,7 @@ func parseSpawnFlags(s string) (model, provider, reasoning, subagent, task strin | |||||
| 315 | i++ | 315 | i++ | ||
| 316 | continue | 316 | continue | ||
| 317 | case f == "--reasoning" || f == "--thinking": | 317 | case f == "--reasoning" || f == "--thinking": | ||
| 318 | - | if i+1 < len(fields) { | 318 | + | if i+1 < len(fields) && !strings.HasPrefix(fields[i+1], "--") { |
| 319 | reasoning = fields[i+1] | 319 | reasoning = fields[i+1] | ||
| 320 | i += 2 | 320 | i += 2 | ||
| 321 | } else { | 321 | } else { | ||
M packages/agent/modes/swarm_slash_test.go +2 -0
| @@ -213,6 +213,8 @@ func TestParseSpawnFlags(t *testing.T) { | |||
| 213 | 213 | {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "", "", "do x"}, | |
| 214 | 214 | {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "", "", "do x"}, | |
| 215 | 215 | {"--agent reviewer --reasoning high review auth", "", "", "high", "reviewer", "review auth"}, | |
| 216 | + | {"--agent --reasoning high review auth", "", "", "high", "", "review auth"}, | |
| 217 | + | {"--reasoning --agent reviewer review auth", "", "", "", "reviewer", "review auth"}, | |
| 216 | 218 | {"--thinking=max do x", "", "", "max", "", "do x"}, | |
| 217 | 219 | // Only LEADING flags are consumed. | |
| 218 | 220 | {"do --model x", "", "", "", "", "do --model x"}, | |
| @@ -213,6 +213,8 @@ func TestParseSpawnFlags(t *testing.T) { | |||||
| 213 | {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "", "", "do x"}, | 213 | {"--provider openai --model gpt-5 do x", "gpt-5", "openai", "", "", "do x"}, | ||
| 214 | {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "", "", "do x"}, | 214 | {"--provider=openai --model=gpt-5 do x", "gpt-5", "openai", "", "", "do x"}, | ||
| 215 | {"--agent reviewer --reasoning high review auth", "", "", "high", "reviewer", "review auth"}, | 215 | {"--agent reviewer --reasoning high review auth", "", "", "high", "reviewer", "review auth"}, | ||
| 216 | + | {"--agent --reasoning high review auth", "", "", "high", "", "review auth"}, | |||
| 217 | + | {"--reasoning --agent reviewer review auth", "", "", "", "reviewer", "review auth"}, | |||
| 216 | {"--thinking=max do x", "", "", "max", "", "do x"}, | 218 | {"--thinking=max do x", "", "", "max", "", "do x"}, | ||
| 217 | // Only LEADING flags are consumed. | 219 | // Only LEADING flags are consumed. | ||
| 218 | {"do --model x", "", "", "", "", "do --model x"}, | 220 | {"do --model x", "", "", "", "", "do --model x"}, | ||
M packages/agent/subagents/profiles.go +9 -6
| @@ -198,7 +198,10 @@ func load(path, source string) (*Profile, error) { | |||
| 198 | 198 | if err != nil { | |
| 199 | 199 | return nil, err | |
| 200 | 200 | } | |
| 201 | - | front, body := splitFrontmatter(string(raw)) | |
| 201 | + | front, body, err := splitFrontmatter(string(raw)) | |
| 202 | + | if err != nil { | |
| 203 | + | return nil, err | |
| 204 | + | } | |
| 202 | 205 | values, lists := parseFrontmatter(front) | |
| 203 | 206 | name := unquote(values["name"]) | |
| 204 | 207 | if name == "" { | |
| @@ -253,11 +256,11 @@ func load(path, source string) (*Profile, error) { | |||
| 253 | 256 | return profile, nil | |
| 254 | 257 | } | |
| 255 | 258 | ||
| 256 | - | func splitFrontmatter(raw string) (front, body string) { | |
| 259 | + | func splitFrontmatter(raw string) (front, body string, err error) { | |
| 257 | 260 | rest := strings.TrimLeft(raw, " \t\r\n") | |
| 258 | 261 | firstEnd := strings.IndexByte(rest, '\n') | |
| 259 | 262 | if firstEnd < 0 || strings.TrimSpace(rest[:firstEnd]) != "---" { | |
| 260 | - | return "", raw | |
| 263 | + | return "", raw, nil | |
| 261 | 264 | } | |
| 262 | 265 | rest = rest[firstEnd+1:] | |
| 263 | 266 | for offset := 0; offset <= len(rest); { | |
| @@ -265,16 +268,16 @@ func splitFrontmatter(raw string) (front, body string) { | |||
| 265 | 268 | if rel < 0 { | |
| 266 | 269 | // A document that starts a frontmatter block but never closes | |
| 267 | 270 | // it is malformed, not a body-only profile. | |
| 268 | - | return "", "" | |
| 271 | + | return "", "", fmt.Errorf("frontmatter: missing closing delimiter") | |
| 269 | 272 | } | |
| 270 | 273 | end := offset + rel | |
| 271 | 274 | after := end + len("\n---") | |
| 272 | 275 | if after == len(rest) || rest[after] == '\n' || rest[after] == '\r' { | |
| 273 | - | return rest[:end], strings.TrimLeft(rest[after:], " \t\r\n") | |
| 276 | + | return rest[:end], strings.TrimLeft(rest[after:], " \t\r\n"), nil | |
| 274 | 277 | } | |
| 275 | 278 | offset = after | |
| 276 | 279 | } | |
| 277 | - | return "", raw | |
| 280 | + | return "", raw, nil | |
| 278 | 281 | } | |
| 279 | 282 | ||
| 280 | 283 | func parseFrontmatter(front string) (map[string]string, map[string][]string) { | |
| @@ -198,7 +198,10 @@ func load(path, source string) (*Profile, error) { | |||||
| 198 | if err != nil { | 198 | if err != nil { | ||
| 199 | return nil, err | 199 | return nil, err | ||
| 200 | } | 200 | } | ||
| 201 | - | front, body := splitFrontmatter(string(raw)) | 201 | + | front, body, err := splitFrontmatter(string(raw)) |
| 202 | + | if err != nil { | |||
| 203 | + | return nil, err | |||
| 204 | + | } | |||
| 202 | values, lists := parseFrontmatter(front) | 205 | values, lists := parseFrontmatter(front) | ||
| 203 | name := unquote(values["name"]) | 206 | name := unquote(values["name"]) | ||
| 204 | if name == "" { | 207 | if name == "" { | ||
| @@ -253,11 +256,11 @@ func load(path, source string) (*Profile, error) { | |||||
| 253 | return profile, nil | 256 | return profile, nil | ||
| 254 | } | 257 | } | ||
| 255 | 258 | ||||
| 256 | - | func splitFrontmatter(raw string) (front, body string) { | 259 | + | func splitFrontmatter(raw string) (front, body string, err error) { |
| 257 | rest := strings.TrimLeft(raw, " \t\r\n") | 260 | rest := strings.TrimLeft(raw, " \t\r\n") | ||
| 258 | firstEnd := strings.IndexByte(rest, '\n') | 261 | firstEnd := strings.IndexByte(rest, '\n') | ||
| 259 | if firstEnd < 0 || strings.TrimSpace(rest[:firstEnd]) != "---" { | 262 | if firstEnd < 0 || strings.TrimSpace(rest[:firstEnd]) != "---" { | ||
| 260 | - | return "", raw | 263 | + | return "", raw, nil |
| 261 | } | 264 | } | ||
| 262 | rest = rest[firstEnd+1:] | 265 | rest = rest[firstEnd+1:] | ||
| 263 | for offset := 0; offset <= len(rest); { | 266 | for offset := 0; offset <= len(rest); { | ||
| @@ -265,16 +268,16 @@ func splitFrontmatter(raw string) (front, body string) { | |||||
| 265 | if rel < 0 { | 268 | if rel < 0 { | ||
| 266 | // A document that starts a frontmatter block but never closes | 269 | // A document that starts a frontmatter block but never closes | ||
| 267 | // it is malformed, not a body-only profile. | 270 | // it is malformed, not a body-only profile. | ||
| 268 | - | return "", "" | 271 | + | return "", "", fmt.Errorf("frontmatter: missing closing delimiter") |
| 269 | } | 272 | } | ||
| 270 | end := offset + rel | 273 | end := offset + rel | ||
| 271 | after := end + len("\n---") | 274 | after := end + len("\n---") | ||
| 272 | if after == len(rest) || rest[after] == '\n' || rest[after] == '\r' { | 275 | if after == len(rest) || rest[after] == '\n' || rest[after] == '\r' { | ||
| 273 | - | return rest[:end], strings.TrimLeft(rest[after:], " \t\r\n") | 276 | + | return rest[:end], strings.TrimLeft(rest[after:], " \t\r\n"), nil |
| 274 | } | 277 | } | ||
| 275 | offset = after | 278 | offset = after | ||
| 276 | } | 279 | } | ||
| 277 | - | return "", raw | 280 | + | return "", raw, nil |
| 278 | } | 281 | } | ||
| 279 | 282 | ||||
| 280 | func parseFrontmatter(front string) (map[string]string, map[string][]string) { | 283 | func parseFrontmatter(front string) (map[string]string, map[string][]string) { | ||
M packages/agent/subagents/profiles_test.go +10 -0
| @@ -100,6 +100,16 @@ No. | |||
| 100 | 100 | } | |
| 101 | 101 | } | |
| 102 | 102 | ||
| 103 | + | func TestLoadRejectsUnclosedFrontmatter(t *testing.T) { | |
| 104 | + | path := filepath.Join(t.TempDir(), "profile.md") | |
| 105 | + | if err := os.WriteFile(path, []byte("---\nname: reviewer\nInstructions without a closing delimiter.\n"), 0o600); err != nil { | |
| 106 | + | t.Fatal(err) | |
| 107 | + | } | |
| 108 | + | if _, err := load(path, "test"); err == nil || !strings.Contains(err.Error(), "missing closing delimiter") { | |
| 109 | + | t.Fatalf("load error = %v, want missing closing delimiter", err) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 103 | 113 | func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) { | |
| 104 | 114 | cases := []struct { | |
| 105 | 115 | name string | |
| @@ -100,6 +100,16 @@ No. | |||||
| 100 | } | 100 | } | ||
| 101 | } | 101 | } | ||
| 102 | 102 | ||||
| 103 | + | func TestLoadRejectsUnclosedFrontmatter(t *testing.T) { | |||
| 104 | + | path := filepath.Join(t.TempDir(), "profile.md") | |||
| 105 | + | if err := os.WriteFile(path, []byte("---\nname: reviewer\nInstructions without a closing delimiter.\n"), 0o600); err != nil { | |||
| 106 | + | t.Fatal(err) | |||
| 107 | + | } | |||
| 108 | + | if _, err := load(path, "test"); err == nil || !strings.Contains(err.Error(), "missing closing delimiter") { | |||
| 109 | + | t.Fatalf("load error = %v, want missing closing delimiter", err) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 103 | func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) { | 113 | func TestLoadRejectsInvalidClosedSchemaMetadata(t *testing.T) { | ||
| 104 | cases := []struct { | 114 | cases := []struct { | ||
| 105 | name string | 115 | name string | ||