feat: /settings dialog with auto-swarm toggle
Re-enable the /settings slash command and add an auto-swarm setting that lets the agent spawn parallel background sub-agents on its own. - Config: persist auto_swarm_enabled in config.json. - SettingsStore: SetAutoSwarm; AutoSwarmEnabled() reads live flag. - /settings: two items (inline images, auto-swarm); any toggle now triggers a full repaint (Ctrl+L equivalent) so changes land immediately. - New built-in tool swarm_spawn: forks a background sub-agent against the host cwd via Swarm.SpawnReq and returns the agent id immediately so the main turn keeps running in parallel. Schema accepts task, optional model, optional provider. - Tool is only registered when auto-swarm is on (startup checks the flag; live toggle adds/removes it on the running agent). Model never sees a tool it can't use. - System prompt gains an AutoSwarmSystemAddendum when auto-swarm is on, telling the model to use swarm_spawn proactively for parallelizable sub-tasks. Applied at Resolve time and live-swapped on toggle so the model adapts mid-session without a restart. - cli.go: construct swarmMgr earlier so the auto-swarm tool can capture it before agent build; injectSwarmSpawn helper threads through every registry build path (initial, buildAgent, buildAgentFor, buildAgentForRescue, /reload-ext).
- commit
- 6320769094fddc845ed9e73586d5e81f23c2cc74
- author
- patriceckhart · · 2026-05-25 16:29 UTC
- parent
- 6684724
- signature
- unsigned
7 files · +342 -30
M internal/agent/build.go +3 -0
| @@ -405,6 +405,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 405 | 405 | if skillAddendum != "" { | |
| 406 | 406 | append_ = append(append_, skillAddendum) | |
| 407 | 407 | } | |
| 408 | + | if AutoSwarmEnabled() { | |
| 409 | + | append_ = append(append_, AutoSwarmSystemAddendum) | |
| 410 | + | } | |
| 408 | 411 | ||
| 409 | 412 | // Custom system prompt resolution order: | |
| 410 | 413 | // 1. --system-prompt flag (highest priority; ad-hoc per run) | |
| @@ -405,6 +405,9 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 405 | if skillAddendum != "" { | 405 | if skillAddendum != "" { | ||
| 406 | append_ = append(append_, skillAddendum) | 406 | append_ = append(append_, skillAddendum) | ||
| 407 | } | 407 | } | ||
| 408 | + | if AutoSwarmEnabled() { | |||
| 409 | + | append_ = append(append_, AutoSwarmSystemAddendum) | |||
| 410 | + | } | |||
| 408 | 411 | ||||
| 409 | // Custom system prompt resolution order: | 412 | // Custom system prompt resolution order: | ||
| 410 | // 1. --system-prompt flag (highest priority; ad-hoc per run) | 413 | // 1. --system-prompt flag (highest priority; ad-hoc per run) | ||
M internal/agent/cli.go +49 -17
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | "github.com/patriceckhart/zot/internal/agent/extensions" | |
| 19 | 19 | "github.com/patriceckhart/zot/internal/agent/modes" | |
| 20 | + | "github.com/patriceckhart/zot/internal/agent/tools" | |
| 20 | 21 | "github.com/patriceckhart/zot/internal/auth" | |
| 21 | 22 | "github.com/patriceckhart/zot/internal/core" | |
| 22 | 23 | "github.com/patriceckhart/zot/internal/extproto" | |
| @@ -422,6 +423,45 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 422 | 423 | extToolAdapter := &extToolAdapter{mgr: extMgr} | |
| 423 | 424 | r.MergeExtensionTools(extToolAdapter) | |
| 424 | 425 | ||
| 426 | + | // Build the swarm supervisor BEFORE the agent so the auto-swarm | |
| 427 | + | // tool can reference it during tool-registry construction. State | |
| 428 | + | // lives under ZotHome/swarm so per-agent meta/events survive | |
| 429 | + | // restarts; the user can hunt orphaned agents down with | |
| 430 | + | // `git worktree list` if anything misbehaves. | |
| 431 | + | // | |
| 432 | + | // swarmMgr is also captured by loadSession / changeCWD closures | |
| 433 | + | // further down the function, which is why we keep the variable | |
| 434 | + | // in this outer scope rather than scoping it tighter. | |
| 435 | + | var swarmMgr *swarm.Swarm | |
| 436 | + | swarmMgr = swarm.New(swarm.Config{ | |
| 437 | + | Root: filepath.Join(ZotHome(), "swarm"), | |
| 438 | + | RepoRoot: r.CWD, | |
| 439 | + | }) | |
| 440 | + | // Pull any previously-spawned agents off disk so the dashboard | |
| 441 | + | // shows them as detached and the user can resume / remove them. | |
| 442 | + | _, _ = swarmMgr.Reload() | |
| 443 | + | ||
| 444 | + | // Inject the swarm_spawn auto-swarm tool only when /settings -> | |
| 445 | + | // auto-swarm is currently enabled. Registering it unconditionally | |
| 446 | + | // leaves the model trying to call it (and getting a polite error) | |
| 447 | + | // even when the user has switched the feature off. The /settings | |
| 448 | + | // toggle live-mutates the running agent's registry separately so | |
| 449 | + | // flipping the flag mid-session takes effect on the next turn. | |
| 450 | + | injectSwarmSpawn := func(reg core.Registry) core.Registry { | |
| 451 | + | if reg == nil { | |
| 452 | + | return reg | |
| 453 | + | } | |
| 454 | + | if !AutoSwarmEnabled() { | |
| 455 | + | return reg | |
| 456 | + | } | |
| 457 | + | reg["swarm_spawn"] = &tools.SwarmSpawnTool{ | |
| 458 | + | Swarm: swarmMgr, | |
| 459 | + | Enabled: AutoSwarmEnabled, | |
| 460 | + | } | |
| 461 | + | return reg | |
| 462 | + | } | |
| 463 | + | injectSwarmSpawn(r.ToolRegistry) | |
| 464 | + | ||
| 425 | 465 | // Confirmation gate: when --no-yolo is on, the agent must ask | |
| 426 | 466 | // the user before every tool call. In interactive mode the TUI | |
| 427 | 467 | // provides the Confirmer; in print/json/rpc modes there's no | |
| @@ -475,6 +515,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 475 | 515 | } | |
| 476 | 516 | resolved.UseSandbox(sharedSandbox) | |
| 477 | 517 | resolved.MergeExtensionTools(extToolAdapter) | |
| 518 | + | injectSwarmSpawn(resolved.ToolRegistry) | |
| 478 | 519 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | |
| 479 | 520 | } | |
| 480 | 521 | ||
| @@ -493,6 +534,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 493 | 534 | } | |
| 494 | 535 | resolved.UseSandbox(sharedSandbox) | |
| 495 | 536 | resolved.MergeExtensionTools(extToolAdapter) | |
| 537 | + | injectSwarmSpawn(resolved.ToolRegistry) | |
| 496 | 538 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | |
| 497 | 539 | } | |
| 498 | 540 | ||
| @@ -519,6 +561,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 519 | 561 | } | |
| 520 | 562 | resolved.UseSandbox(sharedSandbox) | |
| 521 | 563 | resolved.MergeExtensionTools(extToolAdapter) | |
| 564 | + | injectSwarmSpawn(resolved.ToolRegistry) | |
| 522 | 565 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | |
| 523 | 566 | } | |
| 524 | 567 | ||
| @@ -544,6 +587,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 544 | 587 | } | |
| 545 | 588 | resolved.UseSandbox(sharedSandbox) | |
| 546 | 589 | resolved.MergeExtensionTools(extToolAdapter) | |
| 590 | + | injectSwarmSpawn(resolved.ToolRegistry) | |
| 547 | 591 | current.SetTools(resolved.ToolRegistry) | |
| 548 | 592 | }) | |
| 549 | 593 | ||
| @@ -552,11 +596,6 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 552 | 596 | ||
| 553 | 597 | var sess *core.Session | |
| 554 | 598 | var sessBaselineMsgs int // messages already on disk when current session opened | |
| 555 | - | // swarmMgr is constructed below, but loadSession (defined before | |
| 556 | - | // the construction site) needs to re-scope it whenever the user | |
| 557 | - | // swaps sessions. Forward-declare here so the closure can | |
| 558 | - | // reference it; the assignment happens at the construction line. | |
| 559 | - | var swarmMgr *swarm.Swarm | |
| 560 | 599 | // persistMu guards sess + sessBaselineMsgs against concurrent access | |
| 561 | 600 | // from the agent loop's per-message persistence hook (runs on the | |
| 562 | 601 | // agent goroutine) and the TUI's session swap / flush callbacks | |
| @@ -861,18 +900,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 861 | 900 | ||
| 862 | 901 | initialCfg, _ := LoadConfig() | |
| 863 | 902 | ||
| 864 | - | // Build the swarm supervisor. Root lives under ZotHome/swarm so | |
| 865 | - | // worktrees survive across zot sessions and the user can hunt | |
| 866 | - | // them down with `git worktree list` if anything misbehaves. | |
| 867 | - | swarmMgr = swarm.New(swarm.Config{ | |
| 868 | - | Root: filepath.Join(ZotHome(), "swarm"), | |
| 869 | - | RepoRoot: r.CWD, | |
| 870 | - | }) | |
| 871 | - | // Pull any previously-spawned agents off disk so the user can see, | |
| 872 | - | // resume, or remove them from the dashboard. Failures here aren't | |
| 873 | - | // fatal — the supervisor still works for new agents. | |
| 874 | - | _, _ = swarmMgr.Reload() | |
| 875 | - | // Scope the dashboard to the active host session so /swarm only | |
| 903 | + | // swarmMgr was constructed and reloaded earlier (before the agent | |
| 904 | + | // build, so the auto-swarm tool could capture it). Here we just | |
| 905 | + | // scope the dashboard to the active host session so /swarm only | |
| 876 | 906 | // shows agents this session spawned (and any pre-upgrade unscoped | |
| 877 | 907 | // agents — see SnapshotAll docs). Updated again whenever the | |
| 878 | 908 | // user swaps sessions via loadSession below. | |
| @@ -887,6 +917,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 887 | 917 | Terminal: term, | |
| 888 | 918 | Theme: tui.DetectThemeFromBackground(80 * time.Millisecond), | |
| 889 | 919 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | |
| 920 | + | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | |
| 921 | + | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | |
| 890 | 922 | SettingsStore: configSettingsStore{}, | |
| 891 | 923 | Model: r.Model, | |
| 892 | 924 | Provider: r.Provider, | |
| @@ -17,6 +17,7 @@ import ( | |||||
| 17 | 17 | ||||
| 18 | "github.com/patriceckhart/zot/internal/agent/extensions" | 18 | "github.com/patriceckhart/zot/internal/agent/extensions" | ||
| 19 | "github.com/patriceckhart/zot/internal/agent/modes" | 19 | "github.com/patriceckhart/zot/internal/agent/modes" | ||
| 20 | + | "github.com/patriceckhart/zot/internal/agent/tools" | |||
| 20 | "github.com/patriceckhart/zot/internal/auth" | 21 | "github.com/patriceckhart/zot/internal/auth" | ||
| 21 | "github.com/patriceckhart/zot/internal/core" | 22 | "github.com/patriceckhart/zot/internal/core" | ||
| 22 | "github.com/patriceckhart/zot/internal/extproto" | 23 | "github.com/patriceckhart/zot/internal/extproto" | ||
| @@ -422,6 +423,45 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 422 | extToolAdapter := &extToolAdapter{mgr: extMgr} | 423 | extToolAdapter := &extToolAdapter{mgr: extMgr} | ||
| 423 | r.MergeExtensionTools(extToolAdapter) | 424 | r.MergeExtensionTools(extToolAdapter) | ||
| 424 | 425 | ||||
| 426 | + | // Build the swarm supervisor BEFORE the agent so the auto-swarm | |||
| 427 | + | // tool can reference it during tool-registry construction. State | |||
| 428 | + | // lives under ZotHome/swarm so per-agent meta/events survive | |||
| 429 | + | // restarts; the user can hunt orphaned agents down with | |||
| 430 | + | // `git worktree list` if anything misbehaves. | |||
| 431 | + | // | |||
| 432 | + | // swarmMgr is also captured by loadSession / changeCWD closures | |||
| 433 | + | // further down the function, which is why we keep the variable | |||
| 434 | + | // in this outer scope rather than scoping it tighter. | |||
| 435 | + | var swarmMgr *swarm.Swarm | |||
| 436 | + | swarmMgr = swarm.New(swarm.Config{ | |||
| 437 | + | Root: filepath.Join(ZotHome(), "swarm"), | |||
| 438 | + | RepoRoot: r.CWD, | |||
| 439 | + | }) | |||
| 440 | + | // Pull any previously-spawned agents off disk so the dashboard | |||
| 441 | + | // shows them as detached and the user can resume / remove them. | |||
| 442 | + | _, _ = swarmMgr.Reload() | |||
| 443 | + | ||||
| 444 | + | // Inject the swarm_spawn auto-swarm tool only when /settings -> | |||
| 445 | + | // auto-swarm is currently enabled. Registering it unconditionally | |||
| 446 | + | // leaves the model trying to call it (and getting a polite error) | |||
| 447 | + | // even when the user has switched the feature off. The /settings | |||
| 448 | + | // toggle live-mutates the running agent's registry separately so | |||
| 449 | + | // flipping the flag mid-session takes effect on the next turn. | |||
| 450 | + | injectSwarmSpawn := func(reg core.Registry) core.Registry { | |||
| 451 | + | if reg == nil { | |||
| 452 | + | return reg | |||
| 453 | + | } | |||
| 454 | + | if !AutoSwarmEnabled() { | |||
| 455 | + | return reg | |||
| 456 | + | } | |||
| 457 | + | reg["swarm_spawn"] = &tools.SwarmSpawnTool{ | |||
| 458 | + | Swarm: swarmMgr, | |||
| 459 | + | Enabled: AutoSwarmEnabled, | |||
| 460 | + | } | |||
| 461 | + | return reg | |||
| 462 | + | } | |||
| 463 | + | injectSwarmSpawn(r.ToolRegistry) | |||
| 464 | + | ||||
| 425 | // Confirmation gate: when --no-yolo is on, the agent must ask | 465 | // Confirmation gate: when --no-yolo is on, the agent must ask | ||
| 426 | // the user before every tool call. In interactive mode the TUI | 466 | // the user before every tool call. In interactive mode the TUI | ||
| 427 | // provides the Confirmer; in print/json/rpc modes there's no | 467 | // provides the Confirmer; in print/json/rpc modes there's no | ||
| @@ -475,6 +515,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 475 | } | 515 | } | ||
| 476 | resolved.UseSandbox(sharedSandbox) | 516 | resolved.UseSandbox(sharedSandbox) | ||
| 477 | resolved.MergeExtensionTools(extToolAdapter) | 517 | resolved.MergeExtensionTools(extToolAdapter) | ||
| 518 | + | injectSwarmSpawn(resolved.ToolRegistry) | |||
| 478 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | 519 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | ||
| 479 | } | 520 | } | ||
| 480 | 521 | ||||
| @@ -493,6 +534,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 493 | } | 534 | } | ||
| 494 | resolved.UseSandbox(sharedSandbox) | 535 | resolved.UseSandbox(sharedSandbox) | ||
| 495 | resolved.MergeExtensionTools(extToolAdapter) | 536 | resolved.MergeExtensionTools(extToolAdapter) | ||
| 537 | + | injectSwarmSpawn(resolved.ToolRegistry) | |||
| 496 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | 538 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | ||
| 497 | } | 539 | } | ||
| 498 | 540 | ||||
| @@ -519,6 +561,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 519 | } | 561 | } | ||
| 520 | resolved.UseSandbox(sharedSandbox) | 562 | resolved.UseSandbox(sharedSandbox) | ||
| 521 | resolved.MergeExtensionTools(extToolAdapter) | 563 | resolved.MergeExtensionTools(extToolAdapter) | ||
| 564 | + | injectSwarmSpawn(resolved.ToolRegistry) | |||
| 522 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | 565 | return wireAgentExt(resolved.NewAgent()), resolved.Provider, resolved.Model, nil | ||
| 523 | } | 566 | } | ||
| 524 | 567 | ||||
| @@ -544,6 +587,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 544 | } | 587 | } | ||
| 545 | resolved.UseSandbox(sharedSandbox) | 588 | resolved.UseSandbox(sharedSandbox) | ||
| 546 | resolved.MergeExtensionTools(extToolAdapter) | 589 | resolved.MergeExtensionTools(extToolAdapter) | ||
| 590 | + | injectSwarmSpawn(resolved.ToolRegistry) | |||
| 547 | current.SetTools(resolved.ToolRegistry) | 591 | current.SetTools(resolved.ToolRegistry) | ||
| 548 | }) | 592 | }) | ||
| 549 | 593 | ||||
| @@ -552,11 +596,6 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 552 | 596 | ||||
| 553 | var sess *core.Session | 597 | var sess *core.Session | ||
| 554 | var sessBaselineMsgs int // messages already on disk when current session opened | 598 | var sessBaselineMsgs int // messages already on disk when current session opened | ||
| 555 | - | // swarmMgr is constructed below, but loadSession (defined before | |||
| 556 | - | // the construction site) needs to re-scope it whenever the user | |||
| 557 | - | // swaps sessions. Forward-declare here so the closure can | |||
| 558 | - | // reference it; the assignment happens at the construction line. | |||
| 559 | - | var swarmMgr *swarm.Swarm | |||
| 560 | // persistMu guards sess + sessBaselineMsgs against concurrent access | 599 | // persistMu guards sess + sessBaselineMsgs against concurrent access | ||
| 561 | // from the agent loop's per-message persistence hook (runs on the | 600 | // from the agent loop's per-message persistence hook (runs on the | ||
| 562 | // agent goroutine) and the TUI's session swap / flush callbacks | 601 | // agent goroutine) and the TUI's session swap / flush callbacks | ||
| @@ -861,18 +900,9 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 861 | 900 | ||||
| 862 | initialCfg, _ := LoadConfig() | 901 | initialCfg, _ := LoadConfig() | ||
| 863 | 902 | ||||
| 864 | - | // Build the swarm supervisor. Root lives under ZotHome/swarm so | 903 | + | // swarmMgr was constructed and reloaded earlier (before the agent |
| 865 | - | // worktrees survive across zot sessions and the user can hunt | 904 | + | // build, so the auto-swarm tool could capture it). Here we just |
| 866 | - | // them down with `git worktree list` if anything misbehaves. | 905 | + | // scope the dashboard to the active host session so /swarm only |
| 867 | - | swarmMgr = swarm.New(swarm.Config{ | |||
| 868 | - | Root: filepath.Join(ZotHome(), "swarm"), | |||
| 869 | - | RepoRoot: r.CWD, | |||
| 870 | - | }) | |||
| 871 | - | // Pull any previously-spawned agents off disk so the user can see, | |||
| 872 | - | // resume, or remove them from the dashboard. Failures here aren't | |||
| 873 | - | // fatal — the supervisor still works for new agents. | |||
| 874 | - | _, _ = swarmMgr.Reload() | |||
| 875 | - | // Scope the dashboard to the active host session so /swarm only | |||
| 876 | // shows agents this session spawned (and any pre-upgrade unscoped | 906 | // shows agents this session spawned (and any pre-upgrade unscoped | ||
| 877 | // agents — see SnapshotAll docs). Updated again whenever the | 907 | // agents — see SnapshotAll docs). Updated again whenever the | ||
| 878 | // user swaps sessions via loadSession below. | 908 | // user swaps sessions via loadSession below. | ||
| @@ -887,6 +917,8 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 887 | Terminal: term, | 917 | Terminal: term, | ||
| 888 | Theme: tui.DetectThemeFromBackground(80 * time.Millisecond), | 918 | Theme: tui.DetectThemeFromBackground(80 * time.Millisecond), | ||
| 889 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | 919 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | ||
| 920 | + | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | |||
| 921 | + | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | |||
| 890 | SettingsStore: configSettingsStore{}, | 922 | SettingsStore: configSettingsStore{}, | ||
| 891 | Model: r.Model, | 923 | Model: r.Model, | ||
| 892 | Provider: r.Provider, | 924 | Provider: r.Provider, | ||
M internal/agent/config.go +5 -0
| @@ -27,6 +27,11 @@ type Config struct { | |||
| 27 | 27 | // detected protocol when available. | |
| 28 | 28 | InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"` | |
| 29 | 29 | ||
| 30 | + | // AutoSwarmEnabled lets the main agent spawn background sub-agents | |
| 31 | + | // for parallel sub-tasks via a built-in swarm_spawn tool. Off by | |
| 32 | + | // default; nil/missing means disabled. Toggle from /settings. | |
| 33 | + | AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"` | |
| 34 | + | ||
| 30 | 35 | // LastChangelogShown is the version whose release-notes | |
| 31 | 36 | // dialog the user has already seen. When the running binary's | |
| 32 | 37 | // version differs, the next interactive run shows the | |
| @@ -27,6 +27,11 @@ type Config struct { | |||||
| 27 | // detected protocol when available. | 27 | // detected protocol when available. | ||
| 28 | InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"` | 28 | InlineImagesEnabled *bool `json:"inline_images_enabled,omitempty"` | ||
| 29 | 29 | ||||
| 30 | + | // AutoSwarmEnabled lets the main agent spawn background sub-agents | |||
| 31 | + | // for parallel sub-tasks via a built-in swarm_spawn tool. Off by | |||
| 32 | + | // default; nil/missing means disabled. Toggle from /settings. | |||
| 33 | + | AutoSwarmEnabled *bool `json:"auto_swarm_enabled,omitempty"` | |||
| 34 | + | ||||
| 30 | // LastChangelogShown is the version whose release-notes | 35 | // LastChangelogShown is the version whose release-notes | ||
| 31 | // dialog the user has already seen. When the running binary's | 36 | // dialog the user has already seen. When the running binary's | ||
| 32 | // version differs, the next interactive run shows the | 37 | // version differs, the next interactive run shows the | ||
M internal/agent/modes/interactive.go +130 -13
| @@ -39,7 +39,18 @@ type InteractiveConfig struct { | |||
| 39 | 39 | // auto-detect and render when supported; false disables; true uses | |
| 40 | 40 | // the detected protocol when available. | |
| 41 | 41 | InlineImagesEnabled *bool | |
| 42 | - | SettingsStore SettingsStore | |
| 42 | + | ||
| 43 | + | // AutoSwarmEnabled mirrors the persisted config flag at startup so | |
| 44 | + | // the /settings dialog can render the current state without | |
| 45 | + | // re-reading config.json on every open. | |
| 46 | + | AutoSwarmEnabled *bool | |
| 47 | + | ||
| 48 | + | // AutoSwarmSystemAddendum is the system-prompt block that gets | |
| 49 | + | // appended/stripped when the user toggles auto-swarm at runtime. | |
| 50 | + | // Plumbed in from the cli so this package doesn't have to import | |
| 51 | + | // agent (cycle). | |
| 52 | + | AutoSwarmSystemAddendum string | |
| 53 | + | SettingsStore SettingsStore | |
| 43 | 54 | ||
| 44 | 55 | // Agent is optional. If nil, zot opens without credentials; the | |
| 45 | 56 | // user must /login before they can prompt. | |
| @@ -209,6 +220,7 @@ type chatCacheKey struct { | |||
| 209 | 220 | // SettingsStore persists user-toggleable settings surfaced by /settings. | |
| 210 | 221 | type SettingsStore interface { | |
| 211 | 222 | SetInlineImages(enabled bool) error | |
| 223 | + | SetAutoSwarm(enabled bool) error | |
| 212 | 224 | } | |
| 213 | 225 | ||
| 214 | 226 | type Interactive struct { | |
| @@ -2470,17 +2482,49 @@ func (i *Interactive) openSettingsDialog() { | |||
| 2470 | 2482 | } else { | |
| 2471 | 2483 | imgHint = "terminal supports " + imageProtocolName(detected) | |
| 2472 | 2484 | } | |
| 2473 | - | i.settingsDialog.Open([]settingsItem{{ | |
| 2474 | - | key: "inline_images_enabled", | |
| 2475 | - | label: "render images when supported", | |
| 2476 | - | desc: "draw screenshots inline instead of showing a text placeholder", | |
| 2477 | - | value: imgEnabled, | |
| 2478 | - | disabled: imgDisabled, | |
| 2479 | - | hint: imgHint, | |
| 2480 | - | }}) | |
| 2485 | + | ||
| 2486 | + | autoSwarm := false | |
| 2487 | + | if i.cfg.AutoSwarmEnabled != nil { | |
| 2488 | + | autoSwarm = *i.cfg.AutoSwarmEnabled | |
| 2489 | + | } | |
| 2490 | + | autoSwarmDisabled := i.cfg.Swarm == nil | |
| 2491 | + | autoSwarmHint := "" | |
| 2492 | + | if autoSwarmDisabled { | |
| 2493 | + | autoSwarm = false | |
| 2494 | + | autoSwarmHint = "swarm supervisor not available in this mode" | |
| 2495 | + | } | |
| 2496 | + | ||
| 2497 | + | i.settingsDialog.Open([]settingsItem{ | |
| 2498 | + | { | |
| 2499 | + | key: "inline_images_enabled", | |
| 2500 | + | label: "render images when supported", | |
| 2501 | + | desc: "draw screenshots inline instead of showing a text placeholder", | |
| 2502 | + | value: imgEnabled, | |
| 2503 | + | disabled: imgDisabled, | |
| 2504 | + | hint: imgHint, | |
| 2505 | + | }, | |
| 2506 | + | { | |
| 2507 | + | key: "auto_swarm_enabled", | |
| 2508 | + | label: "auto-swarm", | |
| 2509 | + | desc: "let the agent spawn background sub-agents in parallel via the swarm_spawn tool", | |
| 2510 | + | value: autoSwarm, | |
| 2511 | + | disabled: autoSwarmDisabled, | |
| 2512 | + | hint: autoSwarmHint, | |
| 2513 | + | }, | |
| 2514 | + | }) | |
| 2481 | 2515 | } | |
| 2482 | 2516 | ||
| 2483 | 2517 | func (i *Interactive) applySettingToggle(key string, value bool) { | |
| 2518 | + | // Every setting toggle forces a full repaint at the end — same | |
| 2519 | + | // effect as the user pressing Ctrl+L — so any per-setting visual | |
| 2520 | + | // change (image rendering, status copy, future toggles) lands | |
| 2521 | + | // immediately instead of waiting for the next diff frame. | |
| 2522 | + | defer func() { | |
| 2523 | + | if i.rend != nil { | |
| 2524 | + | i.rend.Clear() | |
| 2525 | + | } | |
| 2526 | + | i.invalidate() | |
| 2527 | + | }() | |
| 2484 | 2528 | switch key { | |
| 2485 | 2529 | case "inline_images_enabled": | |
| 2486 | 2530 | val := value | |
| @@ -2499,6 +2543,30 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||
| 2499 | 2543 | i.statusOK = "inline image rendering " + onOff(value) | |
| 2500 | 2544 | i.statusErr = "" | |
| 2501 | 2545 | i.mu.Unlock() | |
| 2546 | + | case "auto_swarm_enabled": | |
| 2547 | + | val := value | |
| 2548 | + | i.cfg.AutoSwarmEnabled = &val | |
| 2549 | + | if i.cfg.SettingsStore != nil { | |
| 2550 | + | if err := i.cfg.SettingsStore.SetAutoSwarm(value); err != nil { | |
| 2551 | + | i.mu.Lock() | |
| 2552 | + | i.statusErr = "settings: " + err.Error() | |
| 2553 | + | i.mu.Unlock() | |
| 2554 | + | return | |
| 2555 | + | } | |
| 2556 | + | } | |
| 2557 | + | // Add/remove the swarm_spawn tool on the live agent so the | |
| 2558 | + | // model's tools[] list reflects the toggle on the next turn. | |
| 2559 | + | // Without this the tool stays advertised after a disable and | |
| 2560 | + | // the model keeps trying to call it. | |
| 2561 | + | i.applyAutoSwarmTool(value) | |
| 2562 | + | // Also swap the system-prompt addendum in/out so the model | |
| 2563 | + | // knows to use the tool proactively (or stops referencing it | |
| 2564 | + | // after a disable). | |
| 2565 | + | i.applyAutoSwarmSystemPrompt(value) | |
| 2566 | + | i.mu.Lock() | |
| 2567 | + | i.statusOK = "auto-swarm " + onOff(value) | |
| 2568 | + | i.statusErr = "" | |
| 2569 | + | i.mu.Unlock() | |
| 2502 | 2570 | } | |
| 2503 | 2571 | } | |
| 2504 | 2572 | ||
| @@ -2790,10 +2858,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 2790 | 2858 | i.modelDialog.Open(i.cfg.Model, loggedIn) | |
| 2791 | 2859 | } | |
| 2792 | 2860 | case "/settings": | |
| 2793 | - | i.mu.Lock() | |
| 2794 | - | i.statusErr = "/settings is temporarily disabled" | |
| 2795 | - | i.statusOK = "" | |
| 2796 | - | i.mu.Unlock() | |
| 2861 | + | i.openSettingsDialog() | |
| 2797 | 2862 | case "/sessions": | |
| 2798 | 2863 | i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD) | |
| 2799 | 2864 | case "/jump": | |
| @@ -4353,6 +4418,58 @@ func (a telegramSenderAdapter) Active() bool { | |||
| 4353 | 4418 | return a.bridge != nil && a.bridge.Active() | |
| 4354 | 4419 | } | |
| 4355 | 4420 | ||
| 4421 | + | // applyAutoSwarmSystemPrompt appends (active=true) or strips | |
| 4422 | + | // (active=false) the auto-swarm system-prompt block on the running | |
| 4423 | + | // agent so the model proactively considers swarm_spawn when the user | |
| 4424 | + | // flips the toggle. The block lives at the tail of agent.System so | |
| 4425 | + | // stripping is a plain suffix-trim; idempotent in both directions. | |
| 4426 | + | func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) { | |
| 4427 | + | if i.agent == nil { | |
| 4428 | + | return | |
| 4429 | + | } | |
| 4430 | + | addendum := i.cfg.AutoSwarmSystemAddendum | |
| 4431 | + | if addendum == "" { | |
| 4432 | + | return | |
| 4433 | + | } | |
| 4434 | + | sys := i.agent.System | |
| 4435 | + | has := strings.Contains(sys, addendum) | |
| 4436 | + | switch { | |
| 4437 | + | case active && !has: | |
| 4438 | + | if sys != "" && !strings.HasSuffix(sys, "\n\n") { | |
| 4439 | + | sys += "\n\n" | |
| 4440 | + | } | |
| 4441 | + | i.agent.System = sys + addendum | |
| 4442 | + | case !active && has: | |
| 4443 | + | i.agent.System = strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n" | |
| 4444 | + | } | |
| 4445 | + | } | |
| 4446 | + | ||
| 4447 | + | // applyAutoSwarmTool registers (active=true) or removes (active=false) | |
| 4448 | + | // the swarm_spawn tool on the running agent so the model only sees it | |
| 4449 | + | // when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools' | |
| 4450 | + | // snapshot+mutate pattern so extension tools and /reload-ext additions | |
| 4451 | + | // survive a toggle. | |
| 4452 | + | func (i *Interactive) applyAutoSwarmTool(active bool) { | |
| 4453 | + | if i.agent == nil { | |
| 4454 | + | return | |
| 4455 | + | } | |
| 4456 | + | current := i.agent.Tools | |
| 4457 | + | next := core.Registry{} | |
| 4458 | + | for name, t := range current { | |
| 4459 | + | if name == "swarm_spawn" { | |
| 4460 | + | continue | |
| 4461 | + | } | |
| 4462 | + | next[name] = t | |
| 4463 | + | } | |
| 4464 | + | if active && i.cfg.Swarm != nil { | |
| 4465 | + | next["swarm_spawn"] = &tools.SwarmSpawnTool{ | |
| 4466 | + | Swarm: i.cfg.Swarm, | |
| 4467 | + | Enabled: func() bool { return true }, | |
| 4468 | + | } | |
| 4469 | + | } | |
| 4470 | + | i.agent.SetTools(next) | |
| 4471 | + | } | |
| 4472 | + | ||
| 4356 | 4473 | // applyTelegramTools registers (active=true) or removes (active=false) | |
| 4357 | 4474 | // the telegram_send_image and telegram_send_file tools on the running | |
| 4358 | 4475 | // agent so the model only sees them while the bridge is connected. | |
| @@ -39,7 +39,18 @@ type InteractiveConfig struct { | |||||
| 39 | // auto-detect and render when supported; false disables; true uses | 39 | // auto-detect and render when supported; false disables; true uses | ||
| 40 | // the detected protocol when available. | 40 | // the detected protocol when available. | ||
| 41 | InlineImagesEnabled *bool | 41 | InlineImagesEnabled *bool | ||
| 42 | - | SettingsStore SettingsStore | 42 | + | |
| 43 | + | // AutoSwarmEnabled mirrors the persisted config flag at startup so | |||
| 44 | + | // the /settings dialog can render the current state without | |||
| 45 | + | // re-reading config.json on every open. | |||
| 46 | + | AutoSwarmEnabled *bool | |||
| 47 | + | ||||
| 48 | + | // AutoSwarmSystemAddendum is the system-prompt block that gets | |||
| 49 | + | // appended/stripped when the user toggles auto-swarm at runtime. | |||
| 50 | + | // Plumbed in from the cli so this package doesn't have to import | |||
| 51 | + | // agent (cycle). | |||
| 52 | + | AutoSwarmSystemAddendum string | |||
| 53 | + | SettingsStore SettingsStore | |||
| 43 | 54 | ||||
| 44 | // Agent is optional. If nil, zot opens without credentials; the | 55 | // Agent is optional. If nil, zot opens without credentials; the | ||
| 45 | // user must /login before they can prompt. | 56 | // user must /login before they can prompt. | ||
| @@ -209,6 +220,7 @@ type chatCacheKey struct { | |||||
| 209 | // SettingsStore persists user-toggleable settings surfaced by /settings. | 220 | // SettingsStore persists user-toggleable settings surfaced by /settings. | ||
| 210 | type SettingsStore interface { | 221 | type SettingsStore interface { | ||
| 211 | SetInlineImages(enabled bool) error | 222 | SetInlineImages(enabled bool) error | ||
| 223 | + | SetAutoSwarm(enabled bool) error | |||
| 212 | } | 224 | } | ||
| 213 | 225 | ||||
| 214 | type Interactive struct { | 226 | type Interactive struct { | ||
| @@ -2470,17 +2482,49 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 2470 | } else { | 2482 | } else { | ||
| 2471 | imgHint = "terminal supports " + imageProtocolName(detected) | 2483 | imgHint = "terminal supports " + imageProtocolName(detected) | ||
| 2472 | } | 2484 | } | ||
| 2473 | - | i.settingsDialog.Open([]settingsItem{{ | 2485 | + | |
| 2474 | - | key: "inline_images_enabled", | 2486 | + | autoSwarm := false |
| 2475 | - | label: "render images when supported", | 2487 | + | if i.cfg.AutoSwarmEnabled != nil { |
| 2476 | - | desc: "draw screenshots inline instead of showing a text placeholder", | 2488 | + | autoSwarm = *i.cfg.AutoSwarmEnabled |
| 2477 | - | value: imgEnabled, | 2489 | + | } |
| 2478 | - | disabled: imgDisabled, | 2490 | + | autoSwarmDisabled := i.cfg.Swarm == nil |
| 2479 | - | hint: imgHint, | 2491 | + | autoSwarmHint := "" |
| 2480 | - | }}) | 2492 | + | if autoSwarmDisabled { |
| 2493 | + | autoSwarm = false | |||
| 2494 | + | autoSwarmHint = "swarm supervisor not available in this mode" | |||
| 2495 | + | } | |||
| 2496 | + | ||||
| 2497 | + | i.settingsDialog.Open([]settingsItem{ | |||
| 2498 | + | { | |||
| 2499 | + | key: "inline_images_enabled", | |||
| 2500 | + | label: "render images when supported", | |||
| 2501 | + | desc: "draw screenshots inline instead of showing a text placeholder", | |||
| 2502 | + | value: imgEnabled, | |||
| 2503 | + | disabled: imgDisabled, | |||
| 2504 | + | hint: imgHint, | |||
| 2505 | + | }, | |||
| 2506 | + | { | |||
| 2507 | + | key: "auto_swarm_enabled", | |||
| 2508 | + | label: "auto-swarm", | |||
| 2509 | + | desc: "let the agent spawn background sub-agents in parallel via the swarm_spawn tool", | |||
| 2510 | + | value: autoSwarm, | |||
| 2511 | + | disabled: autoSwarmDisabled, | |||
| 2512 | + | hint: autoSwarmHint, | |||
| 2513 | + | }, | |||
| 2514 | + | }) | |||
| 2481 | } | 2515 | } | ||
| 2482 | 2516 | ||||
| 2483 | func (i *Interactive) applySettingToggle(key string, value bool) { | 2517 | func (i *Interactive) applySettingToggle(key string, value bool) { | ||
| 2518 | + | // Every setting toggle forces a full repaint at the end — same | |||
| 2519 | + | // effect as the user pressing Ctrl+L — so any per-setting visual | |||
| 2520 | + | // change (image rendering, status copy, future toggles) lands | |||
| 2521 | + | // immediately instead of waiting for the next diff frame. | |||
| 2522 | + | defer func() { | |||
| 2523 | + | if i.rend != nil { | |||
| 2524 | + | i.rend.Clear() | |||
| 2525 | + | } | |||
| 2526 | + | i.invalidate() | |||
| 2527 | + | }() | |||
| 2484 | switch key { | 2528 | switch key { | ||
| 2485 | case "inline_images_enabled": | 2529 | case "inline_images_enabled": | ||
| 2486 | val := value | 2530 | val := value | ||
| @@ -2499,6 +2543,30 @@ func (i *Interactive) applySettingToggle(key string, value bool) { | |||||
| 2499 | i.statusOK = "inline image rendering " + onOff(value) | 2543 | i.statusOK = "inline image rendering " + onOff(value) | ||
| 2500 | i.statusErr = "" | 2544 | i.statusErr = "" | ||
| 2501 | i.mu.Unlock() | 2545 | i.mu.Unlock() | ||
| 2546 | + | case "auto_swarm_enabled": | |||
| 2547 | + | val := value | |||
| 2548 | + | i.cfg.AutoSwarmEnabled = &val | |||
| 2549 | + | if i.cfg.SettingsStore != nil { | |||
| 2550 | + | if err := i.cfg.SettingsStore.SetAutoSwarm(value); err != nil { | |||
| 2551 | + | i.mu.Lock() | |||
| 2552 | + | i.statusErr = "settings: " + err.Error() | |||
| 2553 | + | i.mu.Unlock() | |||
| 2554 | + | return | |||
| 2555 | + | } | |||
| 2556 | + | } | |||
| 2557 | + | // Add/remove the swarm_spawn tool on the live agent so the | |||
| 2558 | + | // model's tools[] list reflects the toggle on the next turn. | |||
| 2559 | + | // Without this the tool stays advertised after a disable and | |||
| 2560 | + | // the model keeps trying to call it. | |||
| 2561 | + | i.applyAutoSwarmTool(value) | |||
| 2562 | + | // Also swap the system-prompt addendum in/out so the model | |||
| 2563 | + | // knows to use the tool proactively (or stops referencing it | |||
| 2564 | + | // after a disable). | |||
| 2565 | + | i.applyAutoSwarmSystemPrompt(value) | |||
| 2566 | + | i.mu.Lock() | |||
| 2567 | + | i.statusOK = "auto-swarm " + onOff(value) | |||
| 2568 | + | i.statusErr = "" | |||
| 2569 | + | i.mu.Unlock() | |||
| 2502 | } | 2570 | } | ||
| 2503 | } | 2571 | } | ||
| 2504 | 2572 | ||||
| @@ -2790,10 +2858,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 2790 | i.modelDialog.Open(i.cfg.Model, loggedIn) | 2858 | i.modelDialog.Open(i.cfg.Model, loggedIn) | ||
| 2791 | } | 2859 | } | ||
| 2792 | case "/settings": | 2860 | case "/settings": | ||
| 2793 | - | i.mu.Lock() | 2861 | + | i.openSettingsDialog() |
| 2794 | - | i.statusErr = "/settings is temporarily disabled" | |||
| 2795 | - | i.statusOK = "" | |||
| 2796 | - | i.mu.Unlock() | |||
| 2797 | case "/sessions": | 2862 | case "/sessions": | ||
| 2798 | i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD) | 2863 | i.sessionDialog.Open(i.cfg.ZotHome, i.cfg.CWD) | ||
| 2799 | case "/jump": | 2864 | case "/jump": | ||
| @@ -4353,6 +4418,58 @@ func (a telegramSenderAdapter) Active() bool { | |||||
| 4353 | return a.bridge != nil && a.bridge.Active() | 4418 | return a.bridge != nil && a.bridge.Active() | ||
| 4354 | } | 4419 | } | ||
| 4355 | 4420 | ||||
| 4421 | + | // applyAutoSwarmSystemPrompt appends (active=true) or strips | |||
| 4422 | + | // (active=false) the auto-swarm system-prompt block on the running | |||
| 4423 | + | // agent so the model proactively considers swarm_spawn when the user | |||
| 4424 | + | // flips the toggle. The block lives at the tail of agent.System so | |||
| 4425 | + | // stripping is a plain suffix-trim; idempotent in both directions. | |||
| 4426 | + | func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) { | |||
| 4427 | + | if i.agent == nil { | |||
| 4428 | + | return | |||
| 4429 | + | } | |||
| 4430 | + | addendum := i.cfg.AutoSwarmSystemAddendum | |||
| 4431 | + | if addendum == "" { | |||
| 4432 | + | return | |||
| 4433 | + | } | |||
| 4434 | + | sys := i.agent.System | |||
| 4435 | + | has := strings.Contains(sys, addendum) | |||
| 4436 | + | switch { | |||
| 4437 | + | case active && !has: | |||
| 4438 | + | if sys != "" && !strings.HasSuffix(sys, "\n\n") { | |||
| 4439 | + | sys += "\n\n" | |||
| 4440 | + | } | |||
| 4441 | + | i.agent.System = sys + addendum | |||
| 4442 | + | case !active && has: | |||
| 4443 | + | i.agent.System = strings.TrimRight(strings.ReplaceAll(sys, addendum, ""), "\n") + "\n" | |||
| 4444 | + | } | |||
| 4445 | + | } | |||
| 4446 | + | ||||
| 4447 | + | // applyAutoSwarmTool registers (active=true) or removes (active=false) | |||
| 4448 | + | // the swarm_spawn tool on the running agent so the model only sees it | |||
| 4449 | + | // when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools' | |||
| 4450 | + | // snapshot+mutate pattern so extension tools and /reload-ext additions | |||
| 4451 | + | // survive a toggle. | |||
| 4452 | + | func (i *Interactive) applyAutoSwarmTool(active bool) { | |||
| 4453 | + | if i.agent == nil { | |||
| 4454 | + | return | |||
| 4455 | + | } | |||
| 4456 | + | current := i.agent.Tools | |||
| 4457 | + | next := core.Registry{} | |||
| 4458 | + | for name, t := range current { | |||
| 4459 | + | if name == "swarm_spawn" { | |||
| 4460 | + | continue | |||
| 4461 | + | } | |||
| 4462 | + | next[name] = t | |||
| 4463 | + | } | |||
| 4464 | + | if active && i.cfg.Swarm != nil { | |||
| 4465 | + | next["swarm_spawn"] = &tools.SwarmSpawnTool{ | |||
| 4466 | + | Swarm: i.cfg.Swarm, | |||
| 4467 | + | Enabled: func() bool { return true }, | |||
| 4468 | + | } | |||
| 4469 | + | } | |||
| 4470 | + | i.agent.SetTools(next) | |||
| 4471 | + | } | |||
| 4472 | + | ||||
| 4356 | // applyTelegramTools registers (active=true) or removes (active=false) | 4473 | // applyTelegramTools registers (active=true) or removes (active=false) | ||
| 4357 | // the telegram_send_image and telegram_send_file tools on the running | 4474 | // the telegram_send_image and telegram_send_file tools on the running | ||
| 4358 | // agent so the model only sees them while the bridge is connected. | 4475 | // agent so the model only sees them while the bridge is connected. | ||
M internal/agent/modes/slash_suggest.go +1 -0
| @@ -50,6 +50,7 @@ var slashCatalog = []slashCommand{ | |||
| 50 | 50 | {Name: "/swarm", Desc: "supervise background agents that share this working directory"}, | |
| 51 | 51 | {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"}, | |
| 52 | 52 | {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"}, | |
| 53 | + | {Name: "/settings", Desc: "open settings"}, | |
| 53 | 54 | {Name: "/clear", Desc: "clear the chat transcript"}, | |
| 54 | 55 | {Name: "/exit", Desc: "exit zot"}, | |
| 55 | 56 | } | |
| @@ -50,6 +50,7 @@ var slashCatalog = []slashCommand{ | |||||
| 50 | {Name: "/swarm", Desc: "supervise background agents that share this working directory"}, | 50 | {Name: "/swarm", Desc: "supervise background agents that share this working directory"}, | ||
| 51 | {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"}, | 51 | {Name: "/reload-ext", Desc: "hot-reload all extensions (re-read manifests and respawn)"}, | ||
| 52 | {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"}, | 52 | {Name: "/telegram", Desc: "connect, disconnect, or show status of the telegram bridge"}, | ||
| 53 | + | {Name: "/settings", Desc: "open settings"}, | |||
| 53 | {Name: "/clear", Desc: "clear the chat transcript"}, | 54 | {Name: "/clear", Desc: "clear the chat transcript"}, | ||
| 54 | {Name: "/exit", Desc: "exit zot"}, | 55 | {Name: "/exit", Desc: "exit zot"}, | ||
| 55 | } | 56 | } | ||
M internal/agent/settings_store.go +29 -0
| @@ -10,3 +10,32 @@ func (configSettingsStore) SetInlineImages(enabled bool) error { | |||
| 10 | 10 | cfg.InlineImagesEnabled = &enabled | |
| 11 | 11 | return SaveConfig(cfg) | |
| 12 | 12 | } | |
| 13 | + | ||
| 14 | + | func (configSettingsStore) SetAutoSwarm(enabled bool) error { | |
| 15 | + | cfg, err := LoadConfig() | |
| 16 | + | if err != nil { | |
| 17 | + | return err | |
| 18 | + | } | |
| 19 | + | cfg.AutoSwarmEnabled = &enabled | |
| 20 | + | return SaveConfig(cfg) | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // AutoSwarmEnabled reads the current auto-swarm flag from config. | |
| 24 | + | // Used by the swarm_spawn tool at call time to gate execution. | |
| 25 | + | func AutoSwarmEnabled() bool { | |
| 26 | + | cfg, err := LoadConfig() | |
| 27 | + | if err != nil { | |
| 28 | + | return false | |
| 29 | + | } | |
| 30 | + | return cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // AutoSwarmSystemAddendum is appended to the system prompt when | |
| 34 | + | // auto-swarm is enabled, so the model knows it may delegate to | |
| 35 | + | // background sub-agents without the user having to mention the tool | |
| 36 | + | // by name. Kept short so it doesn't bloat the cached prompt prefix. | |
| 37 | + | const AutoSwarmSystemAddendum = `Auto-swarm is enabled. You have a swarm_spawn tool that forks background sub-agents working in parallel in this same working directory. | |
| 38 | + | ||
| 39 | + | Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing. | |
| 40 | + | ||
| 41 | + | Do NOT use swarm_spawn for trivial single-step work, for tasks that depend on each other sequentially, or when the user explicitly asked you to do the work yourself.` | |
| @@ -10,3 +10,32 @@ func (configSettingsStore) SetInlineImages(enabled bool) error { | |||||
| 10 | cfg.InlineImagesEnabled = &enabled | 10 | cfg.InlineImagesEnabled = &enabled | ||
| 11 | return SaveConfig(cfg) | 11 | return SaveConfig(cfg) | ||
| 12 | } | 12 | } | ||
| 13 | + | ||||
| 14 | + | func (configSettingsStore) SetAutoSwarm(enabled bool) error { | |||
| 15 | + | cfg, err := LoadConfig() | |||
| 16 | + | if err != nil { | |||
| 17 | + | return err | |||
| 18 | + | } | |||
| 19 | + | cfg.AutoSwarmEnabled = &enabled | |||
| 20 | + | return SaveConfig(cfg) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // AutoSwarmEnabled reads the current auto-swarm flag from config. | |||
| 24 | + | // Used by the swarm_spawn tool at call time to gate execution. | |||
| 25 | + | func AutoSwarmEnabled() bool { | |||
| 26 | + | cfg, err := LoadConfig() | |||
| 27 | + | if err != nil { | |||
| 28 | + | return false | |||
| 29 | + | } | |||
| 30 | + | return cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // AutoSwarmSystemAddendum is appended to the system prompt when | |||
| 34 | + | // auto-swarm is enabled, so the model knows it may delegate to | |||
| 35 | + | // background sub-agents without the user having to mention the tool | |||
| 36 | + | // by name. Kept short so it doesn't bloat the cached prompt prefix. | |||
| 37 | + | const AutoSwarmSystemAddendum = `Auto-swarm is enabled. You have a swarm_spawn tool that forks background sub-agents working in parallel in this same working directory. | |||
| 38 | + | ||||
| 39 | + | Use it proactively when the user's request naturally splits into independent sub-tasks that can run concurrently (e.g. "refactor module A and module B", "write the implementation and the tests", "investigate three separate files"). Spawn one sub-agent per independent sub-task with a self-contained task description (sub-agents start with no context from this conversation). Continue working on the remaining or coordinating work yourself in parallel; do not wait for sub-agents to finish before responding. Briefly tell the user which sub-agents you spawned and what each is doing. | |||
| 40 | + | ||||
| 41 | + | Do NOT use swarm_spawn for trivial single-step work, for tasks that depend on each other sequentially, or when the user explicitly asked you to do the work yourself.` | |||
A internal/agent/tools/swarm_spawn.go +125 -0
| @@ -0,0 +1,125 @@ | |||
| 1 | + | package tools | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/patriceckhart/zot/internal/core" | |
| 10 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 11 | + | "github.com/patriceckhart/zot/internal/swarm" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // SwarmSpawnTool lets the main agent fork a background sub-agent | |
| 15 | + | // against the host's cwd via swarm.Swarm.SpawnReq. The sub-agent runs | |
| 16 | + | // in parallel: the tool returns the agent id immediately and the main | |
| 17 | + | // turn continues uninterrupted. The user can monitor / chat with the | |
| 18 | + | // spawned agent via /swarm. | |
| 19 | + | // | |
| 20 | + | // Gated by the auto_swarm_enabled config flag at call time so a user | |
| 21 | + | // can flip it off mid-session and the next call refuses cleanly | |
| 22 | + | // without re-registering the tool. | |
| 23 | + | type SwarmSpawnTool struct { | |
| 24 | + | // Swarm is the supervisor used to spawn agents. Nil means | |
| 25 | + | // "auto-swarm not available in this mode" and the tool always | |
| 26 | + | // errors. | |
| 27 | + | Swarm *swarm.Swarm | |
| 28 | + | ||
| 29 | + | // Enabled reads the live config flag. Lets users toggle from | |
| 30 | + | // /settings without rebuilding the agent. When nil, the tool | |
| 31 | + | // is treated as disabled. | |
| 32 | + | Enabled func() bool | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | type swarmSpawnArgs struct { | |
| 36 | + | Task string `json:"task"` | |
| 37 | + | Model string `json:"model,omitempty"` | |
| 38 | + | Provider string `json:"provider,omitempty"` | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | const swarmSpawnSchema = `{ | |
| 42 | + | "type": "object", | |
| 43 | + | "properties": { | |
| 44 | + | "task": { | |
| 45 | + | "type": "string", | |
| 46 | + | "description": "The full task description for the sub-agent. Be specific: the sub-agent has the same tools (read/write/edit/bash) and shares this working directory, but starts with NO context from this conversation." | |
| 47 | + | }, | |
| 48 | + | "model": { | |
| 49 | + | "type": "string", | |
| 50 | + | "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." | |
| 51 | + | }, | |
| 52 | + | "provider": { | |
| 53 | + | "type": "string", | |
| 54 | + | "description": "Optional provider id (e.g. \"anthropic\", \"openai\"). Usually paired with model." | |
| 55 | + | } | |
| 56 | + | }, | |
| 57 | + | "required": ["task"] | |
| 58 | + | }` | |
| 59 | + | ||
| 60 | + | func (t *SwarmSpawnTool) Name() string { return "swarm_spawn" } | |
| 61 | + | func (t *SwarmSpawnTool) Description() string { | |
| 62 | + | return "Spawn a background sub-agent to work on a parallel sub-task. Returns the sub-agent id immediately; the sub-agent keeps running while this conversation continues. Useful for splitting independent work (write tests while implementing a feature, refactor module A while drafting module B). The sub-agent shares this working directory and has the same tools." | |
| 63 | + | } | |
| 64 | + | func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) } | |
| 65 | + | ||
| 66 | + | func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |
| 67 | + | if t.Swarm == nil { | |
| 68 | + | return toolErr("swarm_spawn: swarm supervisor not available in this mode"), nil | |
| 69 | + | } | |
| 70 | + | if t.Enabled == nil || !t.Enabled() { | |
| 71 | + | return toolErr("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks."), nil | |
| 72 | + | } | |
| 73 | + | var a swarmSpawnArgs | |
| 74 | + | if err := json.Unmarshal(raw, &a); err != nil { | |
| 75 | + | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | |
| 76 | + | } | |
| 77 | + | task := strings.TrimSpace(a.Task) | |
| 78 | + | if task == "" { | |
| 79 | + | return toolErr("swarm_spawn: task is required"), nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | |
| 83 | + | Task: task, | |
| 84 | + | Model: strings.TrimSpace(a.Model), | |
| 85 | + | Provider: strings.TrimSpace(a.Provider), | |
| 86 | + | }) | |
| 87 | + | if err != nil { | |
| 88 | + | return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | var sb strings.Builder | |
| 92 | + | fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID) | |
| 93 | + | fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200)) | |
| 94 | + | if a.Model != "" { | |
| 95 | + | fmt.Fprintf(&sb, "model: %s\n", a.Model) | |
| 96 | + | } | |
| 97 | + | if a.Provider != "" { | |
| 98 | + | fmt.Fprintf(&sb, "provider: %s\n", a.Provider) | |
| 99 | + | } | |
| 100 | + | sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ") | |
| 101 | + | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | |
| 102 | + | return core.ToolResult{ | |
| 103 | + | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | |
| 104 | + | Details: map[string]any{ | |
| 105 | + | "agent_id": agent.ID, | |
| 106 | + | "task": task, | |
| 107 | + | "model": a.Model, | |
| 108 | + | "provider": a.Provider, | |
| 109 | + | }, | |
| 110 | + | }, nil | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func toolErr(msg string) core.ToolResult { | |
| 114 | + | return core.ToolResult{ | |
| 115 | + | Content: []provider.Content{provider.TextBlock{Text: msg}}, | |
| 116 | + | IsError: true, | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func truncateTask(s string, n int) string { | |
| 121 | + | if len(s) <= n { | |
| 122 | + | return s | |
| 123 | + | } | |
| 124 | + | return s[:n-3] + "..." | |
| 125 | + | } | |
| @@ -0,0 +1,125 @@ | |||||
| 1 | + | package tools | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "github.com/patriceckhart/zot/internal/core" | |||
| 10 | + | "github.com/patriceckhart/zot/internal/provider" | |||
| 11 | + | "github.com/patriceckhart/zot/internal/swarm" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // SwarmSpawnTool lets the main agent fork a background sub-agent | |||
| 15 | + | // against the host's cwd via swarm.Swarm.SpawnReq. The sub-agent runs | |||
| 16 | + | // in parallel: the tool returns the agent id immediately and the main | |||
| 17 | + | // turn continues uninterrupted. The user can monitor / chat with the | |||
| 18 | + | // spawned agent via /swarm. | |||
| 19 | + | // | |||
| 20 | + | // Gated by the auto_swarm_enabled config flag at call time so a user | |||
| 21 | + | // can flip it off mid-session and the next call refuses cleanly | |||
| 22 | + | // without re-registering the tool. | |||
| 23 | + | type SwarmSpawnTool struct { | |||
| 24 | + | // Swarm is the supervisor used to spawn agents. Nil means | |||
| 25 | + | // "auto-swarm not available in this mode" and the tool always | |||
| 26 | + | // errors. | |||
| 27 | + | Swarm *swarm.Swarm | |||
| 28 | + | ||||
| 29 | + | // Enabled reads the live config flag. Lets users toggle from | |||
| 30 | + | // /settings without rebuilding the agent. When nil, the tool | |||
| 31 | + | // is treated as disabled. | |||
| 32 | + | Enabled func() bool | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | type swarmSpawnArgs struct { | |||
| 36 | + | Task string `json:"task"` | |||
| 37 | + | Model string `json:"model,omitempty"` | |||
| 38 | + | Provider string `json:"provider,omitempty"` | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | const swarmSpawnSchema = `{ | |||
| 42 | + | "type": "object", | |||
| 43 | + | "properties": { | |||
| 44 | + | "task": { | |||
| 45 | + | "type": "string", | |||
| 46 | + | "description": "The full task description for the sub-agent. Be specific: the sub-agent has the same tools (read/write/edit/bash) and shares this working directory, but starts with NO context from this conversation." | |||
| 47 | + | }, | |||
| 48 | + | "model": { | |||
| 49 | + | "type": "string", | |||
| 50 | + | "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." | |||
| 51 | + | }, | |||
| 52 | + | "provider": { | |||
| 53 | + | "type": "string", | |||
| 54 | + | "description": "Optional provider id (e.g. \"anthropic\", \"openai\"). Usually paired with model." | |||
| 55 | + | } | |||
| 56 | + | }, | |||
| 57 | + | "required": ["task"] | |||
| 58 | + | }` | |||
| 59 | + | ||||
| 60 | + | func (t *SwarmSpawnTool) Name() string { return "swarm_spawn" } | |||
| 61 | + | func (t *SwarmSpawnTool) Description() string { | |||
| 62 | + | return "Spawn a background sub-agent to work on a parallel sub-task. Returns the sub-agent id immediately; the sub-agent keeps running while this conversation continues. Useful for splitting independent work (write tests while implementing a feature, refactor module A while drafting module B). The sub-agent shares this working directory and has the same tools." | |||
| 63 | + | } | |||
| 64 | + | func (t *SwarmSpawnTool) Schema() json.RawMessage { return json.RawMessage(swarmSpawnSchema) } | |||
| 65 | + | ||||
| 66 | + | func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progress func(string)) (core.ToolResult, error) { | |||
| 67 | + | if t.Swarm == nil { | |||
| 68 | + | return toolErr("swarm_spawn: swarm supervisor not available in this mode"), nil | |||
| 69 | + | } | |||
| 70 | + | if t.Enabled == nil || !t.Enabled() { | |||
| 71 | + | return toolErr("swarm_spawn: auto-swarm is disabled. Ask the user to enable it from /settings before delegating sub-tasks."), nil | |||
| 72 | + | } | |||
| 73 | + | var a swarmSpawnArgs | |||
| 74 | + | if err := json.Unmarshal(raw, &a); err != nil { | |||
| 75 | + | return core.ToolResult{}, fmt.Errorf("invalid args: %w", err) | |||
| 76 | + | } | |||
| 77 | + | task := strings.TrimSpace(a.Task) | |||
| 78 | + | if task == "" { | |||
| 79 | + | return toolErr("swarm_spawn: task is required"), nil | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ | |||
| 83 | + | Task: task, | |||
| 84 | + | Model: strings.TrimSpace(a.Model), | |||
| 85 | + | Provider: strings.TrimSpace(a.Provider), | |||
| 86 | + | }) | |||
| 87 | + | if err != nil { | |||
| 88 | + | return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | var sb strings.Builder | |||
| 92 | + | fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID) | |||
| 93 | + | fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200)) | |||
| 94 | + | if a.Model != "" { | |||
| 95 | + | fmt.Fprintf(&sb, "model: %s\n", a.Model) | |||
| 96 | + | } | |||
| 97 | + | if a.Provider != "" { | |||
| 98 | + | fmt.Fprintf(&sb, "provider: %s\n", a.Provider) | |||
| 99 | + | } | |||
| 100 | + | sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ") | |||
| 101 | + | sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") | |||
| 102 | + | return core.ToolResult{ | |||
| 103 | + | Content: []provider.Content{provider.TextBlock{Text: sb.String()}}, | |||
| 104 | + | Details: map[string]any{ | |||
| 105 | + | "agent_id": agent.ID, | |||
| 106 | + | "task": task, | |||
| 107 | + | "model": a.Model, | |||
| 108 | + | "provider": a.Provider, | |||
| 109 | + | }, | |||
| 110 | + | }, nil | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func toolErr(msg string) core.ToolResult { | |||
| 114 | + | return core.ToolResult{ | |||
| 115 | + | Content: []provider.Content{provider.TextBlock{Text: msg}}, | |||
| 116 | + | IsError: true, | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func truncateTask(s string, n int) string { | |||
| 121 | + | if len(s) <= n { | |||
| 122 | + | return s | |||
| 123 | + | } | |||
| 124 | + | return s[:n-3] + "..." | |||
| 125 | + | } | |||