fix(swarm): inherit host model provider for auto-spawn
- commit
- 4bec50ae9c11ce1f5a7b38a1defd757b2a857671
- author
- patriceckhart · · 2026-06-22 15:39 UTC
- parent
- 6899695
- signature
- unsigned
- refs
- tag: v0.2.44
4 files · +153 -19
M packages/agent/cli.go +5 -3
| @@ -484,9 +484,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 484 | 484 | return reg | |
| 485 | 485 | } | |
| 486 | 486 | reg["swarm_spawn"] = &tools.SwarmSpawnTool{ | |
| 487 | - | Swarm: swarmMgr, | |
| 488 | - | Enabled: AutoSwarmEnabled, | |
| 489 | - | OnSpawned: onSpawnedSwarm, | |
| 487 | + | Swarm: swarmMgr, | |
| 488 | + | Enabled: AutoSwarmEnabled, | |
| 489 | + | DefaultModel: func() string { return r.Model }, | |
| 490 | + | DefaultProvider: func() string { return r.Provider }, | |
| 491 | + | OnSpawned: onSpawnedSwarm, | |
| 490 | 492 | } | |
| 491 | 493 | return reg | |
| 492 | 494 | } | |
| @@ -484,9 +484,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 484 | return reg | 484 | return reg | ||
| 485 | } | 485 | } | ||
| 486 | reg["swarm_spawn"] = &tools.SwarmSpawnTool{ | 486 | reg["swarm_spawn"] = &tools.SwarmSpawnTool{ | ||
| 487 | - | Swarm: swarmMgr, | 487 | + | Swarm: swarmMgr, |
| 488 | - | Enabled: AutoSwarmEnabled, | 488 | + | Enabled: AutoSwarmEnabled, |
| 489 | - | OnSpawned: onSpawnedSwarm, | 489 | + | DefaultModel: func() string { return r.Model }, |
| 490 | + | DefaultProvider: func() string { return r.Provider }, | |||
| 491 | + | OnSpawned: onSpawnedSwarm, | |||
| 490 | } | 492 | } | ||
| 491 | return reg | 493 | return reg | ||
| 492 | } | 494 | } | ||
M packages/agent/modes/interactive.go +17 -6
| @@ -508,6 +508,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 508 | 508 | i.view.TailLimit = initialResumeTailLimit | |
| 509 | 509 | } | |
| 510 | 510 | } | |
| 511 | + | if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled { | |
| 512 | + | i.applyAutoSwarmTool(true) | |
| 513 | + | } | |
| 511 | 514 | return i | |
| 512 | 515 | } | |
| 513 | 516 | ||
| @@ -4175,9 +4178,10 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string) | |||
| 4175 | 4178 | // to invalidate. | |
| 4176 | 4179 | i.mu.Unlock() | |
| 4177 | 4180 | // The new agent was built off the base tool registry, so any | |
| 4178 | - | // dynamically-registered tools (telegram_send_*) need to be | |
| 4179 | - | // reattached. applyTelegramTools is a no-op when the bridge is | |
| 4180 | - | // idle so the cross-provider path still works on a vanilla setup. | |
| 4181 | + | // dynamically-registered tools need to be reattached. The apply | |
| 4182 | + | // helpers are no-ops when their feature is inactive, so the | |
| 4183 | + | // cross-provider path still works on a vanilla setup. | |
| 4184 | + | i.applyAutoSwarmTool(i.autoSwarmEnabled()) | |
| 4181 | 4185 | i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active()) | |
| 4182 | 4186 | if i.cfg.PersistModel != nil { | |
| 4183 | 4187 | i.cfg.PersistModel(p, md) | |
| @@ -4206,6 +4210,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) { | |||
| 4206 | 4210 | i.statusErr = "" | |
| 4207 | 4211 | i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method | |
| 4208 | 4212 | i.mu.Unlock() | |
| 4213 | + | i.applyAutoSwarmTool(i.autoSwarmEnabled()) | |
| 4209 | 4214 | i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active()) | |
| 4210 | 4215 | i.dialog.ShowResult(true, "") | |
| 4211 | 4216 | } | |
| @@ -5269,6 +5274,10 @@ func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) { | |||
| 5269 | 5274 | // when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools' | |
| 5270 | 5275 | // snapshot+mutate pattern so extension tools and /reload-ext additions | |
| 5271 | 5276 | // survive a toggle. | |
| 5277 | + | func (i *Interactive) autoSwarmEnabled() bool { | |
| 5278 | + | return i.cfg.AutoSwarmEnabled != nil && *i.cfg.AutoSwarmEnabled | |
| 5279 | + | } | |
| 5280 | + | ||
| 5272 | 5281 | func (i *Interactive) applyAutoSwarmTool(active bool) { | |
| 5273 | 5282 | if i.agent == nil { | |
| 5274 | 5283 | return | |
| @@ -5283,9 +5292,11 @@ func (i *Interactive) applyAutoSwarmTool(active bool) { | |||
| 5283 | 5292 | } | |
| 5284 | 5293 | if active && i.cfg.Swarm != nil { | |
| 5285 | 5294 | next["swarm_spawn"] = &tools.SwarmSpawnTool{ | |
| 5286 | - | Swarm: i.cfg.Swarm, | |
| 5287 | - | Enabled: func() bool { return true }, | |
| 5288 | - | OnSpawned: i.trackSwarmAgent, | |
| 5295 | + | Swarm: i.cfg.Swarm, | |
| 5296 | + | Enabled: func() bool { return true }, | |
| 5297 | + | DefaultModel: func() string { return i.cfg.Model }, | |
| 5298 | + | DefaultProvider: func() string { return i.cfg.Provider }, | |
| 5299 | + | OnSpawned: i.trackSwarmAgent, | |
| 5289 | 5300 | } | |
| 5290 | 5301 | } | |
| 5291 | 5302 | i.agent.SetTools(next) | |
| @@ -508,6 +508,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 508 | i.view.TailLimit = initialResumeTailLimit | 508 | i.view.TailLimit = initialResumeTailLimit | ||
| 509 | } | 509 | } | ||
| 510 | } | 510 | } | ||
| 511 | + | if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled { | |||
| 512 | + | i.applyAutoSwarmTool(true) | |||
| 513 | + | } | |||
| 511 | return i | 514 | return i | ||
| 512 | } | 515 | } | ||
| 513 | 516 | ||||
| @@ -4175,9 +4178,10 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string) | |||||
| 4175 | // to invalidate. | 4178 | // to invalidate. | ||
| 4176 | i.mu.Unlock() | 4179 | i.mu.Unlock() | ||
| 4177 | // The new agent was built off the base tool registry, so any | 4180 | // The new agent was built off the base tool registry, so any | ||
| 4178 | - | // dynamically-registered tools (telegram_send_*) need to be | 4181 | + | // dynamically-registered tools need to be reattached. The apply |
| 4179 | - | // reattached. applyTelegramTools is a no-op when the bridge is | 4182 | + | // helpers are no-ops when their feature is inactive, so the |
| 4180 | - | // idle so the cross-provider path still works on a vanilla setup. | 4183 | + | // cross-provider path still works on a vanilla setup. |
| 4184 | + | i.applyAutoSwarmTool(i.autoSwarmEnabled()) | |||
| 4181 | i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active()) | 4185 | i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active()) | ||
| 4182 | if i.cfg.PersistModel != nil { | 4186 | if i.cfg.PersistModel != nil { | ||
| 4183 | i.cfg.PersistModel(p, md) | 4187 | i.cfg.PersistModel(p, md) | ||
| @@ -4206,6 +4210,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) { | |||||
| 4206 | i.statusErr = "" | 4210 | i.statusErr = "" | ||
| 4207 | i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method | 4211 | i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method | ||
| 4208 | i.mu.Unlock() | 4212 | i.mu.Unlock() | ||
| 4213 | + | i.applyAutoSwarmTool(i.autoSwarmEnabled()) | |||
| 4209 | i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active()) | 4214 | i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active()) | ||
| 4210 | i.dialog.ShowResult(true, "") | 4215 | i.dialog.ShowResult(true, "") | ||
| 4211 | } | 4216 | } | ||
| @@ -5269,6 +5274,10 @@ func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) { | |||||
| 5269 | // when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools' | 5274 | // when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools' | ||
| 5270 | // snapshot+mutate pattern so extension tools and /reload-ext additions | 5275 | // snapshot+mutate pattern so extension tools and /reload-ext additions | ||
| 5271 | // survive a toggle. | 5276 | // survive a toggle. | ||
| 5277 | + | func (i *Interactive) autoSwarmEnabled() bool { | |||
| 5278 | + | return i.cfg.AutoSwarmEnabled != nil && *i.cfg.AutoSwarmEnabled | |||
| 5279 | + | } | |||
| 5280 | + | ||||
| 5272 | func (i *Interactive) applyAutoSwarmTool(active bool) { | 5281 | func (i *Interactive) applyAutoSwarmTool(active bool) { | ||
| 5273 | if i.agent == nil { | 5282 | if i.agent == nil { | ||
| 5274 | return | 5283 | return | ||
| @@ -5283,9 +5292,11 @@ func (i *Interactive) applyAutoSwarmTool(active bool) { | |||||
| 5283 | } | 5292 | } | ||
| 5284 | if active && i.cfg.Swarm != nil { | 5293 | if active && i.cfg.Swarm != nil { | ||
| 5285 | next["swarm_spawn"] = &tools.SwarmSpawnTool{ | 5294 | next["swarm_spawn"] = &tools.SwarmSpawnTool{ | ||
| 5286 | - | Swarm: i.cfg.Swarm, | 5295 | + | Swarm: i.cfg.Swarm, |
| 5287 | - | Enabled: func() bool { return true }, | 5296 | + | Enabled: func() bool { return true }, |
| 5288 | - | OnSpawned: i.trackSwarmAgent, | 5297 | + | DefaultModel: func() string { return i.cfg.Model }, |
| 5298 | + | DefaultProvider: func() string { return i.cfg.Provider }, | |||
| 5299 | + | OnSpawned: i.trackSwarmAgent, | |||
| 5289 | } | 5300 | } | ||
| 5290 | } | 5301 | } | ||
| 5291 | i.agent.SetTools(next) | 5302 | i.agent.SetTools(next) | ||
M packages/agent/tools/swarm_spawn.go +31 -10
| @@ -31,6 +31,13 @@ type SwarmSpawnTool struct { | |||
| 31 | 31 | // is treated as disabled. | |
| 32 | 32 | Enabled func() bool | |
| 33 | 33 | ||
| 34 | + | // DefaultModel and DefaultProvider return the host agent's resolved | |
| 35 | + | // model and provider. They are used when the tool call omits both | |
| 36 | + | // fields, so auto-swarm follows the same auth route as the user sees | |
| 37 | + | // in the parent session. | |
| 38 | + | DefaultModel func() string | |
| 39 | + | DefaultProvider func() string | |
| 40 | + | ||
| 34 | 41 | // OnSpawned, if set, is called after every successful spawn with | |
| 35 | 42 | // the new agent + the task it was started with. Used by the | |
| 36 | 43 | // interactive host to track agents and surface a summary back | |
| @@ -53,11 +60,11 @@ const swarmSpawnSchema = `{ | |||
| 53 | 60 | }, | |
| 54 | 61 | "model": { | |
| 55 | 62 | "type": "string", | |
| 56 | - | "description": "Optional model id to pin the sub-agent to (e.g. \"claude-sonnet-4-5\", \"gpt-5\"). Defaults to the host's current model." | |
| 63 | + | "description": "Optional model id to pin the sub-agent to. Normally omit both model and provider so the sub-agent inherits the host session's resolved provider/model/auth route. Do not infer provider from model name. If you override this, also provide provider." | |
| 57 | 64 | }, | |
| 58 | 65 | "provider": { | |
| 59 | 66 | "type": "string", | |
| 60 | - | "description": "Optional provider id (e.g. \"anthropic\", \"openai\"). Usually paired with model." | |
| 67 | + | "description": "Optional provider id. Normally omit both model and provider so the sub-agent inherits the host session. If you override this, also provide model. Note: openai means public OpenAI API-key auth; openai-codex means ChatGPT/Codex subscription auth." | |
| 61 | 68 | } | |
| 62 | 69 | }, | |
| 63 | 70 | "required": ["task"] | |
| @@ -85,10 +92,24 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 85 | 92 | return toolErr("swarm_spawn: task is required"), nil | |
| 86 | 93 | } | |
| 87 | 94 | ||
| 95 | + | model := strings.TrimSpace(a.Model) | |
| 96 | + | providerID := strings.TrimSpace(a.Provider) | |
| 97 | + | if (model == "") != (providerID == "") { | |
| 98 | + | return toolErr("swarm_spawn: omit both model/provider to inherit the host, or provide both explicitly"), nil | |
| 99 | + | } | |
| 100 | + | if model == "" && providerID == "" { | |
| 101 | + | if t.DefaultModel != nil { | |
| 102 | + | model = strings.TrimSpace(t.DefaultModel()) | |
| 103 | + | } | |
| 104 | + | if t.DefaultProvider != nil { | |
| 105 | + | providerID = strings.TrimSpace(t.DefaultProvider()) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 88 | 109 | agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | |
| 89 | 110 | Task: task, | |
| 90 | - | Model: strings.TrimSpace(a.Model), | |
| 91 | - | Provider: strings.TrimSpace(a.Provider), | |
| 111 | + | Model: model, | |
| 112 | + | Provider: providerID, | |
| 92 | 113 | }) | |
| 93 | 114 | if err != nil { | |
| 94 | 115 | return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err) | |
| @@ -100,11 +121,11 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 100 | 121 | var sb strings.Builder | |
| 101 | 122 | fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID) | |
| 102 | 123 | fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200)) | |
| 103 | - | if a.Model != "" { | |
| 104 | - | fmt.Fprintf(&sb, "model: %s\n", a.Model) | |
| 124 | + | if model != "" { | |
| 125 | + | fmt.Fprintf(&sb, "model: %s\n", model) | |
| 105 | 126 | } | |
| 106 | - | if a.Provider != "" { | |
| 107 | - | fmt.Fprintf(&sb, "provider: %s\n", a.Provider) | |
| 127 | + | if providerID != "" { | |
| 128 | + | fmt.Fprintf(&sb, "provider: %s\n", providerID) | |
| 108 | 129 | } | |
| 109 | 130 | sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ") | |
| 110 | 131 | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | |
| @@ -113,8 +134,8 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||
| 113 | 134 | Details: map[string]any{ | |
| 114 | 135 | "agent_id": agent.ID, | |
| 115 | 136 | "task": task, | |
| 116 | - | "model": a.Model, | |
| 117 | - | "provider": a.Provider, | |
| 137 | + | "model": model, | |
| 138 | + | "provider": providerID, | |
| 118 | 139 | }, | |
| 119 | 140 | }, nil | |
| 120 | 141 | } | |
| @@ -31,6 +31,13 @@ type SwarmSpawnTool struct { | |||||
| 31 | // is treated as disabled. | 31 | // is treated as disabled. | ||
| 32 | Enabled func() bool | 32 | Enabled func() bool | ||
| 33 | 33 | ||||
| 34 | + | // DefaultModel and DefaultProvider return the host agent's resolved | |||
| 35 | + | // model and provider. They are used when the tool call omits both | |||
| 36 | + | // fields, so auto-swarm follows the same auth route as the user sees | |||
| 37 | + | // in the parent session. | |||
| 38 | + | DefaultModel func() string | |||
| 39 | + | DefaultProvider func() string | |||
| 40 | + | ||||
| 34 | // OnSpawned, if set, is called after every successful spawn with | 41 | // OnSpawned, if set, is called after every successful spawn with | ||
| 35 | // the new agent + the task it was started with. Used by the | 42 | // the new agent + the task it was started with. Used by the | ||
| 36 | // interactive host to track agents and surface a summary back | 43 | // interactive host to track agents and surface a summary back | ||
| @@ -53,11 +60,11 @@ const swarmSpawnSchema = `{ | |||||
| 53 | }, | 60 | }, | ||
| 54 | "model": { | 61 | "model": { | ||
| 55 | "type": "string", | 62 | "type": "string", | ||
| 56 | - | "description": "Optional model id to pin the sub-agent to (e.g. \"claude-sonnet-4-5\", \"gpt-5\"). Defaults to the host's current model." | 63 | + | "description": "Optional model id to pin the sub-agent to. Normally omit both model and provider so the sub-agent inherits the host session's resolved provider/model/auth route. Do not infer provider from model name. If you override this, also provide provider." |
| 57 | }, | 64 | }, | ||
| 58 | "provider": { | 65 | "provider": { | ||
| 59 | "type": "string", | 66 | "type": "string", | ||
| 60 | - | "description": "Optional provider id (e.g. \"anthropic\", \"openai\"). Usually paired with model." | 67 | + | "description": "Optional provider id. Normally omit both model and provider so the sub-agent inherits the host session. If you override this, also provide model. Note: openai means public OpenAI API-key auth; openai-codex means ChatGPT/Codex subscription auth." |
| 61 | } | 68 | } | ||
| 62 | }, | 69 | }, | ||
| 63 | "required": ["task"] | 70 | "required": ["task"] | ||
| @@ -85,10 +92,24 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 85 | return toolErr("swarm_spawn: task is required"), nil | 92 | return toolErr("swarm_spawn: task is required"), nil | ||
| 86 | } | 93 | } | ||
| 87 | 94 | ||||
| 95 | + | model := strings.TrimSpace(a.Model) | |||
| 96 | + | providerID := strings.TrimSpace(a.Provider) | |||
| 97 | + | if (model == "") != (providerID == "") { | |||
| 98 | + | return toolErr("swarm_spawn: omit both model/provider to inherit the host, or provide both explicitly"), nil | |||
| 99 | + | } | |||
| 100 | + | if model == "" && providerID == "" { | |||
| 101 | + | if t.DefaultModel != nil { | |||
| 102 | + | model = strings.TrimSpace(t.DefaultModel()) | |||
| 103 | + | } | |||
| 104 | + | if t.DefaultProvider != nil { | |||
| 105 | + | providerID = strings.TrimSpace(t.DefaultProvider()) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 88 | agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | 109 | agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | ||
| 89 | Task: task, | 110 | Task: task, | ||
| 90 | - | Model: strings.TrimSpace(a.Model), | 111 | + | Model: model, |
| 91 | - | Provider: strings.TrimSpace(a.Provider), | 112 | + | Provider: providerID, |
| 92 | }) | 113 | }) | ||
| 93 | if err != nil { | 114 | if err != nil { | ||
| 94 | return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err) | 115 | return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err) | ||
| @@ -100,11 +121,11 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 100 | var sb strings.Builder | 121 | var sb strings.Builder | ||
| 101 | fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID) | 122 | fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID) | ||
| 102 | fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200)) | 123 | fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200)) | ||
| 103 | - | if a.Model != "" { | 124 | + | if model != "" { |
| 104 | - | fmt.Fprintf(&sb, "model: %s\n", a.Model) | 125 | + | fmt.Fprintf(&sb, "model: %s\n", model) |
| 105 | } | 126 | } | ||
| 106 | - | if a.Provider != "" { | 127 | + | if providerID != "" { |
| 107 | - | fmt.Fprintf(&sb, "provider: %s\n", a.Provider) | 128 | + | fmt.Fprintf(&sb, "provider: %s\n", providerID) |
| 108 | } | 129 | } | ||
| 109 | sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ") | 130 | sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ") | ||
| 110 | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | 131 | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | ||
| @@ -113,8 +134,8 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr | |||||
| 113 | Details: map[string]any{ | 134 | Details: map[string]any{ | ||
| 114 | "agent_id": agent.ID, | 135 | "agent_id": agent.ID, | ||
| 115 | "task": task, | 136 | "task": task, | ||
| 116 | - | "model": a.Model, | 137 | + | "model": model, |
| 117 | - | "provider": a.Provider, | 138 | + | "provider": providerID, |
| 118 | }, | 139 | }, | ||
| 119 | }, nil | 140 | }, nil | ||
| 120 | } | 141 | } | ||
A packages/agent/tools/swarm_spawn_test.go +100 -0
| @@ -0,0 +1,100 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "path/filepath" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/swarm" | |
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type noopSwarmRunner struct{} | |
| 15 | + | ||
| 16 | + | func (noopSwarmRunner) Run(context.Context, swarm.Sink) error { return nil } | |
| 17 | + | ||
| 18 | + | func newTestSwarm(t *testing.T) *swarm.Swarm { | |
| 19 | + | t.Helper() | |
| 20 | + | root := t.TempDir() | |
| 21 | + | return swarm.New(swarm.Config{ | |
| 22 | + | Root: filepath.Join(root, "swarm"), | |
| 23 | + | RepoRoot: root, | |
| 24 | + | NewRunner: func(*swarm.Agent) swarm.Runner { | |
| 25 | + | return noopSwarmRunner{} | |
| 26 | + | }, | |
| 27 | + | }) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | |
| 31 | + | tool := &SwarmSpawnTool{ | |
| 32 | + | Swarm: newTestSwarm(t), | |
| 33 | + | Enabled: func() bool { return true }, | |
| 34 | + | DefaultModel: func() string { return "gpt-5" }, | |
| 35 | + | DefaultProvider: func() string { return "openai-codex" }, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil) | |
| 39 | + | if err != nil { | |
| 40 | + | t.Fatal(err) | |
| 41 | + | } | |
| 42 | + | if res.IsError { | |
| 43 | + | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |
| 44 | + | } | |
| 45 | + | details, ok := res.Details.(map[string]any) | |
| 46 | + | if !ok { | |
| 47 | + | t.Fatalf("details type = %T, want map[string]any", res.Details) | |
| 48 | + | } | |
| 49 | + | if got := details["model"]; got != "gpt-5" { | |
| 50 | + | t.Fatalf("model detail = %v, want gpt-5", got) | |
| 51 | + | } | |
| 52 | + | if got := details["provider"]; got != "openai-codex" { | |
| 53 | + | t.Fatalf("provider detail = %v, want openai-codex", got) | |
| 54 | + | } | |
| 55 | + | text := textResult(res.Content) | |
| 56 | + | if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") { | |
| 57 | + | t.Fatalf("result text missing inherited model/provider:\n%s", text) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | agents := tool.Swarm.List() | |
| 61 | + | if len(agents) != 1 { | |
| 62 | + | t.Fatalf("spawned agents = %d, want 1", len(agents)) | |
| 63 | + | } | |
| 64 | + | if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" { | |
| 65 | + | t.Fatalf("agent model/provider = %q/%q, want gpt-5/openai-codex", agents[0].Model, agents[0].Provider) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func TestSwarmSpawnRejectsPartialModelProviderOverride(t *testing.T) { | |
| 70 | + | tool := &SwarmSpawnTool{ | |
| 71 | + | Swarm: newTestSwarm(t), | |
| 72 | + | Enabled: func() bool { return true }, | |
| 73 | + | DefaultModel: func() string { return "gpt-5" }, | |
| 74 | + | DefaultProvider: func() string { return "openai-codex" }, | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","provider":"openai"}`), nil) | |
| 78 | + | if err != nil { | |
| 79 | + | t.Fatal(err) | |
| 80 | + | } | |
| 81 | + | if !res.IsError { | |
| 82 | + | t.Fatalf("expected partial override to fail") | |
| 83 | + | } | |
| 84 | + | if got := textResult(res.Content); !strings.Contains(got, "omit both model/provider") { | |
| 85 | + | t.Fatalf("error text = %q", got) | |
| 86 | + | } | |
| 87 | + | if got := len(tool.Swarm.List()); got != 0 { | |
| 88 | + | t.Fatalf("spawned agents = %d, want 0", got) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func textResult(content []provider.Content) string { | |
| 93 | + | if len(content) == 0 { | |
| 94 | + | return "" | |
| 95 | + | } | |
| 96 | + | if tb, ok := content[0].(provider.TextBlock); ok { | |
| 97 | + | return tb.Text | |
| 98 | + | } | |
| 99 | + | return "" | |
| 100 | + | } | |
| @@ -0,0 +1,100 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/patriceckhart/zot/packages/agent/swarm" | |||
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | type noopSwarmRunner struct{} | |||
| 15 | + | ||||
| 16 | + | func (noopSwarmRunner) Run(context.Context, swarm.Sink) error { return nil } | |||
| 17 | + | ||||
| 18 | + | func newTestSwarm(t *testing.T) *swarm.Swarm { | |||
| 19 | + | t.Helper() | |||
| 20 | + | root := t.TempDir() | |||
| 21 | + | return swarm.New(swarm.Config{ | |||
| 22 | + | Root: filepath.Join(root, "swarm"), | |||
| 23 | + | RepoRoot: root, | |||
| 24 | + | NewRunner: func(*swarm.Agent) swarm.Runner { | |||
| 25 | + | return noopSwarmRunner{} | |||
| 26 | + | }, | |||
| 27 | + | }) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) { | |||
| 31 | + | tool := &SwarmSpawnTool{ | |||
| 32 | + | Swarm: newTestSwarm(t), | |||
| 33 | + | Enabled: func() bool { return true }, | |||
| 34 | + | DefaultModel: func() string { return "gpt-5" }, | |||
| 35 | + | DefaultProvider: func() string { return "openai-codex" }, | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil) | |||
| 39 | + | if err != nil { | |||
| 40 | + | t.Fatal(err) | |||
| 41 | + | } | |||
| 42 | + | if res.IsError { | |||
| 43 | + | t.Fatalf("unexpected tool error: %s", textResult(res.Content)) | |||
| 44 | + | } | |||
| 45 | + | details, ok := res.Details.(map[string]any) | |||
| 46 | + | if !ok { | |||
| 47 | + | t.Fatalf("details type = %T, want map[string]any", res.Details) | |||
| 48 | + | } | |||
| 49 | + | if got := details["model"]; got != "gpt-5" { | |||
| 50 | + | t.Fatalf("model detail = %v, want gpt-5", got) | |||
| 51 | + | } | |||
| 52 | + | if got := details["provider"]; got != "openai-codex" { | |||
| 53 | + | t.Fatalf("provider detail = %v, want openai-codex", got) | |||
| 54 | + | } | |||
| 55 | + | text := textResult(res.Content) | |||
| 56 | + | if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") { | |||
| 57 | + | t.Fatalf("result text missing inherited model/provider:\n%s", text) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | agents := tool.Swarm.List() | |||
| 61 | + | if len(agents) != 1 { | |||
| 62 | + | t.Fatalf("spawned agents = %d, want 1", len(agents)) | |||
| 63 | + | } | |||
| 64 | + | if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" { | |||
| 65 | + | t.Fatalf("agent model/provider = %q/%q, want gpt-5/openai-codex", agents[0].Model, agents[0].Provider) | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func TestSwarmSpawnRejectsPartialModelProviderOverride(t *testing.T) { | |||
| 70 | + | tool := &SwarmSpawnTool{ | |||
| 71 | + | Swarm: newTestSwarm(t), | |||
| 72 | + | Enabled: func() bool { return true }, | |||
| 73 | + | DefaultModel: func() string { return "gpt-5" }, | |||
| 74 | + | DefaultProvider: func() string { return "openai-codex" }, | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","provider":"openai"}`), nil) | |||
| 78 | + | if err != nil { | |||
| 79 | + | t.Fatal(err) | |||
| 80 | + | } | |||
| 81 | + | if !res.IsError { | |||
| 82 | + | t.Fatalf("expected partial override to fail") | |||
| 83 | + | } | |||
| 84 | + | if got := textResult(res.Content); !strings.Contains(got, "omit both model/provider") { | |||
| 85 | + | t.Fatalf("error text = %q", got) | |||
| 86 | + | } | |||
| 87 | + | if got := len(tool.Swarm.List()); got != 0 { | |||
| 88 | + | t.Fatalf("spawned agents = %d, want 0", got) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func textResult(content []provider.Content) string { | |||
| 93 | + | if len(content) == 0 { | |||
| 94 | + | return "" | |||
| 95 | + | } | |||
| 96 | + | if tb, ok := content[0].(provider.TextBlock); ok { | |||
| 97 | + | return tb.Text | |||
| 98 | + | } | |||
| 99 | + | return "" | |||
| 100 | + | } | |||