Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions internal/attractor/engine/codergen_router.go
Original file line number Diff line number Diff line change
Expand Up @@ -1273,8 +1273,9 @@ func (r *CodergenRouter) runCLI(ctx context.Context, execCtx *Execution, node *m
if promptMode == "stdin" {
cmd.Stdin = strings.NewReader(prompt)
} else {
// Avoid interactive reads if the CLI tries stdin for confirmations.
cmd.Stdin = strings.NewReader("")
// Auto-accept any interactive confirmation prompts (e.g. the
// --dangerously-skip-permissions warning in Claude CLI).
cmd.Stdin = strings.NewReader("y\n")
}
stdoutFile, err := os.Create(stdoutPath)
if err != nil {
Expand Down
2 changes: 1 addition & 1 deletion internal/attractor/engine/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ func validateConfig(cfg *RunConfigFile) error {
return fmt.Errorf("llm.providers.%s backend=cli requires builtin provider with cli contract", prov)
}
default:
return fmt.Errorf("invalid backend for provider %q: %q (want api|cli)", prov, pc.Backend)
return fmt.Errorf("invalid backend for provider %q: %q (want api|cli)\n hint: add backend: cli (or api) under llm.providers.%s in your run config", prov, pc.Backend, prov)
}
if strings.EqualFold(cfg.LLM.CLIProfile, "real") && strings.TrimSpace(pc.Executable) != "" {
return fmt.Errorf("llm.providers.%s.executable is only allowed when llm.cli_profile=test_shim", prov)
Expand Down
6 changes: 5 additions & 1 deletion internal/attractor/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,11 @@ func PrepareWithOptions(dotSource []byte, opts PrepareOptions) (*model.Graph, []
var errs []string
for _, d := range diags {
if d.Severity == validate.SeverityError {
errs = append(errs, d.Rule+": "+d.Message)
msg := d.Rule + ": " + d.Message
if d.Fix != "" {
msg += "\n hint: " + d.Fix
}
errs = append(errs, msg)
}
}
if len(errs) > 0 {
Expand Down
68 changes: 68 additions & 0 deletions internal/attractor/engine/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -919,6 +919,9 @@ func (h *ToolHandler) Execute(ctx context.Context, execCtx *Execution, node *mod
if failureReason == "" {
failureReason = "tool command failed"
}
if hint := worktreeNotFoundHint(stderrBytes, cmdStr, execCtx); hint != "" {
failureReason += "\n hint: " + hint
}
if execCtx != nil && execCtx.Engine != nil && execCtx.Engine.CXDB != nil {
if _, _, err := execCtx.Engine.CXDB.Append(ctx, "com.kilroy.attractor.ToolResult", 1, map[string]any{
"run_id": execCtx.Engine.Options.RunID,
Expand Down Expand Up @@ -1020,6 +1023,71 @@ func looksActionableToolOutputLine(line string) bool {
return false
}

// worktreeNotFoundHint returns a hint when a tool command fails because a
// referenced file doesn't exist in the worktree but does exist in the source
// repo. Returns "" when no hint applies.
func worktreeNotFoundHint(stderr []byte, cmdStr string, execCtx *Execution) string {
stderrStr := strings.ToLower(string(stderr))
if !strings.Contains(stderrStr, "not found") && !strings.Contains(stderrStr, "no such file") {
return ""
}
repoPath := ""
if execCtx != nil && execCtx.Engine != nil {
repoPath = execCtx.Engine.Options.RepoPath
}

// Try to extract a script path from the command (first token of the command).
scriptPath := extractLeadingPath(cmdStr)
if scriptPath == "" {
return "file not found — if this script is not committed to git, run 'git add <file> && git commit' in the source repo"
}

worktreeDir := ""
if execCtx != nil {
worktreeDir = execCtx.WorktreeDir
}

// Check if the file exists in the source repo but not in the worktree.
inWorktree := worktreeDir != "" && pathExists(filepath.Join(worktreeDir, scriptPath))
inRepo := repoPath != "" && pathExists(filepath.Join(repoPath, scriptPath))
if !inWorktree && inRepo {
return fmt.Sprintf("file %q exists in the source repo but not in the worktree — it may not be committed to git; run 'git add %s && git commit'", scriptPath, scriptPath)
}
if !inWorktree && !inRepo {
return fmt.Sprintf("file %q not found in worktree or source repo — check the path in tool_command", scriptPath)
}
return ""
}

// extractLeadingPath pulls the first token from a shell command, stripping
// common prefixes like "bash", "sh", "./" etc. Returns "" if no plausible
// file path is found.
func extractLeadingPath(cmd string) string {
cmd = strings.TrimSpace(cmd)
// Strip leading "bash -c", "sh -c", etc.
for _, prefix := range []string{"bash -c ", "sh -c "} {
if strings.HasPrefix(cmd, prefix) {
cmd = strings.TrimSpace(cmd[len(prefix):])
// Remove surrounding quotes from the remaining command.
if len(cmd) >= 2 && (cmd[0] == '\'' || cmd[0] == '"') && cmd[len(cmd)-1] == cmd[0] {
cmd = cmd[1 : len(cmd)-1]
}
break
}
}
// Take the first whitespace-delimited token.
fields := strings.Fields(cmd)
if len(fields) == 0 {
return ""
}
candidate := fields[0]
// Skip if it looks like a bare command (no path separator and no extension).
if !strings.Contains(candidate, "/") && !strings.Contains(candidate, ".") {
return ""
}
return candidate
}

func resolveToolShellPath() string {
return resolveToolShellPathWith(goruntime.GOOS, exec.LookPath, pathExists)
}
Expand Down
4 changes: 2 additions & 2 deletions internal/attractor/engine/provider_preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ func runProviderAPIPreflight(ctx context.Context, g *model.Graph, runtimes map[s
Status: preflightStatusFail,
Message: "api key env is not configured",
})
return fmt.Errorf("preflight: provider %s api key env is not configured", provider)
return fmt.Errorf("preflight: provider %s api key env is not configured\n hint: set api_key_env in your run config under llm.providers.%s.api, or use backend: cli to skip API keys", provider, provider)
}
if strings.TrimSpace(os.Getenv(keyEnv)) == "" {
report.addCheck(providerPreflightCheck{
Expand All @@ -244,7 +244,7 @@ func runProviderAPIPreflight(ctx context.Context, g *model.Graph, runtimes map[s
Status: preflightStatusFail,
Message: fmt.Sprintf("required api key env %s is not set", keyEnv),
})
return fmt.Errorf("preflight: provider %s missing api key env %s", provider, keyEnv)
return fmt.Errorf("preflight: provider %s missing api key env %s\n hint: export %s=<your-api-key> or switch to backend: cli in your run config", provider, keyEnv, keyEnv)
}
report.addCheck(providerPreflightCheck{
Name: "provider_api_credentials",
Expand Down
2 changes: 1 addition & 1 deletion internal/attractor/engine/run_with_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ func bootstrapRunWithConfig(ctx context.Context, dotSource []byte, cfg *RunConfi
for p := range usedProviders {
rt, ok := runtimes[p]
if !ok || (rt.Backend != BackendAPI && rt.Backend != BackendCLI) {
return nil, fmt.Errorf("missing llm.providers.%s.backend (Kilroy forbids implicit backend defaults)", p)
return nil, fmt.Errorf("missing llm.providers.%s.backend (Kilroy forbids implicit backend defaults)\n hint: add llm.providers.%s.backend: cli (or api) to your run config, or remove --config to use auto-detection", p, p)
}
}
runUsesCLIProviders := false
Expand Down
3 changes: 3 additions & 0 deletions internal/attractor/engine/run_with_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ digraph G {
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "hint:") {
t.Errorf("error should contain a hint for the user, got: %s", err.Error())
}
}

func TestRunWithConfig_ReportsCXDBUIURL(t *testing.T) {
Expand Down
99 changes: 99 additions & 0 deletions internal/attractor/engine/worktree_hint_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Tests for worktree file-not-found hint in ToolHandler error paths.

package engine

import (
"os"
"path/filepath"
"strings"
"testing"
)

func TestExtractLeadingPath(t *testing.T) {
tests := []struct {
cmd string
want string
}{
{"./scripts/check.sh", "./scripts/check.sh"},
{"scripts/check.sh --flag", "scripts/check.sh"},
{"bash -c 'scripts/check.sh'", "scripts/check.sh"},
{"sh -c \"./run.sh arg1 arg2\"", "./run.sh"},
{"echo hello", ""}, // bare command, no path
{"ls", ""}, // bare command
{"node app.js", ""}, // first token is bare command
{"", ""}, // empty
{" ./test.sh ", "./test.sh"},
}
for _, tt := range tests {
got := extractLeadingPath(tt.cmd)
if got != tt.want {
t.Errorf("extractLeadingPath(%q) = %q, want %q", tt.cmd, got, tt.want)
}
}
}

func TestWorktreeNotFoundHint_FileInRepoNotWorktree(t *testing.T) {
repoDir := t.TempDir()
worktreeDir := t.TempDir()

// Create the file in repo but not in worktree.
scriptDir := filepath.Join(repoDir, "scripts")
if err := os.MkdirAll(scriptDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(scriptDir, "check.sh"), []byte("#!/bin/bash\necho ok"), 0o755); err != nil {
t.Fatal(err)
}

execCtx := &Execution{
WorktreeDir: worktreeDir,
Engine: &Engine{
Options: RunOptions{RepoPath: repoDir},
},
}
stderr := []byte("bash: scripts/check.sh: No such file or directory")
hint := worktreeNotFoundHint(stderr, "scripts/check.sh", execCtx)
if hint == "" {
t.Fatal("expected a hint, got empty string")
}
if want := "exists in the source repo but not in the worktree"; !strings.Contains(hint, want) {
t.Errorf("hint %q should contain %q", hint, want)
}
if want := "git add"; !strings.Contains(hint, want) {
t.Errorf("hint %q should contain %q", hint, want)
}
}

func TestWorktreeNotFoundHint_FileInNeither(t *testing.T) {
repoDir := t.TempDir()
worktreeDir := t.TempDir()

execCtx := &Execution{
WorktreeDir: worktreeDir,
Engine: &Engine{
Options: RunOptions{RepoPath: repoDir},
},
}
stderr := []byte("bash: scripts/missing.sh: No such file or directory")
hint := worktreeNotFoundHint(stderr, "scripts/missing.sh", execCtx)
if hint == "" {
t.Fatal("expected a hint, got empty string")
}
if want := "not found in worktree or source repo"; !strings.Contains(hint, want) {
t.Errorf("hint %q should contain %q", hint, want)
}
}

func TestWorktreeNotFoundHint_NoNotFoundInStderr(t *testing.T) {
execCtx := &Execution{
WorktreeDir: t.TempDir(),
Engine: &Engine{
Options: RunOptions{RepoPath: t.TempDir()},
},
}
stderr := []byte("some other error")
hint := worktreeNotFoundHint(stderr, "scripts/check.sh", execCtx)
if hint != "" {
t.Errorf("expected empty hint for unrelated error, got %q", hint)
}
}
2 changes: 2 additions & 0 deletions internal/attractor/validate/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,7 @@ func lintStylesheetModelIDs(g *model.Graph, catalog *modeldb.Catalog) []Diagnost
Rule: "stylesheet_unknown_model",
Severity: SeverityWarning,
Message: fmt.Sprintf("model_stylesheet: model %q not found in catalog for provider %q", modelID, provider),
Fix: fmt.Sprintf("check the model ID spelling; for Anthropic use dots in versions (e.g. claude-sonnet-4.6)"),
})
case modeldb.ModelFoundNonCanonical:
diags = append(diags, Diagnostic{
Expand Down Expand Up @@ -856,6 +857,7 @@ func lintLLMProviderPresent(g *model.Graph) []Diagnostic {
Severity: SeverityError,
Message: "codergen node missing llm_provider (Kilroy forbids provider auto-detection)",
NodeID: id,
Fix: "add llm_provider in a model_stylesheet (e.g. * [llm_provider=anthropic])",
})
}
}
Expand Down
5 changes: 5 additions & 0 deletions internal/attractor/validate/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ digraph G {
}
diags := Validate(g)
assertHasRule(t, diags, "llm_provider_required", SeverityError)
for _, d := range diags {
if d.Rule == "llm_provider_required" && d.Fix == "" {
t.Error("llm_provider_required diagnostic should include a Fix hint")
}
}
}

func TestValidate_ToolCommandRequired_ParallelogramWithToolCommand_NoError(t *testing.T) {
Expand Down
Loading