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