diff --git a/pkg/agent/core.go b/pkg/agent/core.go index aacc306..7fb384f 100644 --- a/pkg/agent/core.go +++ b/pkg/agent/core.go @@ -65,9 +65,11 @@ func BuildRuntime(cfg *config.Config, d tools.Dispatcher, cwd string, env []stri for k, v := range cfg.Hooks { hooks[k] = []string(v) } + fmt.Fprintf(os.Stderr, "[DEBUG BuildRuntime] cfg.Prompt.IsExec=%v len(Value)=%d\n", cfg.Prompt.IsExec, len(cfg.Prompt.Value)) if resolved, err := resolvePrompt(cfg.Prompt, cwd, env); err != nil { fmt.Fprintf(os.Stderr, "resolve prompt: %v\n", err) } else { + fmt.Fprintf(os.Stderr, "[DEBUG BuildRuntime] preamble len=%d\n", len(resolved)) preamble = resolved } genParams = backend.GenerationParams{ diff --git a/pkg/agent/core_test.go b/pkg/agent/core_test.go index 3e395d3..5cef1cd 100644 --- a/pkg/agent/core_test.go +++ b/pkg/agent/core_test.go @@ -2473,6 +2473,29 @@ func TestBuildRuntime_ExecPrompt(t *testing.T) { } } +func TestBuildRuntime_ExecPromptFileResolution(t *testing.T) { + dir := setupCfgDir(t) + // Create a prompts directory with a test prompt file. + promptsDir := filepath.Join(dir, "prompts") + os.MkdirAll(promptsDir, 0o755) + os.WriteFile(filepath.Join(promptsDir, "test-prompt.md"), []byte("Hello ${PRIME_PLATFORM}"), 0o644) + t.Setenv("OLLIE_PROMPTS_PATH", promptsDir) + + d := tools.NewDispatcher() + cfg := &config.Config{Prompt: config.Prompt{ + Value: []string{"test-prompt", "echo extra"}, + IsExec: true, + }} + cwd := t.TempDir() + rt := BuildRuntime(cfg, d, cwd, PromptEnv(cwd)) + if !strings.Contains(rt.Preamble, "Hello linux") { + t.Errorf("expected file content with expanded vars, got %q", rt.Preamble) + } + if !strings.Contains(rt.Preamble, "extra") { + t.Errorf("expected shell fallback output, got %q", rt.Preamble) + } +} + func TestBuildRuntime_ExecDispatchSuccess(t *testing.T) { setupCfgDir(t) d := &mockDispatcher{ diff --git a/pkg/agent/prompt_resolver.go b/pkg/agent/prompt_resolver.go index baba838..c6ea7cc 100644 --- a/pkg/agent/prompt_resolver.go +++ b/pkg/agent/prompt_resolver.go @@ -29,6 +29,7 @@ func PromptEnv(cwd string) []string { return []string{ "PRIME_CWD=" + cwd, "PRIME_PLATFORM=" + platform, + "PRIME_DATE=" + time.Now().Format("2006-01-02"), "PRIME_IS_GIT_REPO=" + isGitRepo, } } @@ -55,27 +56,52 @@ func resolvePrompt(p config.Prompt, cwd string, env []string) (string, error) { } func resolveExecPrompt(cmds []string, cwd string, env []string) (string, error) { + // Build env map for variable expansion. + fullEnv := append(os.Environ(), env...) + envMap := make(map[string]string, len(fullEnv)) + for _, e := range fullEnv { + if k, v, ok := strings.Cut(e, "="); ok { + envMap[k] = v + } + } + expand := func(s string) string { + return os.Expand(s, func(key string) string { + return envMap[key] + }) + } + var parts []string - for _, cmdStr := range cmds { - cmdStr = strings.TrimSpace(cmdStr) - if cmdStr == "" { + fmt.Fprintf(os.Stderr, "[DEBUG resolveExecPrompt] %d entries\n", len(cmds)) + for _, entry := range cmds { + entry = strings.TrimSpace(entry) + if entry == "" { continue } + fmt.Fprintf(os.Stderr, "[DEBUG resolveExecPrompt] entry=%q\n", entry) + // Try resolving as a prompt file first. + if content, ok := resolvePromptFile(entry, envMap, expand); ok { + fmt.Fprintf(os.Stderr, "[DEBUG resolveExecPrompt] -> file ok, len=%d\n", len(content)) + if content != "" { + parts = append(parts, content) + } + continue + } + // Fall back to shell execution. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - cmd := exec.CommandContext(ctx, "sh", "-c", cmdStr) + cmd := exec.CommandContext(ctx, "sh", "-c", entry) if cwd != "" { if info, err := os.Stat(cwd); err == nil && info.IsDir() { cmd.Dir = cwd } } - cmd.Env = append(os.Environ(), env...) + cmd.Env = fullEnv var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() cancel() if err != nil { - return "", fmt.Errorf("prompt command %q failed: %v: %s", cmdStr, err, stderr.String()) + return "", fmt.Errorf("prompt command %q failed: %v: %s", entry, err, stderr.String()) } if out := strings.TrimRight(stdout.String(), "\n"); out != "" { parts = append(parts, out) @@ -84,6 +110,61 @@ func resolveExecPrompt(cmds []string, cwd string, env []string) (string, error) return strings.Join(parts, "\n"), nil } +// resolvePromptFile attempts to resolve entry as a prompt file. +// It first checks if the entry (after env expansion) is an absolute path to an +// existing file. Otherwise it searches OLLIE_PROMPTS_PATH directories for +// entry and entry.md. Returns the file content (with env vars expanded) and +// true if found. +func resolvePromptFile(entry string, envMap map[string]string, expand func(string) string) (string, bool) { + expanded := expand(entry) + + // If it contains shell metacharacters, it's a command, not a file name. + if strings.ContainsAny(expanded, "|&;<>()$`\\\"' \t") { + return "", false + } + + // Try as absolute path. + if filepath.IsAbs(expanded) { + if info, err := os.Stat(expanded); err == nil && !info.IsDir() { + return readAndExpand(expanded, expand) + } + // Try with .md suffix. + if info, err := os.Stat(expanded + ".md"); err == nil && !info.IsDir() { + return readAndExpand(expanded+".md", expand) + } + return "", false + } + + // Search OLLIE_PROMPTS_PATH. + promptsPath := envMap["OLLIE_PROMPTS_PATH"] + if promptsPath == "" { + return "", false + } + for _, dir := range filepath.SplitList(promptsPath) { + if dir == "" { + continue + } + for _, candidate := range []string{ + filepath.Join(dir, expanded), + filepath.Join(dir, expanded+".md"), + } { + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return readAndExpand(candidate, expand) + } + } + } + return "", false +} + +func readAndExpand(path string, expand func(string) string) (string, bool) { + data, err := os.ReadFile(path) + if err != nil { + return "", false + } + content := strings.TrimRight(expand(string(data)), "\n") + return content, true +} + func resolveStringPrompt(prompt, cwd string, env []string) (string, error) { if prompt == "" { return "", nil