clean up OLLIE_* env vars: replace with XDG conventions
- Remove all OLLIE_*_PATH vars (TOOLS_PATH, CFG_PATH, DATA_PATH, etc.)
Use XDG_CONFIG_HOME/ollie/* and XDG_DATA_HOME/ollie/* instead
- Replace OLLIE_<TAG>_LOG per-component logging with single OLLIE_LOG={level}
- Remove Route()/RouteRequest/RouteResult (replaced by direct backend selection)
- Update sandbox config to use XDG paths instead of OLLIE_*_PATH tokens
- Update docs accordingly
This commit is contained in:
parent
92619d4072
commit
267f438e9e
|
|
@ -56,7 +56,7 @@ paths/ XDG path resolution
|
||||||
|
|
||||||
**`tools.Server`** — interface for a tool provider: `ListTools`, `CallTool`. Implementations: `execute.Server` (local) and `execute.RemoteServer` (SSH).
|
**`tools.Server`** — interface for a tool provider: `ListTools`, `CallTool`. Implementations: `execute.Server` (local) and `execute.RemoteServer` (SSH).
|
||||||
|
|
||||||
**`tools.Registry`** — per-session dynamic tool loading. Discovers scripts from `OLLIE_TOOLS_PATH`, parses schemas, promotes to native callable functions on demand.
|
**`tools.Registry`** — per-session dynamic tool loading. Discovers scripts from `$XDG_CONFIG_HOME/ollie/tools`, parses schemas, promotes to native callable functions on demand.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
|
@ -91,11 +91,11 @@ One built-in tool via `execute.Server`:
|
||||||
|
|
||||||
**`shell`** — run a bash command in a sandbox. Accepts `cmd`, `timeout` (default 30s, 0 for unlimited), `sandbox` (profile name), `elevated` (bypass sandbox via elevation broker).
|
**`shell`** — run a bash command in a sandbox. Accepts `cmd`, `timeout` (default 30s, 0 for unlimited), `sandbox` (profile name), `elevated` (bypass sandbox via elevation broker).
|
||||||
|
|
||||||
Named tool scripts from `OLLIE_TOOLS_PATH` are promoted to native callable functions via `tools.Registry`. Each script declares its schema and metadata in header comments (`ollie:prompt`, `args_json:`, `ollie:tier`, `ollie:parallel read`).
|
Named tool scripts from `$XDG_CONFIG_HOME/ollie/tools` are promoted to native callable functions via `tools.Registry`. Each script declares its schema and metadata in header comments (`ollie:prompt`, `args_json:`, `ollie:tier`, `ollie:parallel read`).
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
Skills are domain-specific knowledge modules in `OLLIE_SKILLS_PATH` (default: `~/.config/ollie/skills/`). Each is a directory containing a `SKILL.md` with YAML front-matter (name, description). Loaded into session context on demand via `skill_load`.
|
Skills are domain-specific knowledge modules in `$XDG_CONFIG_HOME/ollie/skills` (default: `~/.config/ollie/skills/`). Each is a directory containing a `SKILL.md` with YAML front-matter (name, description). Loaded into session context on demand via `skill_load`.
|
||||||
|
|
||||||
## Session lifecycle
|
## Session lifecycle
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,6 @@ type Agent struct {
|
||||||
readPlanStep func() string
|
readPlanStep func() string
|
||||||
saveSession func() // trigger debounced persistence
|
saveSession func() // trigger debounced persistence
|
||||||
flushSave func() // immediately flush persistence
|
flushSave func() // immediately flush persistence
|
||||||
turnError func(ctx context.Context, errType, errMsg string) HookResult // overridable for tests
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend returns the active backend from the runtime.
|
// Backend returns the active backend from the runtime.
|
||||||
|
|
|
||||||
|
|
@ -5,24 +5,6 @@ import (
|
||||||
"io"
|
"io"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HookCmds holds one or more shell commands for a hook. It unmarshals from
|
|
||||||
// either a JSON string ("cmd") or array (["cmd1","cmd2"]).
|
|
||||||
type HookCmds []string
|
|
||||||
|
|
||||||
func (h *HookCmds) UnmarshalJSON(data []byte) error {
|
|
||||||
var s string
|
|
||||||
if err := json.Unmarshal(data, &s); err == nil {
|
|
||||||
*h = HookCmds{s}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
var ss []string
|
|
||||||
if err := json.Unmarshal(data, &ss); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
*h = HookCmds(ss)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prompt holds the agent prompt. It unmarshals from either a JSON string
|
// Prompt holds the agent prompt. It unmarshals from either a JSON string
|
||||||
// (treated as literal text, with the existing resolvePrompt semantics) or
|
// (treated as literal text, with the existing resolvePrompt semantics) or
|
||||||
// an array of strings (each element is a shell command whose stdout is
|
// an array of strings (each element is a shell command whose stdout is
|
||||||
|
|
@ -59,7 +41,6 @@ func (p Prompt) MarshalJSON() ([]byte, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentConfig struct {
|
type AgentConfig struct {
|
||||||
Hooks map[string]HookCmds `json:"hooks,omitempty"`
|
|
||||||
Prompt Prompt `json:"prompt,omitempty"`
|
Prompt Prompt `json:"prompt,omitempty"`
|
||||||
Backend string `json:"backend,omitempty"`
|
Backend string `json:"backend,omitempty"`
|
||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -36,14 +36,10 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string
|
||||||
allToolInfos = append(allToolInfos, toolsrv.DiscoverTools()...)
|
allToolInfos = append(allToolInfos, toolsrv.DiscoverTools()...)
|
||||||
}
|
}
|
||||||
|
|
||||||
hooks := Hooks{}
|
|
||||||
var preamble string
|
var preamble string
|
||||||
var genParams backend.GenerationParams
|
var genParams backend.GenerationParams
|
||||||
var maxSteps int
|
var maxSteps int
|
||||||
if cfg != nil {
|
if cfg != nil {
|
||||||
for k, v := range cfg.Hooks {
|
|
||||||
hooks[k] = []string(v)
|
|
||||||
}
|
|
||||||
if resolved, err := resolvePrompt(cfg.Prompt, cwd, env); err != nil {
|
if resolved, err := resolvePrompt(cfg.Prompt, cwd, env); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "resolve prompt: %v\n", err)
|
fmt.Fprintf(os.Stderr, "resolve prompt: %v\n", err)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -145,7 +141,6 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string
|
||||||
Exec: exec,
|
Exec: exec,
|
||||||
ClassifyTool: classify,
|
ClassifyTool: classify,
|
||||||
ClassifyTier: tierFn,
|
ClassifyTier: tierFn,
|
||||||
Hooks: hooks,
|
|
||||||
Preamble: preamble,
|
Preamble: preamble,
|
||||||
GenParams: genParams,
|
GenParams: genParams,
|
||||||
MaxSteps: maxSteps,
|
MaxSteps: maxSteps,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"ollie/paths"
|
"ollie/paths"
|
||||||
)
|
)
|
||||||
|
|
@ -12,24 +11,18 @@ func DefaultPromptsDir() string {
|
||||||
return paths.CfgDir() + "/prompts"
|
return paths.CfgDir() + "/prompts"
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromptsDirs returns all prompt directories from OLLIE_PROMPTS_PATH (colon-separated).
|
// PromptsDirs returns all prompt directories.
|
||||||
func PromptsDirs() []string {
|
func PromptsDirs() []string {
|
||||||
if p := os.Getenv("OLLIE_PROMPTS_PATH"); p != "" {
|
return paths.PromptsDirs()
|
||||||
return strings.Split(p, ":")
|
|
||||||
}
|
|
||||||
return []string{DefaultPromptsDir()}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentsDirs returns all agent directories from OLLIE_AGENTS_PATH (colon-separated).
|
// AgentsDirs returns all agent directories.
|
||||||
func AgentsDirs() []string {
|
func AgentsDirs() []string {
|
||||||
if p := os.Getenv("OLLIE_AGENTS_PATH"); p != "" {
|
return paths.AgentsDirs()
|
||||||
return strings.Split(p, ":")
|
|
||||||
}
|
|
||||||
return []string{paths.CfgDir() + "/agents"}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentConfigPath resolves the config file path for a named agent.
|
// AgentConfigPath resolves the config file path for a named agent.
|
||||||
// It searches all directories in OLLIE_AGENTS_PATH, falling back to agentsDir.
|
// It searches all agent directories, falling back to agentsDir.
|
||||||
func AgentConfigPath(agentsDir, name string) string {
|
func AgentConfigPath(agentsDir, name string) string {
|
||||||
for _, dir := range AgentsDirs() {
|
for _, dir := range AgentsDirs() {
|
||||||
p := dir + "/" + name + ".json"
|
p := dir + "/" + name + ".json"
|
||||||
|
|
|
||||||
237
agent/hooks.go
237
agent/hooks.go
|
|
@ -1,237 +0,0 @@
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
olog "ollie/log"
|
|
||||||
"ollie/paths"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Hook name constants for well-known agent lifecycle events.
|
|
||||||
const (
|
|
||||||
HookAgentSpawn = "agentSpawn"
|
|
||||||
HookPreTurn = "preTurn"
|
|
||||||
HookPostTurn = "postTurn"
|
|
||||||
HookPreTool = "preTool"
|
|
||||||
HookPostTool = "postTool"
|
|
||||||
HookPreCompact = "preCompact"
|
|
||||||
HookPostCompact = "postCompact"
|
|
||||||
HookTurnError = "turnError"
|
|
||||||
)
|
|
||||||
|
|
||||||
const defaultHookTimeout = 60
|
|
||||||
|
|
||||||
// hookTimeout is the hook execution timeout in seconds. Overridable in tests.
|
|
||||||
var hookTimeout = defaultHookTimeout
|
|
||||||
|
|
||||||
// Hooks maps hook names to one or more shell commands.
|
|
||||||
type Hooks map[string][]string
|
|
||||||
|
|
||||||
// HookResult holds the outcome of running a hook.
|
|
||||||
type HookResult struct {
|
|
||||||
// Ran is true when a hook command was configured and executed.
|
|
||||||
Ran bool
|
|
||||||
// Handled is true when all hook commands exited 0 (no warnings, not blocked).
|
|
||||||
// Use this to check whether a hook fully handled an event (e.g. turnError).
|
|
||||||
Handled bool
|
|
||||||
// Blocked is true when the hook wants to prevent the action (exit 2).
|
|
||||||
// For Stop hooks, Blocked means "don't stop, continue".
|
|
||||||
Blocked bool
|
|
||||||
// Context is stdout from the hook, injected into the conversation.
|
|
||||||
Context string
|
|
||||||
// Warning, if non-empty, is a message about hook execution problems
|
|
||||||
// (e.g. timeout, start failure) that should be surfaced to the user.
|
|
||||||
Warning string
|
|
||||||
// Total is the number of hook commands configured for this event.
|
|
||||||
Total int
|
|
||||||
// Succeeded is the number of hook commands that exited 0.
|
|
||||||
Succeeded int
|
|
||||||
// Failed is the number of hook commands that did not succeed (non-zero, timeout, start failure).
|
|
||||||
Failed int
|
|
||||||
// FailedCmds identifies which commands failed (truncated to 40 chars each).
|
|
||||||
FailedCmds []string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run executes all commands for the named hook in order, sending payload as
|
|
||||||
// JSON on stdin for each. Returns a combined HookResult.
|
|
||||||
//
|
|
||||||
// Exit codes per command:
|
|
||||||
// - 0: success. Stdout is appended to combined context.
|
|
||||||
// - 2: block. Stops execution immediately and returns blocked.
|
|
||||||
// - other: non-blocking warning (stderr logged, execution continues).
|
|
||||||
func (h Hooks) Run(ctx context.Context, name string, payload any, log *olog.Logger) HookResult {
|
|
||||||
cmds := h[name]
|
|
||||||
if len(cmds) == 0 {
|
|
||||||
return HookResult{}
|
|
||||||
}
|
|
||||||
|
|
||||||
payloadJSON, _ := json.Marshal(payload)
|
|
||||||
var cwd string
|
|
||||||
if m, ok := payload.(map[string]string); ok {
|
|
||||||
cwd = m["cwd"]
|
|
||||||
}
|
|
||||||
|
|
||||||
total := len(cmds)
|
|
||||||
var contextParts []string
|
|
||||||
var warnings []string
|
|
||||||
var failedCmds []string
|
|
||||||
succeeded := 0
|
|
||||||
failed := 0
|
|
||||||
allHandled := true
|
|
||||||
for _, cmdStr := range cmds {
|
|
||||||
log.Debug("hook %s: cmd=%q", name, cmdStr)
|
|
||||||
result := runHookCmd(ctx, name, cmdStr, payloadJSON, cwd, log)
|
|
||||||
if result.Warning != "" {
|
|
||||||
warnings = append(warnings, result.Warning)
|
|
||||||
allHandled = false
|
|
||||||
failed++
|
|
||||||
failedCmds = append(failedCmds, truncateCmd(cmdStr))
|
|
||||||
} else if !result.Ran {
|
|
||||||
allHandled = false
|
|
||||||
failed++
|
|
||||||
failedCmds = append(failedCmds, truncateCmd(cmdStr))
|
|
||||||
} else if result.Blocked {
|
|
||||||
// Blocked counts as "ran" for accounting but stops iteration.
|
|
||||||
succeeded++
|
|
||||||
return HookResult{
|
|
||||||
Ran: true, Blocked: true, Context: result.Context,
|
|
||||||
Warning: strings.Join(warnings, "; "),
|
|
||||||
Total: total,
|
|
||||||
Succeeded: succeeded,
|
|
||||||
Failed: failed,
|
|
||||||
FailedCmds: failedCmds,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
succeeded++
|
|
||||||
if result.Context != "" {
|
|
||||||
contextParts = append(contextParts, result.Context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return HookResult{
|
|
||||||
Ran: true, Handled: allHandled,
|
|
||||||
Context: strings.Join(contextParts, "\n"),
|
|
||||||
Warning: strings.Join(warnings, "; "),
|
|
||||||
Total: total,
|
|
||||||
Succeeded: succeeded,
|
|
||||||
Failed: failed,
|
|
||||||
FailedCmds: failedCmds,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runHookCmd(ctx context.Context, name, cmdStr string, payloadJSON []byte, cwd string, log *olog.Logger) HookResult {
|
|
||||||
cmd := exec.CommandContext(ctx, "sh", "-c", cmdStr)
|
|
||||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
|
||||||
cmd.Stdin = bytes.NewReader(payloadJSON)
|
|
||||||
if cwd != "" {
|
|
||||||
expanded := paths.ExpandHome(cwd)
|
|
||||||
// Only set Dir if the path exists locally (remote sessions
|
|
||||||
// have a CWD that only exists on the remote host).
|
|
||||||
if _, err := os.Stat(expanded); err == nil {
|
|
||||||
cmd.Dir = expanded
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject payload map keys as OLLIE_* environment variables so hook
|
|
||||||
// commands can use $OLLIE_SESSION_ID, $OLLIE_MODEL, etc. without
|
|
||||||
// parsing the JSON payload on stdin.
|
|
||||||
var payloadMap map[string]string
|
|
||||||
if err := json.Unmarshal(payloadJSON, &payloadMap); err == nil {
|
|
||||||
env := cmd.Environ()
|
|
||||||
for k, v := range payloadMap {
|
|
||||||
env = append(env, "OLLIE_"+strings.ToUpper(k)+"="+v)
|
|
||||||
}
|
|
||||||
cmd.Env = env
|
|
||||||
}
|
|
||||||
|
|
||||||
var stdout, stderr bytes.Buffer
|
|
||||||
cmd.Stdout = &stdout
|
|
||||||
cmd.Stderr = &stderr
|
|
||||||
|
|
||||||
done := make(chan error, 1)
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
log.Debug("hook %s: start error: %v", name, err)
|
|
||||||
return HookResult{Ran: true, Warning: fmt.Sprintf("hook %s: failed to start: %v", name, err)}
|
|
||||||
}
|
|
||||||
go func() { done <- cmd.Wait() }()
|
|
||||||
|
|
||||||
timeout := time.After(time.Duration(hookTimeout) * time.Second)
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
exitCode := 0
|
|
||||||
if err != nil {
|
|
||||||
var exitErr *exec.ExitError
|
|
||||||
if errors.As(err, &exitErr) {
|
|
||||||
exitCode = exitErr.ExitCode()
|
|
||||||
} else {
|
|
||||||
log.Debug("hook %s: wait error: %v", name, err)
|
|
||||||
return HookResult{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch exitCode {
|
|
||||||
case 0:
|
|
||||||
out := strings.TrimSpace(stdout.String())
|
|
||||||
log.Debug("hook %s: exit=0 context_len=%d", name, len(out))
|
|
||||||
return HookResult{Ran: true, Context: out}
|
|
||||||
case 2:
|
|
||||||
msg := strings.TrimSpace(stderr.String())
|
|
||||||
log.Debug("hook %s: exit=2 (blocked) msg=%q", name, msg)
|
|
||||||
return HookResult{Ran: true, Blocked: true, Context: msg}
|
|
||||||
default:
|
|
||||||
log.Debug("hook %s: exit=%d (non-blocking error) stderr=%q", name, exitCode, stderr.String())
|
|
||||||
return HookResult{Ran: false, Warning: fmt.Sprintf("hook %s: exit %d", name, exitCode)}
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) //nolint:errcheck
|
|
||||||
<-done
|
|
||||||
log.Debug("hook %s: cancelled (context done)", name)
|
|
||||||
return HookResult{}
|
|
||||||
case <-timeout:
|
|
||||||
syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) //nolint:errcheck
|
|
||||||
<-done
|
|
||||||
log.Debug("hook %s: timed out after %ds", name, hookTimeout)
|
|
||||||
return HookResult{Ran: true, Warning: fmt.Sprintf("hook %s: timed out after %ds", name, hookTimeout)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary returns a human-readable summary of hook execution, e.g.
|
|
||||||
// "(2 of 3 hooks run) (1 of 3 failed: my-script.sh)"
|
|
||||||
func (r HookResult) Summary() string {
|
|
||||||
if !r.Ran || r.Total == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
ran := r.Succeeded + r.Failed // commands that were attempted
|
|
||||||
s := fmt.Sprintf("(%d of %d hooks run)", ran, r.Total)
|
|
||||||
if r.Failed > 0 {
|
|
||||||
detail := strings.Join(r.FailedCmds, ", ")
|
|
||||||
s += fmt.Sprintf(" (%d of %d failed: %s)", r.Failed, r.Total, detail)
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
// truncateCmd returns a short identifier for a hook command string.
|
|
||||||
func truncateCmd(cmd string) string {
|
|
||||||
cmd = strings.TrimSpace(cmd)
|
|
||||||
if len(cmd) <= 40 {
|
|
||||||
return cmd
|
|
||||||
}
|
|
||||||
return cmd[:37] + "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
// hooksRan returns a display string for N hooks having run, e.g. "1 hook run".
|
|
||||||
// Deprecated: prefer HookResult.Summary() for richer output.
|
|
||||||
func hooksRan(n int) string {
|
|
||||||
if n == 1 {
|
|
||||||
return "1 hook run"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%d hooks run", n)
|
|
||||||
}
|
|
||||||
|
|
@ -55,9 +55,6 @@ type agentConfig struct {
|
||||||
PopInject func() string // returns and clears pending inject, or ""
|
PopInject func() string // returns and clears pending inject, or ""
|
||||||
AutoCompact func(ctx context.Context) // called after each tool round; may compact in-place
|
AutoCompact func(ctx context.Context) // called after each tool round; may compact in-place
|
||||||
SaveSession func() // called after each state.update(); persists mid-turn progress
|
SaveSession func() // called after each state.update(); persists mid-turn progress
|
||||||
PreTool func(ctx context.Context, name string, args json.RawMessage) HookResult // called before each tool; exit 2 blocks execution
|
|
||||||
PostTool func(ctx context.Context, name string, args json.RawMessage, result string) HookResult // called after each tool; exit 0 appends, exit 2 replaces result
|
|
||||||
TurnError func(ctx context.Context, errType, errMsg string) HookResult // called on first backend error; if ran, skips retries
|
|
||||||
// MaxSteps is the maximum number of tool-call rounds per turn.
|
// MaxSteps is the maximum number of tool-call rounds per turn.
|
||||||
// When reached, a soft nudge is injected and the loop exits cleanly.
|
// When reached, a soft nudge is injected and the loop exits cleanly.
|
||||||
// 0 means unlimited.
|
// 0 means unlimited.
|
||||||
|
|
@ -127,15 +124,6 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
// On the first error, fire the turnError hook. If it handles
|
|
||||||
// the error (exit 0), return immediately — the hook is
|
|
||||||
// responsible for recovery (e.g. switching model and resubmitting).
|
|
||||||
if attempt == 0 && cfg.TurnError != nil {
|
|
||||||
errType := classifyError(err)
|
|
||||||
if r := cfg.TurnError(ctx, errType, err.Error()); r.Handled {
|
|
||||||
return fmt.Errorf("step %d: %w", step, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
wait, retryable := transientWait(err, attempt)
|
wait, retryable := transientWait(err, attempt)
|
||||||
if !retryable || attempt >= maxTransientRetries {
|
if !retryable || attempt >= maxTransientRetries {
|
||||||
return fmt.Errorf("step %d: %w", step, err)
|
return fmt.Errorf("step %d: %w", step, err)
|
||||||
|
|
@ -281,20 +269,6 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
return cr, true
|
return cr, true
|
||||||
}
|
}
|
||||||
emit(cfg, Event{Role: "call", Name: tc.Name, Content: string(tc.Arguments)})
|
emit(cfg, Event{Role: "call", Name: tc.Name, Content: string(tc.Arguments)})
|
||||||
if cfg.PreTool != nil {
|
|
||||||
hr := cfg.PreTool(ctx, tc.Name, tc.Arguments)
|
|
||||||
if hr.Failed > 0 {
|
|
||||||
emit(cfg, Event{Role: "info", Content: "preTool: " + hr.Summary()})
|
|
||||||
}
|
|
||||||
if hr.Blocked {
|
|
||||||
blocked := hr.Context
|
|
||||||
if blocked == "" {
|
|
||||||
blocked = fmt.Sprintf("tool %q blocked by hook", tc.Name)
|
|
||||||
}
|
|
||||||
emit(cfg, Event{Role: "tool", Name: tc.Name, Content: blocked})
|
|
||||||
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: blocked, IsError: true}, false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
readSafe := cfg.ClassifyTool != nil && cfg.ClassifyTool(tc.Name)
|
readSafe := cfg.ClassifyTool != nil && cfg.ClassifyTool(tc.Name)
|
||||||
if readSafe {
|
if readSafe {
|
||||||
key := tc.Name + "\x00" + string(tc.Arguments)
|
key := tc.Name + "\x00" + string(tc.Arguments)
|
||||||
|
|
@ -348,20 +322,9 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
||||||
result = "error: no tool executor configured"
|
result = "error: no tool executor configured"
|
||||||
isErr = true
|
isErr = true
|
||||||
}
|
}
|
||||||
// Accumulate suffix text (PostTool context, user-interruptions,
|
// Accumulate suffix text (user-interruptions,
|
||||||
// truncation hints) that must be emitted after streaming completes.
|
// truncation hints) that must be emitted after streaming completes.
|
||||||
var suffix string
|
var suffix string
|
||||||
if cfg.PostTool != nil {
|
|
||||||
hr := cfg.PostTool(ctx, tc.Name, tc.Arguments, result)
|
|
||||||
if hr.Failed > 0 {
|
|
||||||
emit(cfg, Event{Role: "info", Content: "postTool: " + hr.Summary()})
|
|
||||||
}
|
|
||||||
if hr.Blocked {
|
|
||||||
result = hr.Context
|
|
||||||
} else if hr.Context != "" {
|
|
||||||
suffix += "\n" + hr.Context
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if cfg.PopInject != nil {
|
if cfg.PopInject != nil {
|
||||||
if injected := cfg.PopInject(); injected != "" {
|
if injected := cfg.PopInject(); injected != "" {
|
||||||
suffix += "\n\n<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
|
suffix += "\n\n<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,6 @@ func NewAgent(cfg AgentCfg) *Agent {
|
||||||
state: "idle",
|
state: "idle",
|
||||||
}
|
}
|
||||||
ag.changeCond = sync.NewCond(&ag.changeMu)
|
ag.changeCond = sync.NewCond(&ag.changeMu)
|
||||||
ag.turnError = ag.defaultTurnError
|
|
||||||
ag.wireToolsChanged()
|
ag.wireToolsChanged()
|
||||||
return ag
|
return ag
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"ollie/paths"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PromptEnv returns the standard PRIME_* environment variables for prompt
|
// PromptEnv returns the standard PRIME_* environment variables for prompt
|
||||||
|
|
@ -108,9 +109,8 @@ func resolveExecPrompt(cmds []string, cwd string, env []string) (string, error)
|
||||||
|
|
||||||
// resolvePromptFile attempts to resolve entry as a prompt file.
|
// resolvePromptFile attempts to resolve entry as a prompt file.
|
||||||
// It first checks if the entry (after env expansion) is an absolute path to an
|
// 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
|
// existing file. Otherwise it searches CfgDir()/prompts for entry and entry.md.
|
||||||
// entry and entry.md. Returns the file content (with env vars expanded) and
|
// Returns the file content (with env vars expanded) and true if found.
|
||||||
// true if found.
|
|
||||||
func resolvePromptFile(entry string, envMap map[string]string, expand func(string) string) (string, bool) {
|
func resolvePromptFile(entry string, envMap map[string]string, expand func(string) string) (string, bool) {
|
||||||
expanded := expand(entry)
|
expanded := expand(entry)
|
||||||
|
|
||||||
|
|
@ -131,12 +131,8 @@ func resolvePromptFile(entry string, envMap map[string]string, expand func(strin
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search OLLIE_PROMPTS_PATH.
|
// Search CfgDir()/prompts.
|
||||||
promptsPath := envMap["OLLIE_PROMPTS_PATH"]
|
for _, dir := range paths.PromptsDirs() {
|
||||||
if promptsPath == "" {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
for _, dir := range filepath.SplitList(promptsPath) {
|
|
||||||
if dir == "" {
|
if dir == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import (
|
||||||
type Runtime struct {
|
type Runtime struct {
|
||||||
Backend backend.Backend
|
Backend backend.Backend
|
||||||
ToolServer toolsrv.Runner // the execute server (tool runtime, env, cwd)
|
ToolServer toolsrv.Runner // the execute server (tool runtime, env, cwd)
|
||||||
Hooks Hooks
|
|
||||||
Preamble string // compiled system prompt
|
Preamble string // compiled system prompt
|
||||||
Tools []backend.Tool
|
Tools []backend.Tool
|
||||||
Exec toolExecutor
|
Exec toolExecutor
|
||||||
|
|
|
||||||
172
agent/turn.go
172
agent/turn.go
|
|
@ -2,14 +2,11 @@ package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"ollie/backend"
|
"ollie/backend"
|
||||||
)
|
)
|
||||||
|
|
@ -72,28 +69,8 @@ func (ag *Agent) Submit(ctx context.Context, input string) {
|
||||||
func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
||||||
ag.emit(Event{Role: "user", Content: input})
|
ag.emit(Event{Role: "user", Content: input})
|
||||||
|
|
||||||
hookResult := ag.runtime.Hooks.Run(ctx, HookPreTurn, map[string]string{
|
|
||||||
"session_id": ag.sessionID,
|
|
||||||
"cwd": ag.effectiveCwd(),
|
|
||||||
"prompt": input,
|
|
||||||
}, ag.log)
|
|
||||||
if hookResult.Blocked {
|
|
||||||
ag.emit(infoEvent("hook blocked prompt"))
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if hookResult.Warning != "" {
|
|
||||||
ag.emit(infoEvent(hookResult.Warning))
|
|
||||||
}
|
|
||||||
if sum := hookResult.Summary(); sum != "" {
|
|
||||||
ag.emit(infoEvent("preTurn: " + sum))
|
|
||||||
}
|
|
||||||
if hookResult.Context != "" {
|
|
||||||
input += "\n" + hookResult.Context
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshot session state before this turn modifies it. Restored on failure
|
// Snapshot session state before this turn modifies it. Restored on failure
|
||||||
// so the session is clean for the next attempt.
|
// so the session is clean for the next attempt.
|
||||||
snapSession := ag.history
|
|
||||||
var snapMessages []backend.Message
|
var snapMessages []backend.Message
|
||||||
if ag.history != nil {
|
if ag.history != nil {
|
||||||
snapMessages = cloneMessages(ag.history.messages)
|
snapMessages = cloneMessages(ag.history.messages)
|
||||||
|
|
@ -132,7 +109,6 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
||||||
GenerationParams: ag.runtime.GenParams,
|
GenerationParams: ag.runtime.GenParams,
|
||||||
MaxSteps: ag.runtime.MaxSteps,
|
MaxSteps: ag.runtime.MaxSteps,
|
||||||
ReadPlanStep: ag.readPlanStep,
|
ReadPlanStep: ag.readPlanStep,
|
||||||
TurnError: ag.turnError,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var replyBuf strings.Builder
|
var replyBuf strings.Builder
|
||||||
|
|
@ -164,12 +140,6 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
||||||
CostUSD: costUSD,
|
CostUSD: costUSD,
|
||||||
}, est != 0)
|
}, est != 0)
|
||||||
ag.notifyChange()
|
ag.notifyChange()
|
||||||
if maxCostStr := os.Getenv("OLLIE_MAX_SESSION_COST"); maxCostStr != "" {
|
|
||||||
if limit, ferr := strconv.ParseFloat(maxCostStr, 64); ferr == nil && limit > 0 && ag.history.SessionCostUSD >= limit {
|
|
||||||
ag.emit(infoEvent(fmt.Sprintf("spending cap $%.2f reached — stopping", limit)))
|
|
||||||
ag.Interrupt(ErrInterrupted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ag.emit(ev)
|
ag.emit(ev)
|
||||||
}
|
}
|
||||||
|
|
@ -179,23 +149,6 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
ag.cfg.PreTool = func(ctx context.Context, name string, args json.RawMessage) HookResult {
|
|
||||||
return ag.runtime.Hooks.Run(ctx, HookPreTool, map[string]string{
|
|
||||||
"session_id": ag.sessionID,
|
|
||||||
"cwd": ag.effectiveCwd(),
|
|
||||||
"tool": name,
|
|
||||||
"args": string(args),
|
|
||||||
}, ag.log)
|
|
||||||
}
|
|
||||||
ag.cfg.PostTool = func(ctx context.Context, name string, args json.RawMessage, result string) HookResult {
|
|
||||||
return ag.runtime.Hooks.Run(ctx, HookPostTool, map[string]string{
|
|
||||||
"session_id": ag.sessionID,
|
|
||||||
"cwd": ag.effectiveCwd(),
|
|
||||||
"tool": name,
|
|
||||||
"args": string(args),
|
|
||||||
"result": result,
|
|
||||||
}, ag.log)
|
|
||||||
}
|
|
||||||
ag.cfg.IncrToolCallCount = func() int64 {
|
ag.cfg.IncrToolCallCount = func() int64 {
|
||||||
return ag.toolCallCount.Add(1)
|
return ag.toolCallCount.Add(1)
|
||||||
}
|
}
|
||||||
|
|
@ -238,24 +191,6 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Spending cap: reject before spending more tokens.
|
|
||||||
if maxCostStr := os.Getenv("OLLIE_MAX_SESSION_COST"); maxCostStr != "" && ag.history != nil {
|
|
||||||
if limit, err := strconv.ParseFloat(maxCostStr, 64); err == nil && limit > 0 {
|
|
||||||
if ag.history.SessionCostUSD >= limit {
|
|
||||||
ag.emit(Event{Role: "error", Content: fmt.Sprintf("spending cap $%.2f reached (session total $%.4f)", limit, ag.history.SessionCostUSD)})
|
|
||||||
ag.SetState("idle")
|
|
||||||
actCancel(nil)
|
|
||||||
ag.currentAction.CompareAndSwap(handle, nil)
|
|
||||||
if snapSession == nil {
|
|
||||||
ag.history = nil
|
|
||||||
} else {
|
|
||||||
ag.history.messages = snapMessages
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ag.history != nil {
|
if ag.history != nil {
|
||||||
ag.history.resetTurnAccumulators()
|
ag.history.resetTurnAccumulators()
|
||||||
}
|
}
|
||||||
|
|
@ -315,27 +250,9 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
ag.emit(Event{Role: "error", Content: err.Error()})
|
ag.emit(Event{Role: "error", Content: err.Error()})
|
||||||
// Drain one FIFO item — the turnError hook may have queued a recovery prompt.
|
|
||||||
if next, ok := ag.fifo.Pop(); ok {
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
stopResult := ag.runtime.Hooks.Run(ctx, HookPostTurn, map[string]string{
|
|
||||||
"session_id": ag.sessionID,
|
|
||||||
"cwd": ag.effectiveCwd(),
|
|
||||||
}, ag.log)
|
|
||||||
if stopResult.Warning != "" {
|
|
||||||
ag.emit(infoEvent(stopResult.Warning))
|
|
||||||
}
|
|
||||||
if sum := stopResult.Summary(); sum != "" {
|
|
||||||
ag.emit(infoEvent("postTurn: " + sum))
|
|
||||||
}
|
|
||||||
if !stopResult.Blocked && stopResult.Context != "" && ag.history != nil {
|
|
||||||
ag.history.appendUserMessage(stopResult.Context)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ag.history != nil {
|
if ag.history != nil {
|
||||||
ag.history.recordTurnCost(ag.cfg.Backend.Model())
|
ag.history.recordTurnCost(ag.cfg.Backend.Model())
|
||||||
appendUsageLog(ag.sessionID, ag.cfg.Backend.Name(), ag.cfg.Backend.Model(), ag.history)
|
appendUsageLog(ag.sessionID, ag.cfg.Backend.Name(), ag.cfg.Backend.Model(), ag.history)
|
||||||
|
|
@ -348,11 +265,6 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
||||||
}
|
}
|
||||||
ag.saveSession()
|
ag.saveSession()
|
||||||
|
|
||||||
// Post-turn hook said "continue" — its context becomes the next prompt.
|
|
||||||
if stopResult.Blocked && stopResult.Context != "" {
|
|
||||||
return stopResult.Context
|
|
||||||
}
|
|
||||||
|
|
||||||
// Inject that was pending but never consumed (text-only response with no
|
// Inject that was pending but never consumed (text-only response with no
|
||||||
// tool calls) — treat it as the next user message.
|
// tool calls) — treat it as the next user message.
|
||||||
if p := ag.pendingInject.Swap(nil); p != nil {
|
if p := ag.pendingInject.Swap(nil); p != nil {
|
||||||
|
|
@ -367,19 +279,18 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// turnError is called on the first backend error of a turn. If the hook handles
|
// spawnContext assembles the agent context injected at each session refresh
|
||||||
// the error (exit 0), the turn exits immediately.
|
// point (session start, post-clear, post-compaction). It combines the
|
||||||
// This is the default implementation; it can be overridden via the turnError field.
|
// agent-specific prompt with any AGENTS.md from the working directory.
|
||||||
func (ag *Agent) defaultTurnError(ctx context.Context, errType, errMsg string) HookResult {
|
func (ag *Agent) spawnContext(ctx context.Context) string {
|
||||||
hookCtx, cancel := context.WithTimeout(context.Background(), time.Duration(hookTimeout)*time.Second)
|
var parts []string
|
||||||
defer cancel()
|
// Inject AGENTS.md from the working directory if it exists.
|
||||||
return ag.runtime.Hooks.Run(hookCtx, HookTurnError, map[string]string{
|
if cwd := ag.effectiveCwd(); cwd != "" {
|
||||||
"session_id": ag.sessionID,
|
if data, err := os.ReadFile(cwd + "/AGENTS.md"); err == nil && len(data) > 0 {
|
||||||
"cwd": ag.effectiveCwd(),
|
parts = append(parts, string(data))
|
||||||
"model": ag.runtime.Backend.Model(),
|
}
|
||||||
"error_type": errType,
|
}
|
||||||
"error": errMsg,
|
return strings.Join(parts, "\n\n---\n\n")
|
||||||
}, ag.log)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// autoCompactLimit returns the token threshold for auto-compaction (75%).
|
// autoCompactLimit returns the token threshold for auto-compaction (75%).
|
||||||
|
|
@ -400,54 +311,9 @@ func (ag *Agent) autoWarnLimit(ctx context.Context) int {
|
||||||
return ctxLen * 3 / 5
|
return ctxLen * 3 / 5
|
||||||
}
|
}
|
||||||
|
|
||||||
// spawnContext assembles the agent context injected at each session refresh
|
// runCompact executes a full compaction cycle: compact, spawn-context
|
||||||
// point (session start, post-clear, post-compaction). It combines the
|
// re-injection. Returns (n compacted, error). Caller manages setState.
|
||||||
// agent-specific prompt with any agentSpawn hook output.
|
|
||||||
func (ag *Agent) spawnContext(ctx context.Context) string {
|
|
||||||
result := ag.runtime.Hooks.Run(ctx, HookAgentSpawn, map[string]string{
|
|
||||||
"session_id": ag.sessionID,
|
|
||||||
"agent": ag.agentName,
|
|
||||||
"cwd": ag.effectiveCwd(),
|
|
||||||
"model": ag.runtime.Backend.Model(),
|
|
||||||
}, ag.log)
|
|
||||||
if result.Warning != "" {
|
|
||||||
ag.emit(infoEvent(result.Warning))
|
|
||||||
}
|
|
||||||
if sum := result.Summary(); sum != "" {
|
|
||||||
ag.emit(infoEvent("agentSpawn: " + sum))
|
|
||||||
}
|
|
||||||
var parts []string
|
|
||||||
if result.Context != "" {
|
|
||||||
parts = append(parts, result.Context)
|
|
||||||
}
|
|
||||||
// Inject AGENTS.md from the working directory if it exists.
|
|
||||||
if cwd := ag.effectiveCwd(); cwd != "" {
|
|
||||||
if data, err := os.ReadFile(cwd + "/AGENTS.md"); err == nil && len(data) > 0 {
|
|
||||||
parts = append(parts, string(data))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.Join(parts, "\n\n---\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// runCompact executes a full compaction cycle: pre-hook, compact, spawn-context
|
|
||||||
// re-injection, post-hook. Returns (n compacted, error). Returns (0, nil) if
|
|
||||||
// the pre-hook blocked or there was nothing to compact. Caller manages setState.
|
|
||||||
func (ag *Agent) runCompact(ctx context.Context, trigger string) (int, error) {
|
func (ag *Agent) runCompact(ctx context.Context, trigger string) (int, error) {
|
||||||
payload := map[string]string{"session_id": ag.sessionID, "trigger": trigger, "cwd": ag.effectiveCwd()}
|
|
||||||
pre := ag.runtime.Hooks.Run(ctx, HookPreCompact, payload, ag.log)
|
|
||||||
if pre.Warning != "" {
|
|
||||||
ag.emit(infoEvent(pre.Warning))
|
|
||||||
}
|
|
||||||
if sum := pre.Summary(); sum != "" {
|
|
||||||
ag.emit(infoEvent("preCompact: " + sum))
|
|
||||||
}
|
|
||||||
if pre.Blocked {
|
|
||||||
ag.emit(infoEvent("compact cancelled by hook"))
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
if pre.Context != "" {
|
|
||||||
ag.history.appendUserMessage(pre.Context)
|
|
||||||
}
|
|
||||||
// Use a cheaper model for compaction if configured.
|
// Use a cheaper model for compaction if configured.
|
||||||
compactModel := resolveCompactionModel(ag.runtime.CompactionModel, ag.runtime.Backend)
|
compactModel := resolveCompactionModel(ag.runtime.CompactionModel, ag.runtime.Backend)
|
||||||
origModel := ag.runtime.Backend.Model()
|
origModel := ag.runtime.Backend.Model()
|
||||||
|
|
@ -466,16 +332,6 @@ func (ag *Agent) runCompact(ctx context.Context, trigger string) (int, error) {
|
||||||
ag.history.appendUserMessage(sc)
|
ag.history.appendUserMessage(sc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
post := ag.runtime.Hooks.Run(ctx, HookPostCompact, payload, ag.log)
|
|
||||||
if post.Warning != "" {
|
|
||||||
ag.emit(infoEvent(post.Warning))
|
|
||||||
}
|
|
||||||
if sum := post.Summary(); sum != "" {
|
|
||||||
ag.emit(infoEvent("postCompact: " + sum))
|
|
||||||
}
|
|
||||||
if post.Context != "" {
|
|
||||||
ag.history.appendUserMessage(post.Context)
|
|
||||||
}
|
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,9 @@ type usageEntry struct {
|
||||||
CostUSD float64 `json:"cost_usd"`
|
CostUSD float64 `json:"cost_usd"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// usageLogPath returns the path for the usage log file.
|
// usageLogPath returns the path for the usage log file: DataDir()/usage.jsonl.
|
||||||
// Respects OLLIE_USAGE_LOG env var; defaults to ~/.local/share/ollie/usage.jsonl.
|
|
||||||
func usageLogPath() string {
|
func usageLogPath() string {
|
||||||
if p := os.Getenv("OLLIE_USAGE_LOG"); p != "" {
|
return paths.UsageLogFile()
|
||||||
return p
|
|
||||||
}
|
|
||||||
return filepath.Join(paths.DataDir(), "usage.jsonl")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// appendUsageLog writes a single usage entry to the append-only JSONL log.
|
// appendUsageLog writes a single usage entry to the append-only JSONL log.
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,7 @@ func loadEnvFile(path string) {
|
||||||
// so the file acts as a default; variables already set in the environment take
|
// so the file acts as a default; variables already set in the environment take
|
||||||
// precedence.
|
// precedence.
|
||||||
//
|
//
|
||||||
// OLLIE_BACKEND ollama | openai | openrouter | anthropic | copilot | kiro | gemini
|
// OLLIE_OPENAI_KEY API key (required for openai/openrouter/gemini)
|
||||||
// OLLIE_OLLAMA_URL base URL for ollama (default: http://localhost:11434)
|
|
||||||
// OLLIE_OPENAI_URL base URL for openai-compatible backends
|
|
||||||
// OLLIE_OPENAI_KEY API key (required for openai/openrouter)
|
|
||||||
// OLLIE_ANTHROPIC_KEY API key (required for anthropic)
|
// OLLIE_ANTHROPIC_KEY API key (required for anthropic)
|
||||||
// OLLIE_GEMINI_KEY Google AI Studio API key (required for gemini; falls back to OLLIE_OPENAI_KEY)
|
// OLLIE_GEMINI_KEY Google AI Studio API key (required for gemini; falls back to OLLIE_OPENAI_KEY)
|
||||||
// OLLIE_COPILOT_TOKEN bearer token (required for copilot)
|
// OLLIE_COPILOT_TOKEN bearer token (required for copilot)
|
||||||
|
|
@ -63,18 +60,28 @@ func New() (Backend, error) {
|
||||||
|
|
||||||
// NewWithName constructs a Backend for the given backend name, loading
|
// NewWithName constructs a Backend for the given backend name, loading
|
||||||
// env-file defaults for API keys/URLs but ignoring OLLIE_BACKEND.
|
// env-file defaults for API keys/URLs but ignoring OLLIE_BACKEND.
|
||||||
// If name is empty, falls back to OLLIE_BACKEND; errors if neither is set.
|
// If name is empty, falls back to ~/.config/ollie/backend; errors if neither is set.
|
||||||
func NewWithName(name string) (Backend, error) {
|
func NewWithName(name string) (Backend, error) {
|
||||||
loadEnvFile(paths.CfgDir() + "/env")
|
loadEnvFile(paths.CfgDir() + "/env")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = os.Getenv("OLLIE_BACKEND")
|
name = readDefaultBackend()
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return nil, fmt.Errorf("no backend specified: set OLLIE_BACKEND or pass backend= to the session")
|
return nil, fmt.Errorf("no backend specified: set backend= in agent config or create ~/.config/ollie/backend")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newBackend(name)
|
return newBackend(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readDefaultBackend reads the default backend from ~/.config/ollie/backend.
|
||||||
|
// Returns "" if the file doesn't exist or is empty.
|
||||||
|
func readDefaultBackend() string {
|
||||||
|
data, err := os.ReadFile(paths.CfgDir() + "/backend")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(data))
|
||||||
|
}
|
||||||
|
|
||||||
func newFromEnv(envFile string) (Backend, error) {
|
func newFromEnv(envFile string) (Backend, error) {
|
||||||
loadEnvFile(envFile)
|
loadEnvFile(envFile)
|
||||||
|
|
||||||
|
|
@ -89,14 +96,11 @@ func newFromEnv(envFile string) (Backend, error) {
|
||||||
func newBackend(which string) (Backend, error) {
|
func newBackend(which string) (Backend, error) {
|
||||||
switch which {
|
switch which {
|
||||||
case "ollama":
|
case "ollama":
|
||||||
return NewOllama(os.Getenv("OLLIE_OLLAMA_URL"))
|
return NewOllama(os.Getenv("OLLAMA_HOST"))
|
||||||
case "openai", "openrouter":
|
case "openai":
|
||||||
url := os.Getenv("OLLIE_OPENAI_URL")
|
return NewOpenAI("openai", "https://api.openai.com/v1", os.Getenv("OLLIE_OPENAI_KEY"))
|
||||||
key := os.Getenv("OLLIE_OPENAI_KEY")
|
case "openrouter":
|
||||||
if key == "" {
|
return NewOpenAI("openrouter", "https://openrouter.ai/api/v1", os.Getenv("OLLIE_OPENAI_KEY"))
|
||||||
return nil, fmt.Errorf("OLLIE_OPENAI_KEY is required for %s backend", which)
|
|
||||||
}
|
|
||||||
return NewOpenAI(openAIName(which, url), url, key)
|
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
key := os.Getenv("OLLIE_ANTHROPIC_KEY")
|
key := os.Getenv("OLLIE_ANTHROPIC_KEY")
|
||||||
if key == "" {
|
if key == "" {
|
||||||
|
|
@ -126,27 +130,3 @@ func newBackend(which string) (Backend, error) {
|
||||||
func Backends() []string {
|
func Backends() []string {
|
||||||
return []string{"ollama", "openai", "openrouter", "anthropic", "copilot", "kiro", "gemini"}
|
return []string{"ollama", "openai", "openrouter", "anthropic", "copilot", "kiro", "gemini"}
|
||||||
}
|
}
|
||||||
|
|
||||||
// openAIName derives a short backend label from the OLLIE_BACKEND value and
|
|
||||||
// the base URL, so openai-compatible endpoints self-identify correctly.
|
|
||||||
func openAIName(which, url string) string {
|
|
||||||
url = strings.ToLower(url)
|
|
||||||
switch {
|
|
||||||
case strings.Contains(url, "openrouter"):
|
|
||||||
return "openrouter"
|
|
||||||
case strings.Contains(url, "together"):
|
|
||||||
return "together"
|
|
||||||
case strings.Contains(url, "groq"):
|
|
||||||
return "groq"
|
|
||||||
case strings.Contains(url, "mistral"):
|
|
||||||
return "mistral"
|
|
||||||
case strings.Contains(url, "anthropic"):
|
|
||||||
return "anthropic"
|
|
||||||
case strings.Contains(url, "generativelanguage") || strings.Contains(url, "google"):
|
|
||||||
return "gemini"
|
|
||||||
case strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1"):
|
|
||||||
return "local"
|
|
||||||
default:
|
|
||||||
return which
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ func clearEnv(t *testing.T, keys ...string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewFromEnv_NoBackendErrors(t *testing.T) {
|
func TestNewFromEnv_NoBackendErrors(t *testing.T) {
|
||||||
clearEnv(t, "OLLIE_BACKEND", "OLLIE_OLLAMA_URL")
|
clearEnv(t, "OLLIE_BACKEND")
|
||||||
_, err := newFromEnv("/nonexistent")
|
_, err := newFromEnv("/nonexistent")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error when OLLIE_BACKEND is unset, got nil")
|
t.Fatal("expected error when OLLIE_BACKEND is unset, got nil")
|
||||||
|
|
@ -40,7 +40,7 @@ func TestNewFromEnv_NoBackendErrors(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewFromEnv_Ollama(t *testing.T) {
|
func TestNewFromEnv_Ollama(t *testing.T) {
|
||||||
setEnv(t, map[string]string{"OLLIE_BACKEND": "ollama", "OLLIE_OLLAMA_URL": "http://myhost:11434"})
|
setEnv(t, map[string]string{"OLLIE_BACKEND": "ollama"})
|
||||||
b, err := newFromEnv("/nonexistent")
|
b, err := newFromEnv("/nonexistent")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
@ -51,7 +51,7 @@ func TestNewFromEnv_Ollama(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewFromEnv_OpenAI(t *testing.T) {
|
func TestNewFromEnv_OpenAI(t *testing.T) {
|
||||||
setEnv(t, map[string]string{"OLLIE_BACKEND": "openai", "OLLIE_OPENAI_URL": "https://api.openai.com", "OLLIE_OPENAI_KEY": "sk-test"})
|
setEnv(t, map[string]string{"OLLIE_BACKEND": "openai", "OLLIE_OPENAI_KEY": "sk-test"})
|
||||||
b, err := newFromEnv("/nonexistent")
|
b, err := newFromEnv("/nonexistent")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
@ -62,7 +62,7 @@ func TestNewFromEnv_OpenAI(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewFromEnv_OpenRouter(t *testing.T) {
|
func TestNewFromEnv_OpenRouter(t *testing.T) {
|
||||||
setEnv(t, map[string]string{"OLLIE_BACKEND": "openrouter", "OLLIE_OPENAI_URL": "https://openrouter.ai/api", "OLLIE_OPENAI_KEY": "sk-or-test"})
|
setEnv(t, map[string]string{"OLLIE_BACKEND": "openrouter", "OLLIE_OPENAI_KEY": "sk-or-test"})
|
||||||
b, err := newFromEnv("/nonexistent")
|
b, err := newFromEnv("/nonexistent")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
@ -166,7 +166,6 @@ func TestNewFromEnv_EnvOverridesFile(t *testing.T) {
|
||||||
os.WriteFile(path, []byte("OLLIE_BACKEND=anthropic\n"), 0644)
|
os.WriteFile(path, []byte("OLLIE_BACKEND=anthropic\n"), 0644)
|
||||||
|
|
||||||
setEnv(t, map[string]string{"OLLIE_BACKEND": "ollama"})
|
setEnv(t, map[string]string{"OLLIE_BACKEND": "ollama"})
|
||||||
clearEnv(t, "OLLIE_OLLAMA_URL")
|
|
||||||
b, err := newFromEnv(path)
|
b, err := newFromEnv(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|
@ -176,30 +175,6 @@ func TestNewFromEnv_EnvOverridesFile(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOpenAIName(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
which, url, want string
|
|
||||||
}{
|
|
||||||
{"openai", "https://openrouter.ai/api", "openrouter"},
|
|
||||||
{"openai", "https://api.together.xyz", "together"},
|
|
||||||
{"openai", "https://api.groq.com", "groq"},
|
|
||||||
{"openai", "https://api.mistral.ai", "mistral"},
|
|
||||||
{"openai", "https://api.anthropic.com", "anthropic"},
|
|
||||||
{"openai", "http://localhost:8080", "local"},
|
|
||||||
{"openai", "http://127.0.0.1:1234", "local"},
|
|
||||||
{"openai", "https://api.openai.com", "openai"},
|
|
||||||
{"openrouter", "", "openrouter"},
|
|
||||||
{"openai", "", "openai"},
|
|
||||||
// case insensitive
|
|
||||||
{"openai", "https://API.GROQ.COM", "groq"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
if got := openAIName(tt.which, tt.url); got != tt.want {
|
|
||||||
t.Errorf("openAIName(%q, %q) = %q; want %q", tt.which, tt.url, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBackends(t *testing.T) {
|
func TestBackends(t *testing.T) {
|
||||||
bs := Backends()
|
bs := Backends()
|
||||||
if len(bs) != 7 {
|
if len(bs) != 7 {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ package backend
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -45,97 +44,3 @@ func Generate(ctx context.Context, req GenerateRequest) (string, error) {
|
||||||
}
|
}
|
||||||
return sb.String(), nil
|
return sb.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RouteRequest is the input for model routing.
|
|
||||||
type RouteRequest struct {
|
|
||||||
Task string `json:"task"`
|
|
||||||
Backend string `json:"backend"` // optional: only consider models from this backend
|
|
||||||
}
|
|
||||||
|
|
||||||
// RouteResult is the output of model routing.
|
|
||||||
type RouteResult struct {
|
|
||||||
Backend string
|
|
||||||
Model string
|
|
||||||
}
|
|
||||||
|
|
||||||
// Route classifies a task description and selects the most appropriate model
|
|
||||||
// from the actually available models. Fetches the model list from configured
|
|
||||||
// backends, presents them to a cheap classifier, and returns the selection.
|
|
||||||
func Route(ctx context.Context, req RouteRequest) (RouteResult, error) {
|
|
||||||
if req.Task == "" {
|
|
||||||
return RouteResult{}, fmt.Errorf("provide a task description")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Classifier backend/model
|
|
||||||
classifyBackend := os.Getenv("OLLIE_ROUTE_BACKEND")
|
|
||||||
if classifyBackend == "" {
|
|
||||||
classifyBackend = "ollama"
|
|
||||||
}
|
|
||||||
classifyModel := os.Getenv("OLLIE_ROUTE_MODEL")
|
|
||||||
if classifyModel == "" {
|
|
||||||
classifyModel = "qwen3:8b"
|
|
||||||
}
|
|
||||||
cbe, err := NewWithName(classifyBackend)
|
|
||||||
if err != nil {
|
|
||||||
return RouteResult{}, fmt.Errorf("route backend: %w", err)
|
|
||||||
}
|
|
||||||
cbe.SetModel(classifyModel)
|
|
||||||
|
|
||||||
// Fetch available models
|
|
||||||
type modelEntry struct {
|
|
||||||
backend string
|
|
||||||
model string
|
|
||||||
}
|
|
||||||
var entries []modelEntry
|
|
||||||
var modelList strings.Builder
|
|
||||||
|
|
||||||
backends := Backends()
|
|
||||||
if req.Backend != "" {
|
|
||||||
backends = []string{req.Backend}
|
|
||||||
}
|
|
||||||
for _, name := range backends {
|
|
||||||
b, err := NewWithName(name)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, m := range b.Models(ctx) {
|
|
||||||
entries = append(entries, modelEntry{backend: name, model: m})
|
|
||||||
fmt.Fprintf(&modelList, "%d. %s/%s\n", len(entries), name, m)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(entries) == 0 {
|
|
||||||
return RouteResult{}, fmt.Errorf("no models available")
|
|
||||||
}
|
|
||||||
|
|
||||||
classifyPrompt := fmt.Sprintf(`You are a task router. Given a task and a numbered list of available models, select the single best model for the task.
|
|
||||||
|
|
||||||
Available models:
|
|
||||||
%s
|
|
||||||
Reply with ONLY the number of the best model. Nothing else.
|
|
||||||
|
|
||||||
Task: %s`, modelList.String(), req.Task)
|
|
||||||
|
|
||||||
msgs := []Message{
|
|
||||||
{Role: "user", Content: classifyPrompt},
|
|
||||||
}
|
|
||||||
ch, err := cbe.ChatStream(ctx, msgs, nil, GenerationParams{})
|
|
||||||
if err != nil {
|
|
||||||
return RouteResult{}, fmt.Errorf("route classification failed: %w", err)
|
|
||||||
}
|
|
||||||
var sb strings.Builder
|
|
||||||
for ev := range ch {
|
|
||||||
sb.WriteString(ev.Content)
|
|
||||||
}
|
|
||||||
result := strings.TrimSpace(sb.String())
|
|
||||||
|
|
||||||
// Parse the number from the response
|
|
||||||
var choice int
|
|
||||||
fmt.Sscanf(result, "%d", &choice)
|
|
||||||
if choice < 1 || choice > len(entries) {
|
|
||||||
// Default to first model if parsing fails
|
|
||||||
choice = 1
|
|
||||||
}
|
|
||||||
picked := entries[choice-1]
|
|
||||||
|
|
||||||
return RouteResult{Backend: picked.backend, Model: picked.model}, nil
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,10 @@
|
||||||
# Supported: ollama | openai | openrouter | anthropic | copilot | kiro | gemini
|
# Supported: ollama | openai | openrouter | anthropic | copilot | kiro | gemini
|
||||||
OLLIE_BACKEND=ollama
|
OLLIE_BACKEND=ollama
|
||||||
|
|
||||||
# Ollama
|
# Ollama (also accepts standard OLLAMA_HOST)
|
||||||
OLLIE_OLLAMA_URL=http://localhost:11434
|
OLLAMA_HOST=http://localhost:11434
|
||||||
|
|
||||||
# OpenAI / OpenAI-compatible (openai, openrouter)
|
# OpenAI / OpenAI-compatible (openai, openrouter)
|
||||||
OLLIE_OPENAI_URL=
|
|
||||||
OLLIE_OPENAI_KEY=
|
OLLIE_OPENAI_KEY=
|
||||||
|
|
||||||
# Anthropic
|
# Anthropic
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
// Package env manages the daemon-global environment for ollie.
|
// Package env manages the daemon-global environment for ollie.
|
||||||
// It owns the set of known OLLIE_* and SUPERPOWERD_* variables, provides
|
// It loads $XDG_CONFIG_HOME/ollie/env and provides defaults,
|
||||||
// defaults, and formats them for export to frontends via ollie/env.
|
// formatted for export to frontends via ollie/env.
|
||||||
package env
|
package env
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -13,48 +12,10 @@ import (
|
||||||
"ollie/paths"
|
"ollie/paths"
|
||||||
)
|
)
|
||||||
|
|
||||||
// managed is the ordered list of env vars that ollie exposes to frontends.
|
// EnsureDefaults loads $XDG_CONFIG_HOME/ollie/env and sets default values for any
|
||||||
var managed = []string{
|
|
||||||
"OLLIE",
|
|
||||||
"OLLIE_CFG_PATH",
|
|
||||||
"OLLIE_TOOLS_PATH",
|
|
||||||
"OLLIE_AGENTS_PATH",
|
|
||||||
"OLLIE_SKILLS_PATH",
|
|
||||||
"OLLIE_PROMPTS_PATH",
|
|
||||||
"OLLIE_MEMORY_PATH",
|
|
||||||
"OLLIE_TMP_PATH",
|
|
||||||
"OLLIE_TRANSCRIPT_PATH",
|
|
||||||
"OLLIE_ELEVATE_SOCKET",
|
|
||||||
"SUPERPOWERD_SESSION_TOKEN",
|
|
||||||
"SUPERPOWERD_SOCKET_DIR",
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureDefaults loads ~/.config/ollie/env and sets default values for any
|
|
||||||
// OLLIE_* vars not already present in the process environment.
|
// OLLIE_* vars not already present in the process environment.
|
||||||
func EnsureDefaults() {
|
func EnsureDefaults() {
|
||||||
home, _ := os.UserHomeDir()
|
LoadFile(filepath.Join(paths.CfgDir(), "env"))
|
||||||
LoadFile(filepath.Join(home, ".config", "ollie", "env"))
|
|
||||||
xdgRuntime := os.Getenv("XDG_RUNTIME_DIR")
|
|
||||||
if xdgRuntime == "" {
|
|
||||||
xdgRuntime = fmt.Sprintf("/run/user/%d", os.Getuid())
|
|
||||||
}
|
|
||||||
defaults := map[string]string{
|
|
||||||
"OLLIE": filepath.Join(home, "mnt", "ollie"),
|
|
||||||
"OLLIE_CFG_PATH": filepath.Join(home, ".config", "ollie"),
|
|
||||||
"OLLIE_TOOLS_PATH": filepath.Join(home, ".config", "ollie", "tools"),
|
|
||||||
"OLLIE_AGENTS_PATH": filepath.Join(home, ".config", "ollie", "agents"),
|
|
||||||
"OLLIE_SKILLS_PATH": filepath.Join(home, ".config", "ollie", "skills"),
|
|
||||||
"OLLIE_PROMPTS_PATH": filepath.Join(home, ".config", "ollie", "prompts"),
|
|
||||||
"OLLIE_MEMORY_PATH": filepath.Join(home, ".config", "ollie", "memory"),
|
|
||||||
"OLLIE_TMP_PATH": filepath.Join(home, ".local", "share", "ollie", "tmp"),
|
|
||||||
"OLLIE_TRANSCRIPT_PATH": filepath.Join(home, ".config", "ollie", "transcript"),
|
|
||||||
"OLLIE_ELEVATE_SOCKET": filepath.Join(xdgRuntime, "ollie", "elevate.sock"),
|
|
||||||
}
|
|
||||||
for k, v := range defaults {
|
|
||||||
if os.Getenv(k) == "" {
|
|
||||||
os.Setenv(k, v) //nolint:errcheck
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set sets a variable in the process environment.
|
// Set sets a variable in the process environment.
|
||||||
|
|
@ -96,26 +57,3 @@ func LoadFile(path string) {
|
||||||
|
|
||||||
// Get returns a variable from the process environment.
|
// Get returns a variable from the process environment.
|
||||||
func Get(k string) string { return os.Getenv(k) }
|
func Get(k string) string { return os.Getenv(k) }
|
||||||
|
|
||||||
// All returns the full process environment as a map.
|
|
||||||
func All() map[string]string {
|
|
||||||
pairs := os.Environ()
|
|
||||||
m := make(map[string]string, len(pairs))
|
|
||||||
for _, pair := range pairs {
|
|
||||||
k, v, _ := strings.Cut(pair, "=")
|
|
||||||
m[k] = v
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format returns the managed environment variables as NAME=VALUE lines,
|
|
||||||
// suitable for serving as ollie/env.
|
|
||||||
func Format() []byte {
|
|
||||||
var sb strings.Builder
|
|
||||||
for _, k := range managed {
|
|
||||||
if v := os.Getenv(k); v != "" {
|
|
||||||
fmt.Fprintf(&sb, "%s=%s\n", k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return []byte(sb.String())
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -2,32 +2,9 @@ package env
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEnsureDefaults(t *testing.T) {
|
|
||||||
// Clear managed vars so defaults apply.
|
|
||||||
for _, k := range managed {
|
|
||||||
t.Setenv(k, "")
|
|
||||||
os.Unsetenv(k)
|
|
||||||
}
|
|
||||||
EnsureDefaults()
|
|
||||||
for _, k := range []string{"OLLIE", "OLLIE_TOOLS_PATH", "OLLIE_AGENTS_PATH", "OLLIE_SKILLS_PATH", "OLLIE_PROMPTS_PATH", "OLLIE_MEMORY_PATH", "OLLIE_TMP_PATH", "OLLIE_TRANSCRIPT_PATH"} {
|
|
||||||
if v := os.Getenv(k); v == "" {
|
|
||||||
t.Errorf("%s not set after EnsureDefaults", k)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnsureDefaultsNoOverwrite(t *testing.T) {
|
|
||||||
t.Setenv("OLLIE", "/custom")
|
|
||||||
EnsureDefaults()
|
|
||||||
if v := os.Getenv("OLLIE"); v != "/custom" {
|
|
||||||
t.Errorf("OLLIE = %q, want /custom (should not overwrite)", v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSetGet(t *testing.T) {
|
func TestSetGet(t *testing.T) {
|
||||||
t.Setenv("OLLIE_TEST_VAR", "")
|
t.Setenv("OLLIE_TEST_VAR", "")
|
||||||
Set("OLLIE_TEST_VAR", "hello")
|
Set("OLLIE_TEST_VAR", "hello")
|
||||||
|
|
@ -36,27 +13,24 @@ func TestSetGet(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAll(t *testing.T) {
|
func TestLoadFile(t *testing.T) {
|
||||||
t.Setenv("OLLIE_TEST_ALL", "val")
|
tmp := t.TempDir()
|
||||||
m := All()
|
path := tmp + "/env"
|
||||||
if m["OLLIE_TEST_ALL"] != "val" {
|
os.WriteFile(path, []byte("OLLIE_LOAD_TEST=from-file\n"), 0644) //nolint:errcheck
|
||||||
t.Errorf("All() missing OLLIE_TEST_ALL")
|
t.Setenv("OLLIE_LOAD_TEST", "")
|
||||||
|
LoadFile(path)
|
||||||
|
if v := os.Getenv("OLLIE_LOAD_TEST"); v != "from-file" {
|
||||||
|
t.Errorf("OLLIE_LOAD_TEST = %q, want from-file", v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFormat(t *testing.T) {
|
func TestLoadFileNoOverwrite(t *testing.T) {
|
||||||
t.Setenv("OLLIE_TMP_PATH", "/tmp/test-fmt")
|
t.Setenv("OLLIE_NO_OVERWRITE", "existing")
|
||||||
out := string(Format())
|
tmp := t.TempDir()
|
||||||
if !strings.Contains(out, "OLLIE_TMP_PATH=/tmp/test-fmt") {
|
path := tmp + "/env"
|
||||||
t.Errorf("Format() missing expected var, got:\n%s", out)
|
os.WriteFile(path, []byte("OLLIE_NO_OVERWRITE=from-file\n"), 0644) //nolint:errcheck
|
||||||
}
|
LoadFile(path)
|
||||||
// Every line should be NAME=VALUE\n
|
if v := os.Getenv("OLLIE_NO_OVERWRITE"); v != "existing" {
|
||||||
for _, line := range strings.Split(strings.TrimRight(out, "\n"), "\n") {
|
t.Errorf("OLLIE_NO_OVERWRITE = %q, want existing (should not overwrite)", v)
|
||||||
if line == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !strings.Contains(line, "=") {
|
|
||||||
t.Errorf("malformed line: %q", line)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
log/log.go
13
log/log.go
|
|
@ -93,10 +93,10 @@ func (s *Sink) Logger(tag string, level Level) *Logger {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLogger creates a Logger from this Sink, reading the level from
|
// NewLogger creates a Logger from this Sink, reading the level from
|
||||||
// OLLIE_{TAG}_LOG and falling back to the Sink's default level.
|
// OLLIE_LOG and falling back to the Sink's default level.
|
||||||
func (s *Sink) NewLogger(tag string) *Logger {
|
func (s *Sink) NewLogger(tag string) *Logger {
|
||||||
l := s.level
|
l := s.level
|
||||||
if env := os.Getenv("OLLIE_" + strings.ToUpper(tag) + "_LOG"); env != "" {
|
if env := os.Getenv("OLLIE_LOG"); env != "" {
|
||||||
l = ParseLevel(env, l)
|
l = ParseLevel(env, l)
|
||||||
}
|
}
|
||||||
return s.Logger(tag, l)
|
return s.Logger(tag, l)
|
||||||
|
|
@ -133,12 +133,7 @@ func (l *Logger) Warn(format string, args ...any) { l.emit(LevelWarn, format, a
|
||||||
func (l *Logger) Error(format string, args ...any) { l.emit(LevelError, format, args...) }
|
func (l *Logger) Error(format string, args ...any) { l.emit(LevelError, format, args...) }
|
||||||
|
|
||||||
// Sub returns a new Logger with the given tag sharing the same output writers
|
// Sub returns a new Logger with the given tag sharing the same output writers
|
||||||
// and inheriting this logger's level. The level can be overridden independently
|
// and inheriting this logger's level.
|
||||||
// via the OLLIE_{TAG}_LOG environment variable.
|
|
||||||
func (l *Logger) Sub(tag string) *Logger {
|
func (l *Logger) Sub(tag string) *Logger {
|
||||||
level := l.level
|
return &Logger{tag: tag, level: l.level, out: l.out, errout: l.errout}
|
||||||
if env := os.Getenv("OLLIE_" + strings.ToUpper(tag) + "_LOG"); env != "" {
|
|
||||||
level = ParseLevel(env, level)
|
|
||||||
}
|
|
||||||
return &Logger{tag: tag, level: level, out: l.out, errout: l.errout}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ func TestSinkFlush(t *testing.T) {
|
||||||
func TestSinkNewLogger(t *testing.T) {
|
func TestSinkNewLogger(t *testing.T) {
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
s := NewSink(&out, &out, LevelWarn)
|
s := NewSink(&out, &out, LevelWarn)
|
||||||
t.Setenv("OLLIE_MYTEST_LOG", "debug")
|
t.Setenv("OLLIE_LOG", "debug")
|
||||||
l := s.NewLogger("mytest")
|
l := s.NewLogger("mytest")
|
||||||
l.Debug("visible")
|
l.Debug("visible")
|
||||||
s.Flush()
|
s.Flush()
|
||||||
|
|
@ -126,7 +126,7 @@ func TestSinkNewLogger(t *testing.T) {
|
||||||
func TestSinkNewLoggerDefault(t *testing.T) {
|
func TestSinkNewLoggerDefault(t *testing.T) {
|
||||||
var out bytes.Buffer
|
var out bytes.Buffer
|
||||||
s := NewSink(&out, &out, LevelError)
|
s := NewSink(&out, &out, LevelError)
|
||||||
t.Setenv("OLLIE_NOTSET_LOG", "")
|
t.Setenv("OLLIE_LOG", "")
|
||||||
l := s.NewLogger("notset")
|
l := s.NewLogger("notset")
|
||||||
l.Warn("skip")
|
l.Warn("skip")
|
||||||
s.Flush()
|
s.Flush()
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ package paths
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -15,20 +17,63 @@ func ExpandHome(path string) string {
|
||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
// CfgDir returns the ollie config root from OLLIE_CFG_PATH, defaulting to ~/.config/ollie.
|
// CfgDir returns the ollie config root: ~/.config/ollie.
|
||||||
func CfgDir() string {
|
func CfgDir() string {
|
||||||
if p := os.Getenv("OLLIE_CFG_PATH"); p != "" {
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
return home + "/.config/ollie"
|
return home + "/.config/ollie"
|
||||||
}
|
}
|
||||||
|
|
||||||
// DataDir returns the ollie data root from OLLIE_DATA_PATH, defaulting to ~/.local/share/ollie.
|
// DataDir returns the ollie data root: ~/.local/share/ollie.
|
||||||
func DataDir() string {
|
func DataDir() string {
|
||||||
if p := os.Getenv("OLLIE_DATA_PATH"); p != "" {
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
return home + "/.local/share/ollie"
|
return home + "/.local/share/ollie"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ToolsDir returns the tools directory: CfgDir()/tools.
|
||||||
|
func ToolsDir() string {
|
||||||
|
return CfgDir() + "/tools"
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptsDirs returns prompt directories: [CfgDir()/prompts].
|
||||||
|
func PromptsDirs() []string {
|
||||||
|
return []string{CfgDir() + "/prompts"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentsDirs returns agent directories: [CfgDir()/agents].
|
||||||
|
func AgentsDirs() []string {
|
||||||
|
return []string{CfgDir() + "/agents"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkillsDir returns the skills directory: CfgDir()/skills.
|
||||||
|
func SkillsDir() string {
|
||||||
|
return CfgDir() + "/skills"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemoryDir returns the memory directory: CfgDir()/memory.
|
||||||
|
func MemoryDir() string {
|
||||||
|
return CfgDir() + "/memory"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranscriptDir returns the transcript directory: CfgDir()/transcript.
|
||||||
|
func TranscriptDir() string {
|
||||||
|
return CfgDir() + "/transcript"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TmpDir returns the temporary working directory: $TMPDIR/ollie.
|
||||||
|
func TmpDir() string {
|
||||||
|
return filepath.Join(os.TempDir(), "ollie")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageLogFile returns the usage log file: DataDir()/usage.jsonl.
|
||||||
|
func UsageLogFile() string {
|
||||||
|
return filepath.Join(DataDir(), "usage.jsonl")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ElevateSocket returns the elevation broker socket: XDG_RUNTIME_DIR/ollie/elevate.sock.
|
||||||
|
func ElevateSocket() string {
|
||||||
|
xdgRuntime := os.Getenv("XDG_RUNTIME_DIR")
|
||||||
|
if xdgRuntime == "" {
|
||||||
|
xdgRuntime = filepath.Join("/run/user", strconv.FormatInt(int64(os.Getuid()), 10))
|
||||||
|
}
|
||||||
|
return filepath.Join(xdgRuntime, "ollie", "elevate.sock")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,30 +5,14 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCfgDirFromEnv(t *testing.T) {
|
|
||||||
t.Setenv("OLLIE_CFG_PATH", "/custom/cfg")
|
|
||||||
if got := CfgDir(); got != "/custom/cfg" {
|
|
||||||
t.Errorf("CfgDir() = %q; want /custom/cfg", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCfgDirDefault(t *testing.T) {
|
func TestCfgDirDefault(t *testing.T) {
|
||||||
t.Setenv("OLLIE_CFG_PATH", "")
|
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
if got := CfgDir(); got != home+"/.config/ollie" {
|
if got := CfgDir(); got != home+"/.config/ollie" {
|
||||||
t.Errorf("CfgDir() = %q; want %s/.config/ollie", got, home)
|
t.Errorf("CfgDir() = %q; want %s/.config/ollie", got, home)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDataDirFromEnv(t *testing.T) {
|
|
||||||
t.Setenv("OLLIE_DATA_PATH", "/custom/data")
|
|
||||||
if got := DataDir(); got != "/custom/data" {
|
|
||||||
t.Errorf("DataDir() = %q; want /custom/data", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDataDirDefault(t *testing.T) {
|
func TestDataDirDefault(t *testing.T) {
|
||||||
t.Setenv("OLLIE_DATA_PATH", "")
|
|
||||||
home, _ := os.UserHomeDir()
|
home, _ := os.UserHomeDir()
|
||||||
if got := DataDir(); got != home+"/.local/share/ollie" {
|
if got := DataDir(); got != home+"/.local/share/ollie" {
|
||||||
t.Errorf("DataDir() = %q; want %s/.local/share/ollie", got, home)
|
t.Errorf("DataDir() = %q; want %s/.local/share/ollie", got, home)
|
||||||
|
|
|
||||||
|
|
@ -101,10 +101,6 @@ func expandPath(pattern, cwd string, getenv EnvFunc) string {
|
||||||
return xdg
|
return xdg
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("/run/user/%d", os.Getuid())
|
return fmt.Sprintf("/run/user/%d", os.Getuid())
|
||||||
case "OLLIE_CFG_PATH":
|
|
||||||
return paths.CfgDir()
|
|
||||||
case "OLLIE_DATA_PATH":
|
|
||||||
return paths.DataDir()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if val := getenv(varName); val != "" {
|
if val := getenv(varName); val != "" {
|
||||||
|
|
|
||||||
|
|
@ -363,7 +363,7 @@ filesystem:
|
||||||
rw:
|
rw:
|
||||||
- "{CWD}"
|
- "{CWD}"
|
||||||
rwx:
|
rwx:
|
||||||
- "{OLLIE_CFG_PATH}"
|
- "{XDG_CONFIG_HOME}/ollie"
|
||||||
network:
|
network:
|
||||||
enabled: true
|
enabled: true
|
||||||
unrestricted: false
|
unrestricted: false
|
||||||
|
|
@ -397,8 +397,8 @@ advanced:
|
||||||
if !containsStr(cfg.Filesystem.RW, "{CWD}") {
|
if !containsStr(cfg.Filesystem.RW, "{CWD}") {
|
||||||
t.Error("RW missing {CWD}")
|
t.Error("RW missing {CWD}")
|
||||||
}
|
}
|
||||||
if !containsStr(cfg.Filesystem.RWX, "{OLLIE_CFG_PATH}") {
|
if !containsStr(cfg.Filesystem.RWX, "{XDG_CONFIG_HOME}/ollie") {
|
||||||
t.Error("RWX missing {OLLIE_CFG_PATH}")
|
t.Error("RWX missing {XDG_CONFIG_HOME}/ollie")
|
||||||
}
|
}
|
||||||
if !cfg.Network.Enabled {
|
if !cfg.Network.Enabled {
|
||||||
t.Error("Network.Enabled should be true")
|
t.Error("Network.Enabled should be true")
|
||||||
|
|
@ -511,14 +511,18 @@ func TestExpandPath_XDG_FromEnv(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExpandPath_OlliePaths(t *testing.T) {
|
func TestExpandPath_XDGPaths(t *testing.T) {
|
||||||
got1 := expandPath("{OLLIE_CFG_PATH}", "/cwd", os.Getenv)
|
// XDG_CONFIG_HOME expansion
|
||||||
if got1 == "{OLLIE_CFG_PATH}" || got1 == "" {
|
t.Setenv("XDG_CONFIG_HOME", "/custom/config")
|
||||||
t.Errorf("OLLIE_CFG_PATH not expanded: %q", got1)
|
got1 := expandPath("{XDG_CONFIG_HOME}/ollie", "/cwd", os.Getenv)
|
||||||
|
if got1 != "/custom/config/ollie" {
|
||||||
|
t.Errorf("XDG_CONFIG_HOME/ollie not expanded: %q", got1)
|
||||||
}
|
}
|
||||||
got2 := expandPath("{OLLIE_DATA_PATH}", "/cwd", os.Getenv)
|
// XDG_DATA_HOME expansion
|
||||||
if got2 == "{OLLIE_DATA_PATH}" || got2 == "" {
|
t.Setenv("XDG_DATA_HOME", "/custom/data")
|
||||||
t.Errorf("OLLIE_DATA_PATH not expanded: %q", got2)
|
got2 := expandPath("{XDG_DATA_HOME}/ollie", "/cwd", os.Getenv)
|
||||||
|
if got2 != "/custom/data/ollie" {
|
||||||
|
t.Errorf("XDG_DATA_HOME/ollie not expanded: %q", got2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -625,69 +629,20 @@ func TestWrapCommand_SortTiebreaker(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWrapCommand_ColonSeparatedPaths(t *testing.T) {
|
func TestWrapCommand_EnvVarExpansion(t *testing.T) {
|
||||||
tmpDir := t.TempDir()
|
tmpDir := t.TempDir()
|
||||||
dir1 := filepath.Join(tmpDir, "skills1")
|
skillsDir := filepath.Join(tmpDir, "skills")
|
||||||
dir2 := filepath.Join(tmpDir, "skills2")
|
os.MkdirAll(skillsDir, 0755)
|
||||||
os.MkdirAll(dir1, 0755)
|
|
||||||
os.MkdirAll(dir2, 0755)
|
|
||||||
|
|
||||||
// Simulate OLLIE_SKILLS_PATH="/path1:/path2"
|
t.Setenv("XDG_CONFIG_HOME", tmpDir)
|
||||||
t.Setenv("OLLIE_SKILLS_PATH", dir1+":"+dir2)
|
|
||||||
|
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Filesystem: FilesystemConfig{
|
Filesystem: FilesystemConfig{
|
||||||
RWX: []string{"{OLLIE_SKILLS_PATH}"},
|
RWX: []string{"{XDG_CONFIG_HOME}/skills"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
got := mustWrapCommand(t, cfg, []string{"sh"}, tmpDir)
|
||||||
assertFlagValue(t, got, "--rwx", dir1)
|
assertFlagValue(t, got, "--rwx", skillsDir)
|
||||||
assertFlagValue(t, got, "--rwx", dir2)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckPath_ColonSeparatedPaths(t *testing.T) {
|
|
||||||
tmpDir := t.TempDir()
|
|
||||||
dir1 := filepath.Join(tmpDir, "skills1")
|
|
||||||
dir2 := filepath.Join(tmpDir, "skills2")
|
|
||||||
os.MkdirAll(dir1, 0755)
|
|
||||||
os.MkdirAll(dir2, 0755)
|
|
||||||
|
|
||||||
file1 := filepath.Join(dir1, "test.md")
|
|
||||||
os.WriteFile(file1, []byte("hi"), 0644)
|
|
||||||
file2 := filepath.Join(dir2, "test.md")
|
|
||||||
os.WriteFile(file2, []byte("hi"), 0644)
|
|
||||||
|
|
||||||
getenv := func(k string) string {
|
|
||||||
if k == "OLLIE_SKILLS_PATH" {
|
|
||||||
return dir1 + ":" + dir2
|
|
||||||
}
|
|
||||||
if k == "HOME" {
|
|
||||||
return tmpDir
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg := &Config{
|
|
||||||
Filesystem: FilesystemConfig{
|
|
||||||
RWX: []string{"{OLLIE_SKILLS_PATH}"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Both paths should be allowed for read
|
|
||||||
if err := checkPath(cfg, file1, false, tmpDir, getenv); err != nil {
|
|
||||||
t.Errorf("file in dir1 should be allowed: %v", err)
|
|
||||||
}
|
|
||||||
if err := checkPath(cfg, file2, false, tmpDir, getenv); err != nil {
|
|
||||||
t.Errorf("file in dir2 should be allowed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Path outside both should be denied
|
|
||||||
outside := filepath.Join(tmpDir, "outside", "file.txt")
|
|
||||||
os.MkdirAll(filepath.Dir(outside), 0755)
|
|
||||||
os.WriteFile(outside, []byte("x"), 0644)
|
|
||||||
if err := checkPath(cfg, outside, false, tmpDir, getenv); err == nil {
|
|
||||||
t.Error("path outside both dirs should be denied")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- helpers ----
|
// ---- helpers ----
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// pathEntry holds a path with its permission type
|
// pathEntry holds a path with its permission type
|
||||||
|
|
@ -48,15 +47,12 @@ func WrapCommand(cfg *Config, originalCmd []string, cwd string, getenv EnvFunc)
|
||||||
var entries []pathEntry
|
var entries []pathEntry
|
||||||
addPaths := func(paths []string, flag string) {
|
addPaths := func(paths []string, flag string) {
|
||||||
for _, path := range paths {
|
for _, path := range paths {
|
||||||
expanded := expandPath(path, cwd, getenv)
|
p := expandPath(path, cwd, getenv)
|
||||||
// Handle colon-separated paths (e.g. OLLIE_SKILLS_PATH)
|
|
||||||
for _, p := range strings.Split(expanded, ":") {
|
|
||||||
if p != "" && pathExists(p) {
|
if p != "" && pathExists(p) {
|
||||||
entries = append(entries, pathEntry{p, flag})
|
entries = append(entries, pathEntry{p, flag})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
addPaths(cfg.Filesystem.RO, "--ro")
|
addPaths(cfg.Filesystem.RO, "--ro")
|
||||||
addPaths(cfg.Filesystem.ROX, "--rox")
|
addPaths(cfg.Filesystem.ROX, "--rox")
|
||||||
addPaths(cfg.Filesystem.RW, "--rw")
|
addPaths(cfg.Filesystem.RW, "--rw")
|
||||||
|
|
|
||||||
|
|
@ -87,10 +87,7 @@ func NextUncheckedStep(data []byte) string {
|
||||||
var sweepTmpOnce sync.Once
|
var sweepTmpOnce sync.Once
|
||||||
|
|
||||||
func ollieTmpDir() string {
|
func ollieTmpDir() string {
|
||||||
if p := os.Getenv("OLLIE_TMP_PATH"); p != "" {
|
return paths.TmpDir()
|
||||||
return p
|
|
||||||
}
|
|
||||||
return filepath.Join(os.TempDir(), "ollie")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func sweepStaleTmpDirs() {
|
func sweepStaleTmpDirs() {
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,13 @@ import (
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"ollie/paths"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SkillsPath returns the directory to search for skill modules.
|
// SkillsPath returns the skills directory: CfgDir()/skills.
|
||||||
// Resolved from OLLIE_SKILLS_PATH, then ~/.config/ollie/skills.
|
|
||||||
func SkillsPath() string {
|
func SkillsPath() string {
|
||||||
if p := os.Getenv("OLLIE_SKILLS_PATH"); p != "" {
|
return paths.SkillsDir()
|
||||||
return p
|
|
||||||
}
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
return home + "/.config/ollie/skills"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SkillInfo describes an available skill module.
|
// SkillInfo describes an available skill module.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ CACHE_BIN="$CACHE_DIR/ollie-remote"
|
||||||
CACHE_HASH="$CACHE_DIR/ollie-remote.hash"
|
CACHE_HASH="$CACHE_DIR/ollie-remote.hash"
|
||||||
#
|
#
|
||||||
if [ -x "$CACHE_BIN" ] && [ -f "$CACHE_HASH" ] && [ "$(cat "$CACHE_HASH")" = "@@HASH@@" ]; then
|
if [ -x "$CACHE_BIN" ] && [ -f "$CACHE_HASH" ] && [ "$(cat "$CACHE_HASH")" = "@@HASH@@" ]; then
|
||||||
echo "OLLIE_LOADER_START {\"need_download\":false}" >&2
|
echo "LoaderStart {\"need_download\":false}" >&2
|
||||||
else
|
else
|
||||||
# Find base64 decoder
|
# Find base64 decoder
|
||||||
if command -v base64 >/dev/null 2>&1; then
|
if command -v base64 >/dev/null 2>&1; then
|
||||||
|
|
@ -32,12 +32,12 @@ else
|
||||||
command -v gunzip >/dev/null 2>&1 || GUNZIP_CMD="gzip -d"
|
command -v gunzip >/dev/null 2>&1 || GUNZIP_CMD="gzip -d"
|
||||||
#
|
#
|
||||||
mkdir -p "$CACHE_DIR"
|
mkdir -p "$CACHE_DIR"
|
||||||
echo "OLLIE_LOADER_START {\"need_download\":true}" >&2
|
echo "LoaderStart {\"need_download\":true}" >&2
|
||||||
#
|
#
|
||||||
# Read download size, then binary data
|
# Read download size, then binary data
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
case "$line" in
|
case "$line" in
|
||||||
OLLIE_DOWNLOAD\ *) BINARY_LENGTH="${line#OLLIE_DOWNLOAD }"; break ;;
|
Download\ *) BINARY_LENGTH="${line#Download }"; break ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
#
|
#
|
||||||
|
|
@ -50,7 +50,7 @@ else
|
||||||
chmod +x "$CACHE_TMP"
|
chmod +x "$CACHE_TMP"
|
||||||
mv "$CACHE_TMP" "$CACHE_BIN"
|
mv "$CACHE_TMP" "$CACHE_BIN"
|
||||||
echo "@@HASH@@" > "$CACHE_HASH"
|
echo "@@HASH@@" > "$CACHE_HASH"
|
||||||
echo "OLLIE_LOADER_READY" >&2
|
echo "LoaderReady" >&2
|
||||||
fi
|
fi
|
||||||
#
|
#
|
||||||
exec "$CACHE_BIN" serve --cwd @@CWD@@
|
exec "$CACHE_BIN" serve --cwd @@CWD@@
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,9 @@ import (
|
||||||
"ollie/paths"
|
"ollie/paths"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ToolsPath returns the directory to search for named tool scripts.
|
// ToolsPath returns the tools directory: CfgDir()/tools.
|
||||||
// Resolved in order: first entry of OLLIE_TOOLS_PATH (colon-separated),
|
|
||||||
// then ~/.config/ollie/tools.
|
|
||||||
func ToolsPath() string {
|
func ToolsPath() string {
|
||||||
if p := os.Getenv("OLLIE_TOOLS_PATH"); p != "" {
|
return paths.ToolsDir()
|
||||||
if i := strings.Index(p, ":"); i >= 0 {
|
|
||||||
p = p[:i]
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
return paths.CfgDir() + "/tools"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -137,14 +137,14 @@ func RemoteDial(ctx context.Context, cfg RemoteConfig) (*RemoteServer, error) {
|
||||||
go func() {
|
go func() {
|
||||||
for stderrScanner.Scan() {
|
for stderrScanner.Scan() {
|
||||||
line := strings.TrimSpace(stderrScanner.Text())
|
line := strings.TrimSpace(stderrScanner.Text())
|
||||||
if after, ok := strings.CutPrefix(line, "OLLIE_LOADER_START "); ok {
|
if after, ok := strings.CutPrefix(line, "LoaderStart "); ok {
|
||||||
var msg loaderMsg
|
var msg loaderMsg
|
||||||
json.Unmarshal([]byte(after), &msg)
|
json.Unmarshal([]byte(after), &msg)
|
||||||
loaderCh <- msg
|
loaderCh <- msg
|
||||||
if !msg.NeedDownload {
|
if !msg.NeedDownload {
|
||||||
close(readyCh)
|
close(readyCh)
|
||||||
}
|
}
|
||||||
} else if line == "OLLIE_LOADER_READY" {
|
} else if line == "LoaderReady" {
|
||||||
close(readyCh)
|
close(readyCh)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -229,7 +229,7 @@ func transferBinary(stdin io.Writer, binData []byte) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
encoded := base64.StdEncoding.EncodeToString(compressed.Bytes())
|
encoded := base64.StdEncoding.EncodeToString(compressed.Bytes())
|
||||||
if _, err := fmt.Fprintf(stdin, "OLLIE_DOWNLOAD %d\n", len(encoded)); err != nil {
|
if _, err := fmt.Fprintf(stdin, "Download %d\n", len(encoded)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := stdin.Write([]byte(encoded)); err != nil {
|
if _, err := stdin.Write([]byte(encoded)); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -328,7 +328,7 @@ func (e *Server) callPromotedTool(ctx context.Context, tool string, args json.Ra
|
||||||
workDir := e.cwd
|
workDir := e.cwd
|
||||||
e.wdMu.RUnlock()
|
e.wdMu.RUnlock()
|
||||||
// Broker protocol has no stdin support; pipe JSON via heredoc.
|
// Broker protocol has no stdin support; pipe JSON via heredoc.
|
||||||
elevatedCode := fmt.Sprintf("cat <<'OLLIE_EOF' | %s\n%s\nOLLIE_EOF", code, stdinData)
|
elevatedCode := fmt.Sprintf("cat <<'EOF' | %s\n%s\nEOF", code, stdinData)
|
||||||
result, err = e.executeElevated(ctx, elevatedCode, workDir, 30)
|
result, err = e.executeElevated(ctx, elevatedCode, workDir, 30)
|
||||||
} else {
|
} else {
|
||||||
result, err = e.executeWithStdin(ctx, code, "bash", 30, "default", false, stdinData)
|
result, err = e.executeWithStdin(ctx, code, "bash", 30, "default", false, stdinData)
|
||||||
|
|
|
||||||
|
|
@ -117,14 +117,7 @@ func (lw *limitedWriter) Write(p []byte) (n int, err error) {
|
||||||
// Connects to the broker socket, sends the request with the current env,
|
// Connects to the broker socket, sends the request with the current env,
|
||||||
// and streams the framed response back.
|
// and streams the framed response back.
|
||||||
func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout int, doDetach ...bool) (string, error) {
|
func (e *Server) executeElevated(ctx context.Context, cmd, dir string, timeout int, doDetach ...bool) (string, error) {
|
||||||
sockPath := os.Getenv("OLLIE_ELEVATE_SOCKET")
|
sockPath := paths.ElevateSocket()
|
||||||
if sockPath == "" {
|
|
||||||
xdg := os.Getenv("XDG_RUNTIME_DIR")
|
|
||||||
if xdg == "" {
|
|
||||||
return "", fmt.Errorf("elevation not available: no XDG_RUNTIME_DIR")
|
|
||||||
}
|
|
||||||
sockPath = filepath.Join(xdg, "ollie", "elevate.sock")
|
|
||||||
}
|
|
||||||
|
|
||||||
wantDetach := len(doDetach) > 0 && doDetach[0]
|
wantDetach := len(doDetach) > 0 && doDetach[0]
|
||||||
|
|
||||||
|
|
@ -502,7 +495,6 @@ func (e *Server) executeWithStdin(ctx context.Context, code, language string, ti
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cmd.Env = prependOlliePath(filtered, paths.CfgDir())
|
cmd.Env = prependOlliePath(filtered, paths.CfgDir())
|
||||||
cmd.Env = append(cmd.Env, "OLLIE_TOOLS_PATH="+ToolsPath())
|
|
||||||
for k, v := range e.envExtra {
|
for k, v := range e.envExtra {
|
||||||
cmd.Env = append(cmd.Env, k+"="+v)
|
cmd.Env = append(cmd.Env, k+"="+v)
|
||||||
}
|
}
|
||||||
|
|
@ -644,7 +636,7 @@ func (e *Server) executeWithStdin(ctx context.Context, code, language string, ti
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// prependOlliePath returns env with $OLLIE_CFG_PATH/scripts/x/ prepended to PATH so wrapper
|
// prependOlliePath returns env with $XDG_CONFIG_HOME/ollie/scripts/x/ prepended to PATH so wrapper
|
||||||
// scripts placed there shadow system binaries.
|
// scripts placed there shadow system binaries.
|
||||||
func prependOlliePath(env []string, cfgDir string) []string {
|
func prependOlliePath(env []string, cfgDir string) []string {
|
||||||
if cfgDir == "" {
|
if cfgDir == "" {
|
||||||
|
|
|
||||||
Reference in New Issue