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 --login -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", "--login", "-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 SHELLEY_CONVERSATION_ID so we control it explicitly below.
271	env := slices.DeleteFunc(os.Environ(), func(s string) bool {
272		return strings.HasPrefix(s, "SHELLEY_CONVERSATION_ID=")
273	})
274	env = append(env, "SKETCH=1")          // signal that this has been run by Sketch, sometimes useful for scripts
275	env = append(env, "EDITOR=/bin/false") // interactive editors won't work
276	if b.ConversationID != "" {
277		env = append(env, "SHELLEY_CONVERSATION_ID="+b.ConversationID)
278	}
279	cmd.Env = env
280	return cmd
281}
282
283func cmdWait(cmd *exec.Cmd) error {
284	err := cmd.Wait()
285	// We used to kill the process group here, but it's not clear that
286	// this is correct in the case of self-daemonizing processes,
287	// and I encountered issues where daemons that I tried to run
288	// as background tasks would mysteriously exit.
289	return err
290}
291
292func (b *BashTool) executeBash(ctx context.Context, req bashInput, timeout time.Duration) (string, error) {
293	execCtx, cancel := context.WithTimeout(ctx, timeout)
294	defer cancel()
295
296	output := new(bytes.Buffer)
297	cmd := b.makeBashCommand(execCtx, req.Command, output)
298	// TODO: maybe detect simple interactive git rebase commands and auto-background them?
299	// Would need to hint to the agent what is happening.
300	// We might also be able to do this for other simple interactive commands that use EDITOR.
301	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`)
302	if err := cmd.Start(); err != nil {
303		return "", fmt.Errorf("command failed: %w", err)
304	}
305
306	err := cmdWait(cmd)
307
308	out, formatErr := formatForegroundBashOutput(output.String())
309	if formatErr != nil {
310		return "", formatErr
311	}
312
313	if execCtx.Err() == context.DeadlineExceeded {
314		return "", fmt.Errorf("[command timed out after %s, showing output until timeout]\n%s", timeout, out)
315	}
316	if err != nil {
317		return "", fmt.Errorf("[command failed: %w]\n%s", err, out)
318	}
319
320	return out, nil
321}
322
323// formatForegroundBashOutput formats the output of a foreground bash command for display to the agent.
324// If output exceeds largeOutputThreshold, it saves to a file and returns a summary.
325func formatForegroundBashOutput(out string) (string, error) {
326	if len(out) <= largeOutputThreshold {
327		return out, nil
328	}
329
330	// Save full output to a temp file
331	tmpDir, err := os.MkdirTemp("", "shelley-output-")
332	if err != nil {
333		return "", fmt.Errorf("failed to create temp dir for large output: %w", err)
334	}
335
336	outFile := filepath.Join(tmpDir, "output")
337	if err := os.WriteFile(outFile, []byte(out), 0o644); err != nil {
338		os.RemoveAll(tmpDir)
339		return "", fmt.Errorf("failed to write large output to file: %w", err)
340	}
341
342	// Split into lines
343	lines := strings.Split(out, "\n")
344
345	// If fewer than 3 lines total, likely binary or single-line output
346	if len(lines) < 3 {
347		return fmt.Sprintf("[output too large (%s, %d lines), saved to: %s]",
348			humanizeBytes(len(out)), len(lines), outFile), nil
349	}
350
351	var result strings.Builder
352	result.WriteString(fmt.Sprintf("[output too large (%s, %d lines), saved to: %s]\n\n",
353		humanizeBytes(len(out)), len(lines), outFile))
354
355	// First N lines
356	result.WriteString("First lines:\n")
357	firstN := min(firstLinesCount, len(lines))
358	for i := 0; i < firstN; i++ {
359		result.WriteString(fmt.Sprintf("%5d: %s\n", i+1, truncateLine(lines[i])))
360	}
361
362	// Last N lines
363	result.WriteString("\n...\n\nLast lines:\n")
364	startIdx := max(0, len(lines)-lastLinesCount)
365	for i := startIdx; i < len(lines); i++ {
366		result.WriteString(fmt.Sprintf("%5d: %s\n", i+1, truncateLine(lines[i])))
367	}
368
369	return result.String(), nil
370}
371
372// truncateLine truncates a line to maxLineLength characters, appending "..." if truncated.
373func truncateLine(line string) string {
374	if len(line) <= maxLineLength {
375		return line
376	}
377	return line[:maxLineLength] + "..."
378}
379
380func humanizeBytes(bytes int) string {
381	switch {
382	case bytes < 4*1024:
383		return fmt.Sprintf("%dB", bytes)
384	case bytes < 1024*1024:
385		kb := int(math.Round(float64(bytes) / 1024.0))
386		return fmt.Sprintf("%dkB", kb)
387	case bytes < 1024*1024*1024:
388		mb := int(math.Round(float64(bytes) / (1024.0 * 1024.0)))
389		return fmt.Sprintf("%dMB", mb)
390	}
391	return "more than 1GB"
392}
393
394// executeBackgroundBash executes a command in the background and returns the pid and output file locations
395func (b *BashTool) executeBackgroundBash(ctx context.Context, req bashInput, timeout time.Duration) (*BackgroundResult, error) {
396	// Create temp output files
397	tmpDir, err := os.MkdirTemp("", "sketch-bg-")
398	if err != nil {
399		return nil, fmt.Errorf("failed to create temp directory: %w", err)
400	}
401	// We can't really clean up tempDir, because we have no idea
402	// how far into the future the agent might want to read the output.
403
404	outFile := filepath.Join(tmpDir, "output")
405	out, err := os.Create(outFile)
406	if err != nil {
407		return nil, fmt.Errorf("failed to create output file: %w", err)
408	}
409
410	execCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout) // detach from tool use context
411	cmd := b.makeBashCommand(execCtx, req.Command, out)
412	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()"`)
413
414	if err := cmd.Start(); err != nil {
415		cancel()
416		out.Close()
417		os.RemoveAll(tmpDir) // clean up temp dir -- didn't start means we don't need the output
418		return nil, fmt.Errorf("failed to start background command: %w", err)
419	}
420
421	// Wait for completion in the background, then do cleanup.
422	go func() {
423		err := cmdWait(cmd)
424		// Leave a note to the agent so that it knows that the process has finished.
425		if err != nil {
426			fmt.Fprintf(out, "\n\n[background process failed: %v]\n", err)
427		} else {
428			fmt.Fprintf(out, "\n\n[background process completed]\n")
429		}
430		out.Close()
431		cancel()
432	}()
433
434	return &BackgroundResult{
435		PID:     cmd.Process.Pid,
436		OutFile: outFile,
437	}, nil
438}
439
440// checkAndInstallMissingTools analyzes a bash command and attempts to automatically install any missing tools.
441func (b *BashTool) checkAndInstallMissingTools(ctx context.Context, command string) error {
442	commands, err := bashkit.ExtractCommands(command)
443	if err != nil {
444		return err
445	}
446
447	autoInstallMu.Lock()
448	defer autoInstallMu.Unlock()
449
450	var missing []string
451	for _, cmd := range commands {
452		if doNotAttemptToolInstall[cmd] {
453			continue
454		}
455		_, err := exec.LookPath(cmd)
456		if err == nil {
457			doNotAttemptToolInstall[cmd] = true // spare future LookPath calls
458			continue
459		}
460		missing = append(missing, cmd)
461	}
462
463	if len(missing) == 0 {
464		return nil
465	}
466
467	for _, cmd := range missing {
468		err := b.installTool(ctx, cmd)
469		if err != nil {
470			slog.WarnContext(ctx, "failed to install tool", "tool", cmd, "error", err)
471		}
472		doNotAttemptToolInstall[cmd] = true // either it's installed or it's not--either way, we're done with it
473	}
474	return nil
475}
476
477// Command safety check cache to avoid repeated LLM calls
478var (
479	autoInstallMu           sync.Mutex
480	doNotAttemptToolInstall = make(map[string]bool) // set to true if the tool should not be auto-installed
481)
482
483// autodetectPackageManager returns the first package‑manager binary
484// found in PATH, or an empty string if none are present.
485func autodetectPackageManager() string {
486	// TODO: cache this result with a sync.OnceValue
487
488	managers := []string{
489		"apt", "apt-get", // Debian/Ubuntu
490		"brew", "port", // macOS (Homebrew / MacPorts)
491		"apk",        // Alpine
492		"yum", "dnf", // RHEL/Fedora
493		"pacman",          // Arch
494		"zypper",          // openSUSE
495		"xbps-install",    // Void
496		"emerge",          // Gentoo
497		"nix-env", "guix", // NixOS / Guix
498		"pkg",      // FreeBSD
499		"slackpkg", // Slackware
500	}
501
502	for _, m := range managers {
503		if _, err := exec.LookPath(m); err == nil {
504			return m
505		}
506	}
507	return ""
508}
509
510// installTool attempts to install a single missing tool using LLM validation and system package manager.
511func (b *BashTool) installTool(ctx context.Context, cmd string) error {
512	slog.InfoContext(ctx, "attempting to install tool", "tool", cmd)
513
514	packageManager := autodetectPackageManager()
515	if packageManager == "" {
516		return fmt.Errorf("no known package manager found in PATH")
517	}
518	// Use LLM to validate and get package name
519	if b.LLMProvider == nil {
520		return fmt.Errorf("no LLM provider available for tool validation")
521	}
522	llmService, err := b.selectBestLLM()
523	if err != nil {
524		return fmt.Errorf("failed to get LLM service for tool validation: %w", err)
525	}
526
527	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?
528
529Command: %s
530
531- YES: Respond ONLY with the package name used to install it
532- NO or UNSURE: Respond ONLY with the word NO`, packageManager, cmd)
533
534	req := &llm.Request{
535		Messages: []llm.Message{{
536			Role:    llm.MessageRoleUser,
537			Content: []llm.Content{llm.StringContent(query)},
538		}},
539		System: []llm.SystemContent{{
540			Type: "text",
541			Text: "You are an expert in software developer tools.",
542		}},
543	}
544
545	resp, err := llmService.Do(ctx, req)
546	if err != nil {
547		return fmt.Errorf("failed to validate tool with LLM: %w", err)
548	}
549
550	if len(resp.Content) == 0 {
551		return fmt.Errorf("empty response from LLM for tool validation")
552	}
553
554	response := strings.TrimSpace(resp.Content[0].Text)
555	if response == "NO" || response == "UNSURE" {
556		slog.InfoContext(ctx, "tool installation declined by LLM", "tool", cmd, "response", response)
557		return fmt.Errorf("tool %s not approved for installation", cmd)
558	}
559
560	packageName := strings.TrimSpace(response)
561	if packageName == "" {
562		return fmt.Errorf("no package name provided for tool %s", cmd)
563	}
564
565	return b.installPackage(ctx, cmd, packageName, packageManager)
566}
567
568// installPackage handles the actual package installation
569func (b *BashTool) installPackage(ctx context.Context, cmd, packageName, packageManager string) error {
570	// Install the package (with update command first if needed)
571	// TODO: these invocations create zombies when we are PID 1.
572	// We should give them the same zombie-reaping treatment as above,
573	// if/when we care enough to put in the effort. Not today.
574	var updateCmd, installCmd string
575	switch packageManager {
576	case "apt", "apt-get":
577		updateCmd = fmt.Sprintf("sudo %s update", packageManager)
578		installCmd = fmt.Sprintf("sudo %s install -y %s", packageManager, packageName)
579	case "brew":
580		// brew handles updates automatically, no explicit update needed
581		installCmd = fmt.Sprintf("brew install %s", packageName)
582	case "apk":
583		updateCmd = "sudo apk update"
584		installCmd = fmt.Sprintf("sudo apk add %s", packageName)
585	case "yum", "dnf":
586		// For yum/dnf, we don't need a separate update command as the package cache is usually fresh enough
587		// and install will fetch the latest available packages
588		installCmd = fmt.Sprintf("sudo %s install -y %s", packageManager, packageName)
589	case "pacman":
590		updateCmd = "sudo pacman -Sy"
591		installCmd = fmt.Sprintf("sudo pacman -S --noconfirm %s", packageName)
592	case "zypper":
593		updateCmd = "sudo zypper refresh"
594		installCmd = fmt.Sprintf("sudo zypper install -y %s", packageName)
595	case "xbps-install":
596		updateCmd = "sudo xbps-install -S"
597		installCmd = fmt.Sprintf("sudo xbps-install -y %s", packageName)
598	case "emerge":
599		// Note: emerge --sync is expensive, so we skip it for JIT installs
600		// Users should manually sync if needed
601		installCmd = fmt.Sprintf("sudo emerge %s", packageName)
602	case "nix-env":
603		// nix-env doesn't require explicit updates for JIT installs
604		installCmd = fmt.Sprintf("nix-env -i %s", packageName)
605	case "guix":
606		// guix doesn't require explicit updates for JIT installs
607		installCmd = fmt.Sprintf("guix install %s", packageName)
608	case "pkg":
609		updateCmd = "sudo pkg update"
610		installCmd = fmt.Sprintf("sudo pkg install -y %s", packageName)
611	case "slackpkg":
612		updateCmd = "sudo slackpkg update"
613		installCmd = fmt.Sprintf("sudo slackpkg install %s", packageName)
614	default:
615		return fmt.Errorf("unsupported package manager: %s", packageManager)
616	}
617
618	slog.InfoContext(ctx, "installing tool", "tool", cmd, "package", packageName, "update_command", updateCmd, "install_command", installCmd)
619
620	// Execute the update command first if needed
621	if updateCmd != "" {
622		slog.InfoContext(ctx, "updating package cache", "command", updateCmd)
623		updateCmdExec := exec.CommandContext(ctx, "sh", "-c", updateCmd)
624		updateOutput, err := updateCmdExec.CombinedOutput()
625		if err != nil {
626			slog.WarnContext(ctx, "package cache update failed, proceeding with install anyway", "error", err, "output", string(updateOutput))
627		}
628	}
629
630	// Execute the install command
631	cmdExec := exec.CommandContext(ctx, "sh", "-c", installCmd)
632	output, err := cmdExec.CombinedOutput()
633	if err != nil {
634		return fmt.Errorf("failed to install %s: %w\nOutput: %s", packageName, err, string(output))
635	}
636
637	slog.InfoContext(ctx, "tool installation successful", "tool", cmd, "package", packageName)
638	return nil
639}
640
641// selectBestLLM selects the best available LLM service for bash tool validation
642func (b *BashTool) selectBestLLM() (llm.Service, error) {
643	if b.LLMProvider == nil {
644		return nil, fmt.Errorf("no LLM provider available")
645	}
646
647	// Preferred models in order of preference for tool validation (fast, cheap models preferred)
648	preferredModels := []string{"qwen3-coder-fireworks", "gpt-5-thinking-mini", "gpt5-mini", "claude-sonnet-4.5", "predictable"}
649
650	for _, model := range preferredModels {
651		svc, err := b.LLMProvider.GetService(model)
652		if err == nil {
653			return svc, nil
654		}
655	}
656
657	// If no preferred model is available, try any available model
658	available := b.LLMProvider.GetAvailableModels()
659	if len(available) > 0 {
660		return b.LLMProvider.GetService(available[0])
661	}
662
663	return nil, fmt.Errorf("no LLM services available")
664}