agent: simplify prompt resolver to file-path-only
All agent configs use the same pattern: JSON array of file paths with env var expansion. Remove unused features: - Shell command execution via resolveStringPrompt (the `!cmd` prefix path is kept as a trivial fallback but the 30s timeout/stderr capture/cwd probing is gone) - Prompt directory search (PromptsDirs, resolvePromptFile) - Shell metacharacter detection guard - Literal-text-if-contains-newline heuristic - IsExec flag on Prompt type (both string and array now mean the same thing: list of file paths) 207 + 49 → 104 + 38 lines (-114)
This commit is contained in:
parent
12894027dd
commit
625654fcef
|
|
@ -7,35 +7,22 @@ import (
|
|||
"ollie/backend"
|
||||
)
|
||||
|
||||
// Prompt holds the agent prompt. It unmarshals from either a JSON string
|
||||
// (treated as literal text, with the existing resolvePrompt semantics) or
|
||||
// an array of strings (each element is a shell command whose stdout is
|
||||
// concatenated with newlines).
|
||||
// Prompt holds the agent prompt — a list of file paths to read and concatenate.
|
||||
// Unmarshals from either a JSON string (single path) or array of strings.
|
||||
type Prompt struct {
|
||||
Value []string // len==1 for a plain string; len>1 for command list
|
||||
IsExec bool // true when the value came from an array (execute mode)
|
||||
Value []string
|
||||
}
|
||||
|
||||
func (p *Prompt) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err == nil {
|
||||
p.Value = []string{s}
|
||||
p.IsExec = false
|
||||
return nil
|
||||
}
|
||||
var ss []string
|
||||
if err := json.Unmarshal(data, &ss); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Value = ss
|
||||
p.IsExec = true
|
||||
return nil
|
||||
return json.Unmarshal(data, &p.Value)
|
||||
}
|
||||
|
||||
func (p Prompt) MarshalJSON() ([]byte, error) {
|
||||
if p.IsExec {
|
||||
return json.Marshal(p.Value)
|
||||
}
|
||||
if len(p.Value) == 1 {
|
||||
return json.Marshal(p.Value[0])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,16 +6,6 @@ import (
|
|||
"ollie/paths"
|
||||
)
|
||||
|
||||
// defaultPromptsDir returns the default directory for prompt templates.
|
||||
func defaultPromptsDir() string {
|
||||
return paths.CfgDir() + "/prompts"
|
||||
}
|
||||
|
||||
// PromptsDirs returns all prompt directories. Currently just the default.
|
||||
func PromptsDirs() []string {
|
||||
return []string{defaultPromptsDir()}
|
||||
}
|
||||
|
||||
// AgentsDirs returns all agent directories. Currently just the default.
|
||||
func AgentsDirs() []string {
|
||||
return []string{paths.CfgDir() + "/agents"}
|
||||
|
|
@ -33,7 +23,6 @@ func AgentConfigPath(agentsDir, name string) string {
|
|||
}
|
||||
|
||||
// LoadConfig loads an agent configuration file by profile name.
|
||||
// If open is nil, os.Open is used.
|
||||
func LoadConfig(agentsDir, name string, open func(string) (*os.File, error)) *AgentConfig {
|
||||
if open == nil {
|
||||
open = os.Open
|
||||
|
|
|
|||
|
|
@ -1,9 +1,6 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
|
@ -32,176 +29,76 @@ func PromptEnv(cwd string) []string {
|
|||
}
|
||||
}
|
||||
|
||||
// resolvePrompt interprets a Prompt from an agent config.
|
||||
//
|
||||
// If the prompt was parsed from a JSON array, each element is executed as a
|
||||
// shell command and the combined stdout (joined by newlines) is returned.
|
||||
//
|
||||
// If the prompt was parsed from a JSON string, the existing single-string
|
||||
// semantics apply: environment variables are expanded, then:
|
||||
// - If the string contains a newline, it is treated as literal text.
|
||||
// - If the string starts with '!', the rest is executed as a shell command.
|
||||
// - If the expanded string names an existing file, the file is read.
|
||||
// - Otherwise the string is used as-is.
|
||||
// resolvePrompt reads each file path in p.Value, expands env vars in both
|
||||
// the path and the file content, and joins the results with newlines.
|
||||
func resolvePrompt(p Prompt, cwd string, env []string) (string, error) {
|
||||
if len(p.Value) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
if p.IsExec {
|
||||
return resolveExecPrompt(p.Value, cwd, env)
|
||||
}
|
||||
return resolveStringPrompt(p.Value[0], cwd, env)
|
||||
}
|
||||
|
||||
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]
|
||||
})
|
||||
}
|
||||
lookup := envLookup(env)
|
||||
expand := func(s string) string { return os.Expand(s, lookup) }
|
||||
|
||||
var parts []string
|
||||
for _, entry := range cmds {
|
||||
for _, entry := range p.Value {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
// Try resolving as a prompt file first.
|
||||
if content, ok := resolvePromptFile(entry, expand); ok {
|
||||
if content != "" {
|
||||
parts = append(parts, content)
|
||||
path := expand(entry)
|
||||
|
||||
// Support !command for backward compat (used in some user configs)
|
||||
if strings.HasPrefix(path, "!") {
|
||||
out, err := runPromptCmd(strings.TrimSpace(path[1:]), cwd)
|
||||
if err == nil && out != "" {
|
||||
parts = append(parts, out)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Not a prompt file — skip.
|
||||
continue
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
// Try with .md suffix
|
||||
data, err = os.ReadFile(path + ".md")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
content := strings.TrimRight(expand(string(data)), "\n")
|
||||
if content != "" {
|
||||
parts = append(parts, content)
|
||||
}
|
||||
}
|
||||
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 the default prompts directory for
|
||||
// entry and entry.md. Returns the file content (with env vars expanded) and
|
||||
// true if found.
|
||||
func resolvePromptFile(entry 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 the default prompts directory.
|
||||
for _, dir := range PromptsDirs() {
|
||||
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
|
||||
}
|
||||
envMap := make(map[string]string, len(env))
|
||||
// envLookup returns a lookup function that checks env slice first, then os env.
|
||||
func envLookup(env []string) func(string) string {
|
||||
m := make(map[string]string, len(env))
|
||||
for _, e := range env {
|
||||
if k, v, ok := strings.Cut(e, "="); ok {
|
||||
envMap[k] = v
|
||||
m[k] = v
|
||||
}
|
||||
}
|
||||
expanded := os.Expand(prompt, func(key string) string {
|
||||
if val, ok := envMap[key]; ok {
|
||||
return val
|
||||
return func(key string) string {
|
||||
if v, ok := m[key]; ok {
|
||||
return v
|
||||
}
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return ""
|
||||
})
|
||||
expanded = strings.TrimSpace(expanded)
|
||||
if expanded == "" {
|
||||
return os.Getenv(key)
|
||||
}
|
||||
}
|
||||
|
||||
// runPromptCmd executes a shell command and returns its stdout.
|
||||
func runPromptCmd(cmdStr, cwd string) (string, error) {
|
||||
if cmdStr == "" {
|
||||
return "", nil
|
||||
}
|
||||
if strings.Contains(expanded, "\n") {
|
||||
return expanded, nil
|
||||
cmd := exec.Command("sh", "-c", cmdStr)
|
||||
if cwd != "" {
|
||||
cmd.Dir = cwd
|
||||
}
|
||||
if strings.HasPrefix(expanded, "!") {
|
||||
cmdStr := strings.TrimPrefix(expanded, "!")
|
||||
cmdStr = strings.TrimSpace(cmdStr)
|
||||
if cmdStr == "" {
|
||||
return "", nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", cmdStr)
|
||||
var cmdEnv []string
|
||||
if cwd != "" {
|
||||
if info, err := os.Stat(cwd); err == nil && info.IsDir() {
|
||||
cmd.Dir = cwd
|
||||
} else {
|
||||
cmdEnv = append(cmdEnv, "PWD="+cwd)
|
||||
}
|
||||
}
|
||||
if len(cmdEnv) > 0 {
|
||||
cmd.Env = append(os.Environ(), cmdEnv...)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("prompt command failed: %v: %s", err, stderr.String())
|
||||
}
|
||||
return strings.TrimRight(stdout.String(), "\n"), nil
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := expanded
|
||||
if !filepath.IsAbs(path) && cwd != "" {
|
||||
path = filepath.Join(cwd, path)
|
||||
}
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read prompt file: %w", err)
|
||||
}
|
||||
return strings.TrimRight(string(data), "\n"), nil
|
||||
}
|
||||
return expanded, nil
|
||||
return strings.TrimRight(string(out), "\n"), nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue