bash.go

  1package claudetool
  2
  3import (
  4	"bytes"
  5	"context"
  6	"encoding/json"
  7	"fmt"
  8	"io"
  9	"log/slog"
 10	"math"
 11	"os"
 12	"os/exec"
 13	"path/filepath"
 14	"slices"
 15	"strings"
 16	"sync"
 17	"syscall"
 18	"time"
 19
 20	"shelley.exe.dev/claudetool/bashkit"
 21	"shelley.exe.dev/llm"
 22)
 23
 24// PermissionCallback is a function type for checking if a command is allowed to run
 25type PermissionCallback func(command string) error
 26
 27// BashTool specifies an llm.Tool for executing shell commands.
 28type BashTool struct {
 29	// CheckPermission is called before running any command, if set
 30	CheckPermission PermissionCallback
 31	// EnableJITInstall enables just-in-time tool installation for missing commands
 32	EnableJITInstall bool
 33	// Timeouts holds the configurable timeout values (uses defaults if nil)
 34	Timeouts *Timeouts
 35	// WorkingDir is the shared mutable working directory.
 36	WorkingDir *MutableWorkingDir
 37	// LLMProvider provides access to LLM services for tool validation
 38	LLMProvider LLMServiceProvider
 39	// ConversationID is the ID of the conversation this tool belongs to.
 40	// It is exposed to invoked commands via SHELLEY_CONVERSATION_ID.
 41	ConversationID string
 42}
 43
 44const (
 45	EnableBashToolJITInstall = true
 46	NoBashToolJITInstall     = false
 47
 48	DefaultFastTimeout       = 30 * time.Second
 49	DefaultSlowTimeout       = 15 * time.Minute
 50	DefaultBackgroundTimeout = 24 * time.Hour
 51)
 52
 53// Timeouts holds the configurable timeout values for bash commands.
 54type Timeouts struct {
 55	Fast       time.Duration // regular commands (e.g., ls, echo, simple scripts)
 56	Slow       time.Duration // commands that may reasonably take longer (e.g., downloads, builds, tests)
 57	Background time.Duration // background commands (e.g., servers, long-running processes)
 58}
 59
 60// Fast returns t's fast timeout, or DefaultFastTimeout if t is nil.
 61func (t *Timeouts) fast() time.Duration {
 62	if t == nil {
 63		return DefaultFastTimeout
 64	}
 65	return t.Fast
 66}
 67
 68// Slow returns t's slow timeout, or DefaultSlowTimeout if t is nil.
 69func (t *Timeouts) slow() time.Duration {
 70	if t == nil {
 71		return DefaultSlowTimeout
 72	}
 73	return t.Slow
 74}
 75
 76// Background returns t's background timeout, or DefaultBackgroundTimeout if t is nil.
 77func (t *Timeouts) background() time.Duration {
 78	if t == nil {
 79		return DefaultBackgroundTimeout
 80	}
 81	return t.Background
 82}
 83
 84// Tool returns an llm.Tool based on b.
 85func (b *BashTool) Tool() *llm.Tool {
 86	return &llm.Tool{
 87		Name:        bashName,
 88		Description: strings.TrimSpace(bashDescription),
 89		InputSchema: llm.MustSchema(bashInputSchema),
 90		Run:         b.Run,
 91	}
 92}
 93
 94// getWorkingDir returns the current working directory.
 95func (b *BashTool) getWorkingDir() string {
 96	return b.WorkingDir.Get()
 97}
 98
 99// isNoTrailerSet checks if user has disabled co-author trailer via git config.
100func (b *BashTool) isNoTrailerSet() bool {
101	out, err := exec.Command("git", "config", "--get", "shelley.no-trailer").Output()
102	if err != nil {
103		return false
104	}
105	return strings.TrimSpace(string(out)) == "true"
106}
107
108const (
109	bashName        = "bash"
110	bashDescription = `Executes shell commands via bash -c, returning combined stdout/stderr.
111Bash state changes (working dir, variables, aliases) don't persist between calls.
112
113With background=true, returns immediately, with output redirected to a file.
114Use background for servers/demos that need to stay running.
115
116MUST set slow_ok=true for potentially slow commands: builds, downloads,
117installs, tests, or any other substantive operation.
118
119Avoid overly destructive cleanup commands. Commands that could delete .git
120directories, home directories, or use broad wildcards require explicit paths.
121Confirm with the user before running destructive operations.
122
123To change the working directory persistently, use the change_dir tool.
124
125IMPORTANT: Keep commands concise. The command input must be less than 60k tokens.
126For complex scripts, write them to a file first and then execute the file.
127`
128	// If you modify this, update the termui template for prettier rendering.
129	bashInputSchema = `
130{
131  "type": "object",
132  "required": ["command"],
133  "properties": {
134    "command": {
135      "type": "string",
136      "description": "Shell to execute"
137    },
138    "slow_ok": {
139      "type": "boolean",
140      "description": "Use extended timeout"
141    },
142    "background": {
143      "type": "boolean",
144      "description": "Execute in background"
145    }
146  }
147}
148`
149)
150
151type bashInput struct {
152	Command    string `json:"command"`
153	SlowOK     bool   `json:"slow_ok,omitempty"`
154	Background bool   `json:"background,omitempty"`
155}
156
157// BashDisplayData is the display data sent to the UI for bash tool results.
158type BashDisplayData struct {
159	WorkingDir string `json:"workingDir"`
160}
161
162type BackgroundResult struct {
163	PID     int
164	OutFile string
165}
166
167func (r *BackgroundResult) XMLish() string {
168	return fmt.Sprintf("<pid>%d</pid>\n<output_file>%s</output_file>\n<reminder>To stop the process: `kill -9 -%d`</reminder>\n",
169		r.PID, r.OutFile, r.PID)
170}
171
172func (i *bashInput) timeout(t *Timeouts) time.Duration {
173	switch {
174	case i.Background:
175		return t.background()
176	case i.SlowOK:
177		return t.slow()
178	default:
179		return t.fast()
180	}
181}
182
183func (b *BashTool) Run(ctx context.Context, m json.RawMessage) llm.ToolOut {
184	var req bashInput
185	if err := json.Unmarshal(m, &req); err != nil {
186		return llm.ErrorfToolOut("failed to unmarshal bash command input: %w", err)
187	}
188
189	// Check that the working directory exists
190	wd := b.getWorkingDir()
191	if _, err := os.Stat(wd); err != nil {
192		if os.IsNotExist(err) {
193			return llm.ErrorfToolOut("working directory does not exist: %s (use change_dir to switch to a valid directory)", wd)
194		}
195		return llm.ErrorfToolOut("cannot access working directory %s: %w", wd, err)
196	}
197
198	// do a quick permissions check (NOT a security barrier)
199	err := bashkit.Check(req.Command)
200	if err != nil {
201		return llm.ErrorToolOut(err)
202	}
203
204	// Custom permission callback if set
205	if b.CheckPermission != nil {
206		if err := b.CheckPermission(req.Command); err != nil {
207			return llm.ErrorToolOut(err)
208		}
209	}
210
211	// Check for missing tools and try to install them if needed, best effort only
212	if b.EnableJITInstall {
213		err := b.checkAndInstallMissingTools(ctx, req.Command)
214		if err != nil {
215			slog.DebugContext(ctx, "failed to auto-install missing tools", "error", err)
216		}
217	}
218
219	// Add co-author trailer to git commits unless user has disabled it
220	if !b.isNoTrailerSet() {
221		req.Command = bashkit.AddCoauthorTrailer(req.Command, "Co-authored-by: Shelley <shelley@exe.dev>")
222	}
223
224	timeout := req.timeout(b.Timeouts)
225
226	display := BashDisplayData{WorkingDir: wd}
227
228	// If Background is set to true, use executeBackgroundBash
229	if req.Background {
230		result, err := b.executeBackgroundBash(ctx, req, timeout)
231		if err != nil {
232			return llm.ErrorToolOut(err)
233		}
234		return llm.ToolOut{LLMContent: llm.TextContent(result.XMLish()), Display: display}
235	}
236
237	// For foreground commands, use executeBash
238	out, execErr := b.executeBash(ctx, req, timeout)
239	if execErr != nil {
240		return llm.ErrorToolOut(execErr)
241	}
242	return llm.ToolOut{LLMContent: llm.TextContent(out), Display: display}
243}
244
245const (
246	largeOutputThreshold = 50 * 1024 // 50KB - threshold for saving to file
247	firstLinesCount      = 2
248	lastLinesCount       = 5
249	maxLineLength        = 200 // truncate displayed lines to this length
250)
251
252func (b *BashTool) makeBashCommand(ctx context.Context, command string, out io.Writer) *exec.Cmd {
253	cmd := exec.CommandContext(ctx, "bash", "-c", command)
254	// Use shared WorkingDir if available, then context, then Pwd fallback
255	cmd.Dir = b.getWorkingDir()
256	cmd.Stdin = nil
257	cmd.Stdout = out
258	cmd.Stderr = out
259	cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} // set up for killing the process group
260	cmd.Cancel = func() error {
261		if cmd.Process == nil {
262			// Process hasn't started yet.
263			// Not sure whether this is possible in practice,
264			// but it is possible in theory, and it doesn't hurt to handle it gracefully.
265			return nil
266		}
267		return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // kill entire process group
268	}
269	cmd.WaitDelay = 15 * time.Second // prevent indefinite hangs when child processes keep pipes open
270	// Remove SKETCH_MODEL_URL, SKETCH_PUB_KEY, SKETCH_MODEL_API_KEY,
271	// and any other future SKETCH_ goodies from the environment.
272	// ...except for SKETCH_PROXY_ID, which is intentionally available.
273	env := slices.DeleteFunc(os.Environ(), func(s string) bool {
274		return strings.HasPrefix(s, "SKETCH_") && s != "SKETCH_PROXY_ID"
275	})
276	env = append(env, "SKETCH=1")          // signal that this has been run by Sketch, sometimes useful for scripts
277	env = append(env, "EDITOR=/bin/false") // interactive editors won't work
278	if b.ConversationID != "" {
279		env = append(env, "SHELLEY_CONVERSATION_ID="+b.ConversationID)
280	}
281	cmd.Env = env
282	return cmd
283}
284
285func cmdWait(cmd *exec.Cmd) error {
286	err := cmd.Wait()
287	// We used to kill the process group here, but it's not clear that
288	// this is correct in the case of self-daemonizing processes,
289	// and I encountered issues where daemons that I tried to run
290	// as background tasks would mysteriously exit.
291	return err
292}
293
294func (b *BashTool) executeBash(ctx context.Context, req bashInput, timeout time.Duration) (string, error) {
295	execCtx, cancel := context.WithTimeout(ctx, timeout)
296	defer cancel()
297
298	output := new(bytes.Buffer)
299	cmd := b.makeBashCommand(execCtx, req.Command, output)
300	// TODO: maybe detect simple interactive git rebase commands and auto-background them?
301	// Would need to hint to the agent what is happening.
302	// We might also be able to do this for other simple interactive commands that use EDITOR.
303	cmd.Env = append(cmd.Env, `GIT_SEQUENCE_EDITOR=echo "To do an interactive rebase, run it as a background task and check the output file." && exit 1`)
304	if err := cmd.Start(); err != nil {
305		return "", fmt.Errorf("command failed: %w", err)
306	}
307
308	err := cmdWait(cmd)
309
310	out, formatErr := formatForegroundBashOutput(output.String())
311	if formatErr != nil {
312		return "", formatErr
313	}
314
315	if execCtx.Err() == context.DeadlineExceeded {
316		return "", fmt.Errorf("[command timed out after %s, showing output until timeout]\n%s", timeout, out)
317	}
318	if err != nil {
319		return "", fmt.Errorf("[command failed: %w]\n%s", err, out)
320	}
321
322	return out, nil
323}
324
325// formatForegroundBashOutput formats the output of a foreground bash command for display to the agent.
326// If output exceeds largeOutputThreshold, it saves to a file and returns a summary.
327func formatForegroundBashOutput(out string) (string, error) {
328	if len(out) <= largeOutputThreshold {
329		return out, nil
330	}
331
332	// Save full output to a temp file
333	tmpDir, err := os.MkdirTemp("", "shelley-output-")
334	if err != nil {
335		return "", fmt.Errorf("failed to create temp dir for large output: %w", err)
336	}
337
338	outFile := filepath.Join(tmpDir, "output")
339	if err := os.WriteFile(outFile, []byte(out), 0o644); err != nil {
340		os.RemoveAll(tmpDir)
341		return "", fmt.Errorf("failed to write large output to file: %w", err)
342	}
343
344	// Split into lines
345	lines := strings.Split(out, "\n")
346
347	// If fewer than 3 lines total, likely binary or single-line output
348	if len(lines) < 3 {
349		return fmt.Sprintf("[output too large (%s, %d lines), saved to: %s]",
350			humanizeBytes(len(out)), len(lines), outFile), nil
351	}
352
353	var result strings.Builder
354	result.WriteString(fmt.Sprintf("[output too large (%s, %d lines), saved to: %s]\n\n",
355		humanizeBytes(len(out)), len(lines), outFile))
356
357	// First N lines
358	result.WriteString("First lines:\n")
359	firstN := min(firstLinesCount, len(lines))
360	for i := 0; i < firstN; i++ {
361		result.WriteString(fmt.Sprintf("%5d: %s\n", i+1, truncateLine(lines[i])))
362	}
363
364	// Last N lines
365	result.WriteString("\n...\n\nLast lines:\n")
366	startIdx := max(0, len(lines)-lastLinesCount)
367	for i := startIdx; i < len(lines); i++ {
368		result.WriteString(fmt.Sprintf("%5d: %s\n", i+1, truncateLine(lines[i])))
369	}
370
371	return result.String(), nil
372}
373
374// truncateLine truncates a line to maxLineLength characters, appending "..." if truncated.
375func truncateLine(line string) string {
376	if len(line) <= maxLineLength {
377		return line
378	}
379	return line[:maxLineLength] + "..."
380}
381
382func humanizeBytes(bytes int) string {
383	switch {
384	case bytes < 4*1024:
385		return fmt.Sprintf("%dB", bytes)
386	case bytes < 1024*1024:
387		kb := int(math.Round(float64(bytes) / 1024.0))
388		return fmt.Sprintf("%dkB", kb)
389	case bytes < 1024*1024*1024:
390		mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
391		return fmt.Sprintf("%dMB", mb)
392	}
393	return "more than 1GB"
394}
395
396// executeBackgroundBash executes a command in the background and returns the pid and output file locations
397func (b *BashTool) executeBackgroundBash(ctx context.Context, req bashInput, timeout time.Duration) (*BackgroundResult, error) {
398	// Create temp output files
399	tmpDir, err := os.MkdirTemp("", "sketch-bg-")
400	if err != nil {
401		return nil, fmt.Errorf("failed to create temp directory: %w", err)
402	}
403	// We can't really clean up tempDir, because we have no idea
404	// how far into the future the agent might want to read the output.
405
406	outFile := filepath.Join(tmpDir, "output")
407	out, err := os.Create(outFile)
408	if err != nil {
409		return nil, fmt.Errorf("failed to create output file: %w", err)
410	}
411
412	execCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout) // detach from tool use context
413	cmd := b.makeBashCommand(execCtx, req.Command, out)
414	cmd.Env = append(cmd.Env, `GIT_SEQUENCE_EDITOR=python3 -c "import os, sys, signal, threading; print(f\"Send USR1 to pid {os.getpid()} after editing {sys.argv[1]}\", flush=True); signal.signal(signal.SIGUSR1, lambda *_: sys.exit(0)); threading.Event().wait()"`)
415
416	if err := cmd.Start(); err != nil {
417		cancel()
418		out.Close()
419		os.RemoveAll(tmpDir) // clean up temp dir -- didn't start means we don't need the output
420		return nil, fmt.Errorf("failed to start background command: %w", err)
421	}
422
423	// Wait for completion in the background, then do cleanup.
424	go func() {
425		err := cmdWait(cmd)
426		// Leave a note to the agent so that it knows that the process has finished.
427		if err != nil {
428			fmt.Fprintf(out, "\n\n[background process failed: %v]\n", err)
429		} else {
430			fmt.Fprintf(out, "\n\n[background process completed]\n")
431		}
432		out.Close()
433		cancel()
434	}()
435
436	return &BackgroundResult{
437		PID:     cmd.Process.Pid,
438		OutFile: outFile,
439	}, nil
440}
441
442// checkAndInstallMissingTools analyzes a bash command and attempts to automatically install any missing tools.
443func (b *BashTool) checkAndInstallMissingTools(ctx context.Context, command string) error {
444	commands, err := bashkit.ExtractCommands(command)
445	if err != nil {
446		return err
447	}
448
449	autoInstallMu.Lock()
450	defer autoInstallMu.Unlock()
451
452	var missing []string
453	for _, cmd := range commands {
454		if doNotAttemptToolInstall[cmd] {
455			continue
456		}
457		_, err := exec.LookPath(cmd)
458		if err == nil {
459			doNotAttemptToolInstall[cmd] = true // spare future LookPath calls
460			continue
461		}
462		missing = append(missing, cmd)
463	}
464
465	if len(missing) == 0 {
466		return nil
467	}
468
469	for _, cmd := range missing {
470		err := b.installTool(ctx, cmd)
471		if err != nil {
472			slog.WarnContext(ctx, "failed to install tool", "tool", cmd, "error", err)
473		}
474		doNotAttemptToolInstall[cmd] = true // either it's installed or it's not--either way, we're done with it
475	}
476	return nil
477}
478
479// Command safety check cache to avoid repeated LLM calls
480var (
481	autoInstallMu           sync.Mutex
482	doNotAttemptToolInstall = make(map[string]bool) // set to true if the tool should not be auto-installed
483)
484
485// autodetectPackageManager returns the first package‑manager binary
486// found in PATH, or an empty string if none are present.
487func autodetectPackageManager() string {
488	// TODO: cache this result with a sync.OnceValue
489
490	managers := []string{
491		"apt", "apt-get", // Debian/Ubuntu
492		"brew", "port", // macOS (Homebrew / MacPorts)
493		"apk",        // Alpine
494		"yum", "dnf", // RHEL/Fedora
495		"pacman",          // Arch
496		"zypper",          // openSUSE
497		"xbps-install",    // Void
498		"emerge",          // Gentoo
499		"nix-env", "guix", // NixOS / Guix
500		"pkg",      // FreeBSD
501		"slackpkg", // Slackware
502	}
503
504	for _, m := range managers {
505		if _, err := exec.LookPath(m); err == nil {
506			return m
507		}
508	}
509	return ""
510}
511
512// installTool attempts to install a single missing tool using LLM validation and system package manager.
513func (b *BashTool) installTool(ctx context.Context, cmd string) error {
514	slog.InfoContext(ctx, "attempting to install tool", "tool", cmd)
515
516	packageManager := autodetectPackageManager()
517	if packageManager == "" {
518		return fmt.Errorf("no known package manager found in PATH")
519	}
520	// Use LLM to validate and get package name
521	if b.LLMProvider == nil {
522		return fmt.Errorf("no LLM provider available for tool validation")
523	}
524	llmService, err := b.selectBestLLM()
525	if err != nil {
526		return fmt.Errorf("failed to get LLM service for tool validation: %w", err)
527	}
528
529	query := fmt.Sprintf(`Do you know this command/package/tool? Is it legitimate, clearly non-harmful, and commonly used? Can it be installed with package manager %s?
530
531Command: %s
532
533- YES: Respond ONLY with the package name used to install it
534- NO or UNSURE: Respond ONLY with the word NO`, packageManager, cmd)
535
536	req := &llm.Request{
537		Messages: []llm.Message{{
538			Role:    llm.MessageRoleUser,
539			Content: []llm.Content{llm.StringContent(query)},
540		}},
541		System: []llm.SystemContent{{
542			Type: "text",
543			Text: "You are an expert in software developer tools.",
544		}},
545	}
546
547	resp, err := llmService.Do(ctx, req)
548	if err != nil {
549		return fmt.Errorf("failed to validate tool with LLM: %w", err)
550	}
551
552	if len(resp.Content) == 0 {
553		return fmt.Errorf("empty response from LLM for tool validation")
554	}
555
556	response := strings.TrimSpace(resp.Content[0].Text)
557	if response == "NO" || response == "UNSURE" {
558		slog.InfoContext(ctx, "tool installation declined by LLM", "tool", cmd, "response", response)
559		return fmt.Errorf("tool %s not approved for installation", cmd)
560	}
561
562	packageName := strings.TrimSpace(response)
563	if packageName == "" {
564		return fmt.Errorf("no package name provided for tool %s", cmd)
565	}
566
567	return b.installPackage(ctx, cmd, packageName, packageManager)
568}
569
570// installPackage handles the actual package installation
571func (b *BashTool) installPackage(ctx context.Context, cmd, packageName, packageManager string) error {
572	// Install the package (with update command first if needed)
573	// TODO: these invocations create zombies when we are PID 1.
574	// We should give them the same zombie-reaping treatment as above,
575	// if/when we care enough to put in the effort. Not today.
576	var updateCmd, installCmd string
577	switch packageManager {
578	case "apt", "apt-get":
579		updateCmd = fmt.Sprintf("sudo %s update", packageManager)
580		installCmd = fmt.Sprintf("sudo %s install -y %s", packageManager, packageName)
581	case "brew":
582		// brew handles updates automatically, no explicit update needed
583		installCmd = fmt.Sprintf("brew install %s", packageName)
584	case "apk":
585		updateCmd = "sudo apk update"
586		installCmd = fmt.Sprintf("sudo apk add %s", packageName)
587	case "yum", "dnf":
588		// For yum/dnf, we don't need a separate update command as the package cache is usually fresh enough
589		// and install will fetch the latest available packages
590		installCmd = fmt.Sprintf("sudo %s install -y %s", packageManager, packageName)
591	case "pacman":
592		updateCmd = "sudo pacman -Sy"
593		installCmd = fmt.Sprintf("sudo pacman -S --noconfirm %s", packageName)
594	case "zypper":
595		updateCmd = "sudo zypper refresh"
596		installCmd = fmt.Sprintf("sudo zypper install -y %s", packageName)
597	case "xbps-install":
598		updateCmd = "sudo xbps-install -S"
599		installCmd = fmt.Sprintf("sudo xbps-install -y %s", packageName)
600	case "emerge":
601		// Note: emerge --sync is expensive, so we skip it for JIT installs
602		// Users should manually sync if needed
603		installCmd = fmt.Sprintf("sudo emerge %s", packageName)
604	case "nix-env":
605		// nix-env doesn't require explicit updates for JIT installs
606		installCmd = fmt.Sprintf("nix-env -i %s", packageName)
607	case "guix":
608		// guix doesn't require explicit updates for JIT installs
609		installCmd = fmt.Sprintf("guix install %s", packageName)
610	case "pkg":
611		updateCmd = "sudo pkg update"
612		installCmd = fmt.Sprintf("sudo pkg install -y %s", packageName)
613	case "slackpkg":
614		updateCmd = "sudo slackpkg update"
615		installCmd = fmt.Sprintf("sudo slackpkg install %s", packageName)
616	default:
617		return fmt.Errorf("unsupported package manager: %s", packageManager)
618	}
619
620	slog.InfoContext(ctx, "installing tool", "tool", cmd, "package", packageName, "update_command", updateCmd, "install_command", installCmd)
621
622	// Execute the update command first if needed
623	if updateCmd != "" {
624		slog.InfoContext(ctx, "updating package cache", "command", updateCmd)
625		updateCmdExec := exec.CommandContext(ctx, "sh", "-c", updateCmd)
626		updateOutput, err := updateCmdExec.CombinedOutput()
627		if err != nil {
628			slog.WarnContext(ctx, "package cache update failed, proceeding with install anyway", "error", err, "output", string(updateOutput))
629		}
630	}
631
632	// Execute the install command
633	cmdExec := exec.CommandContext(ctx, "sh", "-c", installCmd)
634	output, err := cmdExec.CombinedOutput()
635	if err != nil {
636		return fmt.Errorf("failed to install %s: %w\nOutput: %s", packageName, err, string(output))
637	}
638
639	slog.InfoContext(ctx, "tool installation successful", "tool", cmd, "package", packageName)
640	return nil
641}
642
643// selectBestLLM selects the best available LLM service for bash tool validation
644func (b *BashTool) selectBestLLM() (llm.Service, error) {
645	if b.LLMProvider == nil {
646		return nil, fmt.Errorf("no LLM provider available")
647	}
648
649	// Preferred models in order of preference for tool validation (fast, cheap models preferred)
650	preferredModels := []string{"qwen3-coder-fireworks", "gpt-5-thinking-mini", "gpt5-mini", "claude-sonnet-4.5", "predictable"}
651
652	for _, model := range preferredModels {
653		svc, err := b.LLMProvider.GetService(model)
654		if err == nil {
655			return svc, nil
656		}
657	}
658
659	// If no preferred model is available, try any available model
660	available := b.LLMProvider.GetAvailableModels()
661	if len(available) > 0 {
662		return b.LLMProvider.GetService(available[0])
663	}
664
665	return nil, fmt.Errorf("no LLM services available")
666}