Good idea fairy cleanup: ~960 lines removed
Phase 1: - agent/hooks.go (entire hooks engine, 237 lines) - backend/noop.go (unused test backend) - backend/oneshot.go: Route/RouteRequest/RouteResult + /route 9P file - cmd/ollie-9p/mount/ (FUSE mount, 336 lines) - toolsrv/schema.go (ParseToolInfo, zero callers) - toolsrv/discover.go: ToolPrompt/ReadTool (zero callers) - agent/commands.go: CompactionSnapshot + history.go: PreCompactionSnapshot - data/tools/route.meta + justfile route install Phase 2: - detach/process.go: stripped Signal(), Cmd field, unused imports - SignalDetached reimplemented with syscall.Kill directly 24 files changed, 19 insertions, ~980 deletions
This commit is contained in:
parent
b703dcf79c
commit
fc0c368e91
43
.plan.md
43
.plan.md
|
|
@ -1,10 +1,39 @@
|
|||
# Plan: Fix tool_load to return schema + docs
|
||||
# Cleanup Checklist
|
||||
|
||||
The problem: tool_load returns bare "loaded: {name}" with no metadata.
|
||||
The agent can't see the schema or usage docs without reading source files.
|
||||
## Phase 1: Delete ✅
|
||||
- [x] `agent/hooks.go` — deleted (237 lines)
|
||||
- [x] `agent/agent_config.go` — removed HookCmds + Hooks field
|
||||
- [x] `agent/runtime.go` — removed Hooks field
|
||||
- [x] `agent/build_runtime.go` — removed hooks construction
|
||||
- [x] `agent/turn.go` — removed 8 hook call sites + defaultTurnError
|
||||
- [x] `agent/loop.go` — removed PreTool/PostTool/TurnError + classifyError
|
||||
- [x] `agent/agent.go` + `new.go` — removed turnError field + assignment
|
||||
- [x] `backend/noop.go` — deleted
|
||||
- [x] `backend/oneshot.go:Route()` — removed Route/RouteRequest/RouteResult
|
||||
- [x] `cmd/ollie-9p/mount/` — deleted entire directory
|
||||
- [x] `fs/spec.go` + `fs/handlers.go` — removed /route file + handler
|
||||
- [x] `data/tools/route.meta` — deleted
|
||||
- [x] `justfile` — removed route install line
|
||||
- [x] `cmd/ollie-9p/main.go` — removed mount references
|
||||
- [x] `agent/commands.go:CompactionSnapshot()` — removed
|
||||
- [x] `agent/history.go:PreCompactionSnapshot()` — removed
|
||||
- [x] `toolsrv/discover.go:ToolPrompt()` + `ReadTool()` — removed
|
||||
- [x] `toolsrv/schema.go:ParseToolInfo()` — deleted entire file
|
||||
|
||||
Fix: make ToolLoad return the full ToolInfo (name, description, input_schema, prompt)
|
||||
so the agent sees how to use the tool immediately after loading.
|
||||
## Phase 2: Strip down
|
||||
- [ ] `detach/process.go` — strip to ring buffer + pid
|
||||
- [ ] `env/env.go` — fix 27:1 scope mismatch, remove `All()`, fix `Format()`
|
||||
- [ ] `agent/compaction.go` — replace hardcoded map with YAML config file
|
||||
|
||||
1. Modify ToolLoad in tools/tool.go to return structured JSON with schema + prompt
|
||||
2. Commit + push
|
||||
## Phase 3: Optimize
|
||||
- [ ] `agent/complete.go` — extract into tool script, use `Generate()`
|
||||
- [ ] `lib9p/` + `cmd/ollie-9p` — ollie-9p imports and uses lib9p
|
||||
|
||||
## Phase 4: Quick fixes
|
||||
- [ ] `fs/spec.go` — move `GID("agent")` to root node
|
||||
- [ ] Namespace width — audit 23 agent-level files
|
||||
|
||||
## Phase 5: Config/sidecar cleanup
|
||||
- [ ] Remove `freeloader` refs from agent JSON `hooks.turnError`
|
||||
- [ ] Remove beads shell one-liner from `default.json`
|
||||
- [ ] Remove `hooks` blocks from all 8 agent JSON files
|
||||
|
|
@ -54,10 +54,9 @@ type Agent struct {
|
|||
sessionID string // the owning session's ID
|
||||
startupMessages []string
|
||||
readPlanStep func() string
|
||||
save func() // trigger debounced persistence
|
||||
flush func() // immediately flush persistence
|
||||
turnError func(ctx context.Context, errType, errMsg string) HookResult // overridable for tests
|
||||
onStateChange func(agentID, state string) // optional callback for state changes
|
||||
save func() // trigger debounced persistence
|
||||
flush func() // immediately flush persistence
|
||||
onStateChange func(agentID, state string) // optional callback for state changes
|
||||
}
|
||||
|
||||
// Backend returns the active backend from the runtime.
|
||||
|
|
|
|||
|
|
@ -5,24 +5,6 @@ import (
|
|||
"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
|
||||
// (treated as literal text, with the existing resolvePrompt semantics) or
|
||||
// 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 {
|
||||
Hooks map[string]HookCmds `json:"hooks,omitempty"`
|
||||
Prompt Prompt `json:"prompt,omitempty"`
|
||||
Backend string `json:"backend,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
|
|
|
|||
|
|
@ -33,14 +33,10 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string
|
|||
allTools = toolInfosToBackend(allToolInfos)
|
||||
}
|
||||
|
||||
hooks := Hooks{}
|
||||
var preamble string
|
||||
var genParams backend.GenerationParams
|
||||
var maxSteps int
|
||||
if cfg != nil {
|
||||
for k, v := range cfg.Hooks {
|
||||
hooks[k] = []string(v)
|
||||
}
|
||||
if resolved, err := resolvePrompt(cfg.Prompt, cwd, env); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "resolve prompt: %v\n", err)
|
||||
} else {
|
||||
|
|
@ -179,7 +175,6 @@ func BuildRuntime(cfg *AgentConfig, srv toolsrv.Runner, cwd string, env []string
|
|||
ClassifyTool: classify,
|
||||
OutputFormat: outputFormat,
|
||||
ClassifyTier: tierFn,
|
||||
Hooks: hooks,
|
||||
Preamble: preamble,
|
||||
GenParams: genParams,
|
||||
MaxSteps: maxSteps,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package agent
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
|
|
@ -349,17 +348,3 @@ func (ag *Agent) InjectRewrite(prompt string) {
|
|||
ag.emit(Event{Role: "info", Content: "\n"})
|
||||
ag.emit(Event{Role: "user", Content: prompt})
|
||||
}
|
||||
|
||||
// CompactionSnapshot returns the pre-compaction snapshot for external persistence.
|
||||
// Returns nil if no history exists.
|
||||
func (ag *Agent) CompactionSnapshot() json.RawMessage {
|
||||
if ag.history == nil {
|
||||
return nil
|
||||
}
|
||||
snap := ag.history.PreCompactionSnapshot()
|
||||
data, err := json.Marshal(snap)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"ollie/backend"
|
||||
|
|
@ -396,12 +395,6 @@ func isCancelledToolResult(content string) bool {
|
|||
strings.Contains(content, "tool execution interrupted by user")
|
||||
}
|
||||
|
||||
// PreCompactionSnapshot returns a copy of the current messages for persistence
|
||||
// before compaction. Call this before compact().
|
||||
func (s *History) PreCompactionSnapshot() []backend.Message {
|
||||
return slices.Clone(s.messages)
|
||||
}
|
||||
|
||||
// Compact summarizes the conversation via an LLM call, replacing the history
|
||||
// with system messages + preserved user messages + a structured summary.
|
||||
// Returns (n compacted, summary text, error); n==0 means nothing to compact.
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
|
|
@ -60,12 +60,9 @@ type agentConfig struct {
|
|||
Output EventHandler
|
||||
preamble string // compiled system+agent prompt sent as the system role
|
||||
GenerationParams backend.GenerationParams
|
||||
PopInject func() string // returns and clears pending inject, or ""
|
||||
AutoCompact func(ctx context.Context) // called after each tool round; may compact in-place
|
||||
Save 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
|
||||
PopInject func() string // returns and clears pending inject, or ""
|
||||
AutoCompact func(ctx context.Context) // called after each tool round; may compact in-place
|
||||
Save func() // called after each state.update(); persists mid-turn progress
|
||||
// MaxSteps is the maximum number of tool-call rounds per turn.
|
||||
// When reached, a soft nudge is injected and the loop exits cleanly.
|
||||
// 0 means unlimited.
|
||||
|
|
@ -135,15 +132,6 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
if ctx.Err() != nil {
|
||||
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)
|
||||
if !retryable || attempt >= maxTransientRetries {
|
||||
return fmt.Errorf("step %d: %w", step, err)
|
||||
|
|
@ -287,20 +275,6 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
return cr, true
|
||||
}
|
||||
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, OutputFormat: toolOutputFormat(cfg, tc.Name)})
|
||||
return toolResult{ToolCallID: tc.ID, Name: tc.Name, Content: blocked, IsError: true}, false
|
||||
}
|
||||
}
|
||||
readSafe := cfg.ClassifyTool != nil && cfg.ClassifyTool(tc.Name)
|
||||
if readSafe {
|
||||
key := tc.Name + "\x00" + string(tc.Arguments)
|
||||
|
|
@ -359,20 +333,9 @@ func run(ctx context.Context, cfg agentConfig, state state) error {
|
|||
result = "error: no tool executor configured"
|
||||
isErr = true
|
||||
}
|
||||
// Accumulate suffix text (PostTool context, user-interruptions,
|
||||
// truncation hints) that must be emitted after streaming completes.
|
||||
// Accumulate suffix text (user-interruptions, truncation hints)
|
||||
// that must be emitted after streaming completes.
|
||||
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 injected := cfg.PopInject(); injected != "" {
|
||||
suffix += "\n\n<system-user-interruption>\n" + injected + "\n</system-user-interruption>"
|
||||
|
|
@ -1007,28 +970,6 @@ func relaxJSON(s string) string {
|
|||
return out.String()
|
||||
}
|
||||
|
||||
// classifyError returns a short string identifying the error type for the
|
||||
// turnError hook payload.
|
||||
func classifyError(err error) string {
|
||||
var rlErr *backend.RateLimitError
|
||||
if errors.As(err, &rlErr) {
|
||||
return "rate_limit"
|
||||
}
|
||||
var tuErr *backend.ToolUnsupportedError
|
||||
if errors.As(err, &tuErr) {
|
||||
return "tool_unsupported"
|
||||
}
|
||||
var coErr *backend.ContextOverflowError
|
||||
if errors.As(err, &coErr) {
|
||||
return "context_overflow"
|
||||
}
|
||||
var tErr *backend.TransientError
|
||||
if errors.As(err, &tErr) {
|
||||
return "transient"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// transientWait returns the retry wait for a retryable error and whether it is
|
||||
// retryable. Rate limits use longer waits; transient/network errors use shorter.
|
||||
func transientWait(err error, attempt int) (time.Duration, bool) {
|
||||
|
|
|
|||
|
|
@ -61,7 +61,6 @@ func NewAgent(cfg AgentCfg) *Agent {
|
|||
state: "idle",
|
||||
}
|
||||
ag.changeCond = sync.NewCond(&ag.changeMu)
|
||||
ag.turnError = ag.defaultTurnError
|
||||
ag.wireToolsChanged()
|
||||
return ag
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import (
|
|||
type Runtime struct {
|
||||
Backend backend.Backend
|
||||
ToolServer toolsrv.Runner // the execute server (tool runtime, env, cwd)
|
||||
Hooks Hooks
|
||||
Preamble string // compiled system prompt
|
||||
Tools []backend.Tool
|
||||
Exec toolExecutor
|
||||
|
|
|
|||
158
agent/turn.go
158
agent/turn.go
|
|
@ -2,14 +2,12 @@ package agent
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ollie/backend"
|
||||
)
|
||||
|
|
@ -72,25 +70,6 @@ func (ag *Agent) Submit(ctx context.Context, input string) {
|
|||
func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
||||
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
|
||||
// so the session is clean for the next attempt.
|
||||
snapSession := ag.history
|
||||
|
|
@ -153,11 +132,10 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
|||
ClassifyTool: ag.runtime.ClassifyTool,
|
||||
OutputFormat: outputFormatFn,
|
||||
ClassifyTier: ag.runtime.ClassifyTier,
|
||||
GenerationParams: ag.runtime.GenParams,
|
||||
MaxSteps: ag.runtime.MaxSteps,
|
||||
ReadPlanStep: ag.readPlanStep,
|
||||
TurnError: ag.turnError,
|
||||
}
|
||||
GenerationParams: ag.runtime.GenParams,
|
||||
MaxSteps: ag.runtime.MaxSteps,
|
||||
ReadPlanStep: ag.readPlanStep,
|
||||
}
|
||||
|
||||
var replyBuf strings.Builder
|
||||
ag.cfg.Output = func(ev Event) {
|
||||
|
|
@ -203,23 +181,6 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
|||
}
|
||||
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 {
|
||||
return ag.toolCallCount.Add(1)
|
||||
}
|
||||
|
|
@ -333,31 +294,17 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
|||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, ErrInterrupted) {
|
||||
ag.auditLog.Debug("turn: interrupted session=%s", ag.sessionID)
|
||||
ag.save()
|
||||
ag.save()
|
||||
return ""
|
||||
}
|
||||
ag.emit(Event{Role: "error", Content: err.Error()})
|
||||
// Drain one FIFO item — the turnError hook may have queued a recovery prompt.
|
||||
// Drain one FIFO item.
|
||||
if next, ok := ag.fifo.Pop(); ok {
|
||||
return next
|
||||
}
|
||||
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 {
|
||||
ag.history.recordTurnCost(ag.cfg.Backend.Model())
|
||||
appendUsageLog(ag.sessionID, ag.cfg.Backend.Name(), ag.cfg.Backend.Model(), ag.history)
|
||||
|
|
@ -368,16 +315,11 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
|||
auditTruncate(ag.Reply()), ag.history.LastTurnCostUSD, ag.history.SessionCostUSD, ag.sessionID)
|
||||
ag.notifyChange()
|
||||
}
|
||||
ag.save()
|
||||
ag.save()
|
||||
|
||||
// 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.
|
||||
if p := ag.pendingInject.Swap(nil); p != nil {
|
||||
if p := ag.pendingInject.Swap(nil); p != nil {
|
||||
return *p
|
||||
}
|
||||
|
||||
|
|
@ -389,21 +331,6 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
// turnError is called on the first backend error of a turn. If the hook handles
|
||||
// the error (exit 0), the turn exits immediately.
|
||||
// This is the default implementation; it can be overridden via the turnError field.
|
||||
func (ag *Agent) defaultTurnError(ctx context.Context, errType, errMsg string) HookResult {
|
||||
hookCtx, cancel := context.WithTimeout(context.Background(), time.Duration(hookTimeout)*time.Second)
|
||||
defer cancel()
|
||||
return ag.runtime.Hooks.Run(hookCtx, HookTurnError, map[string]string{
|
||||
"session_id": ag.sessionID,
|
||||
"cwd": ag.effectiveCwd(),
|
||||
"model": ag.runtime.Backend.Model(),
|
||||
"error_type": errType,
|
||||
"error": errMsg,
|
||||
}, ag.log)
|
||||
}
|
||||
|
||||
// autoCompactLimit returns the token threshold for auto-compaction (75%).
|
||||
func (ag *Agent) autoCompactLimit(ctx context.Context) int {
|
||||
ctxLen := ag.runtime.Backend.ContextLength(ctx)
|
||||
|
|
@ -423,27 +350,9 @@ func (ag *Agent) autoWarnLimit(ctx context.Context) int {
|
|||
}
|
||||
|
||||
// spawnContext assembles the agent context injected at each session refresh
|
||||
// point (session start, post-clear, post-compaction). It combines the
|
||||
// agent-specific prompt with any agentSpawn hook output.
|
||||
// point (session start, post-clear, post-compaction).
|
||||
func (ag *Agent) spawnContext(ctx context.Context) string {
|
||||
result := ag.runtime.Hooks.Run(ctx, HookAgentSpawn, map[string]string{
|
||||
"session_id": ag.sessionID,
|
||||
"agent_id": ag.ID(),
|
||||
"profile": ag.Profile(),
|
||||
"agent": ag.Name(),
|
||||
"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 {
|
||||
|
|
@ -453,25 +362,10 @@ func (ag *Agent) spawnContext(ctx context.Context) string {
|
|||
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.
|
||||
// runCompact executes a full compaction cycle: compact, spawn-context
|
||||
// re-injection. Returns (n compacted, error). Returns (0, nil) if
|
||||
// there was nothing to compact. Caller manages setState.
|
||||
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.
|
||||
compactModel := resolveCompactionModel(ag.runtime.CompactionModel, ag.runtime.Backend)
|
||||
origModel := ag.runtime.Backend.Model()
|
||||
|
|
@ -483,27 +377,17 @@ func (ag *Agent) runCompact(ctx context.Context, trigger string) (int, error) {
|
|||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n > 0 {
|
||||
ag.auditLog.Debug("compact: removed %d messages trigger=%s session=%s", n, trigger, ag.sessionID)
|
||||
ag.warnedContext = false
|
||||
if sc := ag.spawnContext(ctx); sc != "" {
|
||||
ag.history.appendUserMessage(sc)
|
||||
if n > 0 {
|
||||
ag.auditLog.Debug("compact: removed %d messages trigger=%s session=%s", n, trigger, ag.sessionID)
|
||||
ag.warnedContext = false
|
||||
if sc := ag.spawnContext(ctx); sc != "" {
|
||||
ag.history.appendUserMessage(sc)
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// ErrInterrupted is returned when the user cancels an agent turn (Ctrl-C).
|
||||
// ErrInterrupted is returned when the user cancels an agent turn (Ctrl-C).
|
||||
var ErrInterrupted = errors.New("interrupted")
|
||||
|
||||
// defaultContextLength is used when the backend cannot report the model's
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
package backend
|
||||
|
||||
import "context"
|
||||
|
||||
// Noop is a programmable test backend that satisfies the Backend interface
|
||||
// without making any network calls. By default it returns an empty response
|
||||
// immediately. Set ChatStreamFunc to override behavior.
|
||||
type Noop struct {
|
||||
name string
|
||||
model string
|
||||
|
||||
// ChatStreamFunc, if non-nil, replaces the default ChatStream behavior.
|
||||
// Use this to simulate blocking, errors, or custom responses.
|
||||
ChatStreamFunc func(ctx context.Context, messages []Message, tools []Tool, params GenerationParams) (<-chan StreamEvent, error)
|
||||
}
|
||||
|
||||
// NewNoop creates a Noop backend with the given name and model.
|
||||
func NewNoop(name, model string) *Noop {
|
||||
if name == "" {
|
||||
name = "noop"
|
||||
}
|
||||
if model == "" {
|
||||
model = "noop"
|
||||
}
|
||||
return &Noop{name: name, model: model}
|
||||
}
|
||||
|
||||
func (n *Noop) Name() string { return n.name }
|
||||
func (n *Noop) DefaultModel() string { return n.model }
|
||||
func (n *Noop) Model() string { return n.model }
|
||||
func (n *Noop) SetModel(model string) { n.model = model }
|
||||
|
||||
func (n *Noop) ContextLength(_ context.Context) int { return 128000 }
|
||||
|
||||
func (n *Noop) Models(_ context.Context) []string {
|
||||
return []string{n.model}
|
||||
}
|
||||
|
||||
func (n *Noop) ChatStream(ctx context.Context, messages []Message, tools []Tool, params GenerationParams) (<-chan StreamEvent, error) {
|
||||
if n.ChatStreamFunc != nil {
|
||||
return n.ChatStreamFunc(ctx, messages, tools, params)
|
||||
}
|
||||
ch := make(chan StreamEvent, 1)
|
||||
ch <- StreamEvent{Done: true, StopReason: "end_turn"}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ package backend
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
|
@ -45,97 +44,3 @@ func Generate(ctx context.Context, req GenerateRequest) (string, error) {
|
|||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,14 +23,12 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"9fans.net/go/plan9"
|
||||
"9fans.net/go/plan9/client"
|
||||
"ollie/cmd/ollie-9p/mount"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -39,7 +37,7 @@ var (
|
|||
|
||||
func usage() {
|
||||
fmt.Fprintf(os.Stderr, "usage: ollie-9p [-a addr] cmd args...\n")
|
||||
fmt.Fprintf(os.Stderr, "commands: read write rdwr stat ls create rm mkdir mv mount\n")
|
||||
fmt.Fprintf(os.Stderr, "commands: read write rdwr stat ls create rm mkdir mv\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
|
@ -106,9 +104,7 @@ func main() {
|
|||
fatalf("usage: ollie-9p mv old new")
|
||||
}
|
||||
cmdMv(args[0], args[1])
|
||||
case "mount":
|
||||
cmdMount(args)
|
||||
default:
|
||||
default:
|
||||
fatalf("unknown command: %s", cmd)
|
||||
}
|
||||
}
|
||||
|
|
@ -420,33 +416,3 @@ func splitPath(path string) (string, string) {
|
|||
}
|
||||
return path[:i], path[i+1:]
|
||||
}
|
||||
|
||||
func cmdMount(args []string) {
|
||||
if len(args) < 1 {
|
||||
fatalf("usage: ollie-9p mount <address> [mountpoint]")
|
||||
}
|
||||
address := args[0]
|
||||
mnt := ""
|
||||
if len(args) > 1 {
|
||||
mnt = args[1]
|
||||
}
|
||||
if mnt == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
mnt = filepath.Join(home, "mnt", address)
|
||||
}
|
||||
if err := os.MkdirAll(mnt, 0755); err != nil {
|
||||
fatalf("cannot create mount dir: %v", err)
|
||||
}
|
||||
network := "unix"
|
||||
if strings.Contains(address, ":") {
|
||||
network = "tcp"
|
||||
}
|
||||
fmt.Printf("mounting %s at %s\n", address, mnt)
|
||||
if err := mount.Run(mount.Config{
|
||||
Network: network,
|
||||
Address: address,
|
||||
Mountpoint: mnt,
|
||||
}); err != nil {
|
||||
fatalf("mount: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
// Package mount provides FUSE-based 9P mount functionality.
|
||||
// Used by `ollie-9p mount` for network-transparent access to remote servers.
|
||||
package mount
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"9fans.net/go/plan9/client"
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
)
|
||||
|
||||
// Config configures a FUSE mount.
|
||||
type Config struct {
|
||||
Network string // "unix" or "tcp"
|
||||
Address string // server address
|
||||
Mountpoint string // local mountpoint
|
||||
Uname string // uname for Tattach (default: $USER)
|
||||
}
|
||||
|
||||
// Run mounts the remote 9P server and blocks until unmounted or signaled.
|
||||
func Run(cfg Config) error {
|
||||
if cfg.Uname == "" {
|
||||
cfg.Uname = os.Getenv("USER")
|
||||
}
|
||||
if cfg.Uname == "" {
|
||||
cfg.Uname = "none"
|
||||
}
|
||||
|
||||
conn, err := client.Dial(cfg.Network, cfg.Address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s!%s: %w", cfg.Network, cfg.Address, err)
|
||||
}
|
||||
fsys, err := conn.Attach(nil, cfg.Uname, "")
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return fmt.Errorf("attach: %w", err)
|
||||
}
|
||||
|
||||
root := &p9Dir{fsys: fsys, path: "/"}
|
||||
server, err := fs.Mount(cfg.Mountpoint, root, &fs.Options{
|
||||
MountOptions: fuse.MountOptions{
|
||||
FsName: "ollie",
|
||||
Name: "ollie9p",
|
||||
DisableXAttrs: true,
|
||||
MaxBackground: 32,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("mount %s: %w", cfg.Mountpoint, err)
|
||||
}
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
server.Unmount()
|
||||
}()
|
||||
|
||||
server.Wait()
|
||||
return nil
|
||||
}
|
||||
|
|
@ -1,271 +0,0 @@
|
|||
package mount
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/user"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
|
||||
"9fans.net/go/plan9"
|
||||
"9fans.net/go/plan9/client"
|
||||
"github.com/hanwen/go-fuse/v2/fs"
|
||||
"github.com/hanwen/go-fuse/v2/fuse"
|
||||
)
|
||||
|
||||
// p9Dir is a FUSE directory node backed by a 9P path.
|
||||
type p9Dir struct {
|
||||
fs.Inode
|
||||
fsys *client.Fsys
|
||||
path string // 9P path (empty string = root)
|
||||
}
|
||||
|
||||
// p9File is a FUSE file node backed by a 9P path.
|
||||
type p9File struct {
|
||||
fs.Inode
|
||||
fsys *client.Fsys
|
||||
path string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
var _ = (fs.NodeGetattrer)((*p9Dir)(nil))
|
||||
var _ = (fs.NodeReaddirer)((*p9Dir)(nil))
|
||||
var _ = (fs.NodeLookuper)((*p9Dir)(nil))
|
||||
var _ = (fs.NodeCreater)((*p9Dir)(nil))
|
||||
var _ = (fs.NodeUnlinker)((*p9Dir)(nil))
|
||||
var _ = (fs.NodeMkdirer)((*p9Dir)(nil))
|
||||
var _ = (fs.NodeRmdirer)((*p9Dir)(nil))
|
||||
var _ = (fs.NodeRenamer)((*p9Dir)(nil))
|
||||
|
||||
var _ = (fs.NodeGetattrer)((*p9File)(nil))
|
||||
var _ = (fs.NodeOpener)((*p9File)(nil))
|
||||
var _ = (fs.NodeReader)((*p9File)(nil))
|
||||
var _ = (fs.NodeWriter)((*p9File)(nil))
|
||||
var _ = (fs.NodeSetattrer)((*p9File)(nil))
|
||||
|
||||
func p9path(base, name string) string {
|
||||
if base == "" {
|
||||
return name
|
||||
}
|
||||
return base + "/" + name
|
||||
}
|
||||
|
||||
// --- p9Dir ---
|
||||
|
||||
func (d *p9Dir) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||
path := d.path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
dir, err := d.fsys.Stat(path)
|
||||
if err != nil {
|
||||
return syscall.ENOENT
|
||||
}
|
||||
out.Mode = uint32(dir.Mode)&0777 | syscall.S_IFDIR
|
||||
out.Uid = lookupUid(dir.Uid)
|
||||
out.Gid = lookupGid(dir.Gid)
|
||||
return 0
|
||||
}
|
||||
|
||||
func (d *p9Dir) Readdir(ctx context.Context) (fs.DirStream, syscall.Errno) {
|
||||
path := d.path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
fid, err := d.fsys.Open(path, plan9.OREAD)
|
||||
if err != nil {
|
||||
return nil, syscall.EIO
|
||||
}
|
||||
defer fid.Close()
|
||||
dirs, err := fid.Dirreadall()
|
||||
if err != nil {
|
||||
return nil, syscall.EIO
|
||||
}
|
||||
entries := make([]fuse.DirEntry, len(dirs))
|
||||
for i, dir := range dirs {
|
||||
m := uint32(dir.Mode) & 0777
|
||||
if dir.Mode&plan9.DMDIR != 0 {
|
||||
m |= syscall.S_IFDIR
|
||||
}
|
||||
entries[i] = fuse.DirEntry{Name: dir.Name, Mode: m}
|
||||
}
|
||||
return fs.NewListDirStream(entries), 0
|
||||
}
|
||||
|
||||
func (d *p9Dir) Lookup(ctx context.Context, name string, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
|
||||
child := p9path(d.path, name)
|
||||
dir, err := d.fsys.Stat(child)
|
||||
if err != nil {
|
||||
return nil, syscall.ENOENT
|
||||
}
|
||||
out.Uid = lookupUid(dir.Uid)
|
||||
out.Gid = lookupGid(dir.Gid)
|
||||
if dir.Mode&plan9.DMDIR != 0 {
|
||||
out.Mode = uint32(dir.Mode)&0777 | syscall.S_IFDIR
|
||||
node := &p9Dir{fsys: d.fsys, path: child}
|
||||
return d.NewInode(ctx, node, fs.StableAttr{Mode: syscall.S_IFDIR}), 0
|
||||
}
|
||||
out.Mode = uint32(dir.Mode) & 0777
|
||||
out.Size = dir.Length
|
||||
node := &p9File{fsys: d.fsys, path: child}
|
||||
return d.NewInode(ctx, node, fs.StableAttr{}), 0
|
||||
}
|
||||
|
||||
func (d *p9Dir) Create(ctx context.Context, name string, flags uint32, mode uint32, out *fuse.EntryOut) (*fs.Inode, fs.FileHandle, uint32, syscall.Errno) {
|
||||
child := p9path(d.path, name)
|
||||
fid, err := d.fsys.Create(child, plan9.OWRITE, plan9.Perm(mode&0777))
|
||||
if err != nil {
|
||||
return nil, nil, 0, syscall.EIO
|
||||
}
|
||||
fid.Close()
|
||||
out.Mode = mode & 0777
|
||||
node := &p9File{fsys: d.fsys, path: child}
|
||||
inode := d.NewInode(ctx, node, fs.StableAttr{})
|
||||
return inode, nil, fuse.FOPEN_DIRECT_IO, 0
|
||||
}
|
||||
|
||||
func (d *p9Dir) Unlink(ctx context.Context, name string) syscall.Errno {
|
||||
child := p9path(d.path, name)
|
||||
if err := d.fsys.Remove(child); err != nil {
|
||||
return syscall.EIO
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (d *p9Dir) Mkdir(ctx context.Context, name string, mode uint32, out *fuse.EntryOut) (*fs.Inode, syscall.Errno) {
|
||||
child := p9path(d.path, name)
|
||||
fid, err := d.fsys.Create(child, plan9.OREAD, plan9.DMDIR|plan9.Perm(mode&0777))
|
||||
if err != nil {
|
||||
return nil, syscall.EIO
|
||||
}
|
||||
fid.Close()
|
||||
out.Mode = mode&0777 | syscall.S_IFDIR
|
||||
node := &p9Dir{fsys: d.fsys, path: child}
|
||||
return d.NewInode(ctx, node, fs.StableAttr{Mode: syscall.S_IFDIR}), 0
|
||||
}
|
||||
|
||||
func (d *p9Dir) Rmdir(ctx context.Context, name string) syscall.Errno {
|
||||
child := p9path(d.path, name)
|
||||
if err := d.fsys.Remove(child); err != nil {
|
||||
return syscall.EIO
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (d *p9Dir) Rename(ctx context.Context, name string, newParent fs.InodeEmbedder, newName string, flags uint32) syscall.Errno {
|
||||
child := p9path(d.path, name)
|
||||
var dir plan9.Dir
|
||||
dir.Null()
|
||||
dir.Name = newName
|
||||
if err := d.fsys.Wstat(child, &dir); err != nil {
|
||||
return syscall.EIO
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// --- p9File ---
|
||||
|
||||
func (f *p9File) Getattr(ctx context.Context, fh fs.FileHandle, out *fuse.AttrOut) syscall.Errno {
|
||||
dir, err := f.fsys.Stat(f.path)
|
||||
if err != nil {
|
||||
return syscall.ENOENT
|
||||
}
|
||||
out.Mode = uint32(dir.Mode) & 0777
|
||||
out.Size = dir.Length
|
||||
out.Uid = lookupUid(dir.Uid)
|
||||
out.Gid = lookupGid(dir.Gid)
|
||||
return 0
|
||||
}
|
||||
|
||||
func (f *p9File) Setattr(ctx context.Context, fh fs.FileHandle, in *fuse.SetAttrIn, out *fuse.AttrOut) syscall.Errno {
|
||||
// Accept truncate silently — 9P files are append/overwrite on write.
|
||||
dir, err := f.fsys.Stat(f.path)
|
||||
if err != nil {
|
||||
return syscall.ENOENT
|
||||
}
|
||||
out.Mode = uint32(dir.Mode) & 0777
|
||||
return 0
|
||||
}
|
||||
|
||||
func (f *p9File) Open(ctx context.Context, flags uint32) (fs.FileHandle, uint32, syscall.Errno) {
|
||||
return nil, fuse.FOPEN_DIRECT_IO, 0
|
||||
}
|
||||
|
||||
func (f *p9File) Read(ctx context.Context, fh fs.FileHandle, dest []byte, off int64) (fuse.ReadResult, syscall.Errno) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
fid, err := f.fsys.Open(f.path, plan9.OREAD)
|
||||
if err != nil {
|
||||
return nil, syscall.EIO
|
||||
}
|
||||
defer fid.Close()
|
||||
if off > 0 {
|
||||
buf := make([]byte, min(off, 8192))
|
||||
remaining := off
|
||||
for remaining > 0 {
|
||||
n := int64(len(buf))
|
||||
if n > remaining {
|
||||
n = remaining
|
||||
}
|
||||
nr, err := fid.Read(buf[:n])
|
||||
if err != nil || nr == 0 {
|
||||
break
|
||||
}
|
||||
remaining -= int64(nr)
|
||||
}
|
||||
}
|
||||
n, err := fid.Read(dest)
|
||||
if n == 0 {
|
||||
return fuse.ReadResultData(nil), 0
|
||||
}
|
||||
return fuse.ReadResultData(dest[:n]), 0
|
||||
}
|
||||
|
||||
func (f *p9File) Write(ctx context.Context, fh fs.FileHandle, data []byte, off int64) (uint32, syscall.Errno) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
fid, err := f.fsys.Open(f.path, plan9.OWRITE)
|
||||
if err != nil {
|
||||
return 0, syscall.EACCES
|
||||
}
|
||||
n, err := fid.Write(data)
|
||||
if err != nil {
|
||||
fid.Close()
|
||||
return 0, syscall.EIO
|
||||
}
|
||||
if err := fid.Close(); err != nil {
|
||||
return 0, syscall.EACCES
|
||||
}
|
||||
return uint32(n), 0
|
||||
}
|
||||
|
||||
func min(a, b int64) int64 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func lookupUid(name string) uint32 {
|
||||
if id, err := strconv.ParseUint(name, 10, 32); err == nil {
|
||||
return uint32(id)
|
||||
}
|
||||
if u, err := user.Lookup(name); err == nil {
|
||||
if id, err := strconv.ParseUint(u.Uid, 10, 32); err == nil {
|
||||
return uint32(id)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func lookupGid(name string) uint32 {
|
||||
if id, err := strconv.ParseUint(name, 10, 32); err == nil {
|
||||
return uint32(id)
|
||||
}
|
||||
if g, err := user.LookupGroup(name); err == nil {
|
||||
if id, err := strconv.ParseUint(g.Gid, 10, 32); err == nil {
|
||||
return uint32(id)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
{
|
||||
"description": "Route a subtask to an appropriate model tier. Spawns a subagent that reports back.",
|
||||
"prompt": "## route\n\nRoute a subtask to an appropriate model tier. Spawns a subagent that reports back.\n\n```\nroute(instruction=\"implement the login page\")\nroute(instruction=\"refactor auth module\", tier=\"expert\")\n```",
|
||||
"args": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"instruction"
|
||||
],
|
||||
"properties": {
|
||||
"instruction": {
|
||||
"type": "string",
|
||||
"description": "Task description to route"
|
||||
},
|
||||
"tier": {
|
||||
"type": "string",
|
||||
"description": "Model tier: worker (default) or expert"
|
||||
},
|
||||
"backend": {
|
||||
"type": "string",
|
||||
"description": "Backend override"
|
||||
}
|
||||
}
|
||||
},
|
||||
"readOnly": true
|
||||
}
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
package detach
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -20,7 +17,6 @@ type Process struct {
|
|||
ExitCode int
|
||||
|
||||
Ring *RingBuffer
|
||||
Cmd *os.Process
|
||||
Done chan struct{}
|
||||
Mu sync.Mutex
|
||||
}
|
||||
|
|
@ -53,20 +49,6 @@ func (p *Process) Output() string {
|
|||
return p.Ring.String()
|
||||
}
|
||||
|
||||
// Signal sends a signal to the detached process.
|
||||
func (p *Process) Signal(sig syscall.Signal) error {
|
||||
p.Mu.Lock()
|
||||
defer p.Mu.Unlock()
|
||||
if p.Exited {
|
||||
return fmt.Errorf("process %d already exited", p.PID)
|
||||
}
|
||||
if p.Cmd == nil {
|
||||
return fmt.Errorf("process %d has no handle", p.PID)
|
||||
}
|
||||
// Send to process group
|
||||
return syscall.Kill(-p.PID, sig)
|
||||
}
|
||||
|
||||
// ringBuffer is a fixed-size circular byte buffer.
|
||||
type RingBuffer struct {
|
||||
buf []byte
|
||||
|
|
|
|||
|
|
@ -147,18 +147,6 @@ func requestGenerate(_ HandlerCtx, data []byte) ([]byte, error) {
|
|||
return []byte(result + "\n"), nil
|
||||
}
|
||||
|
||||
func requestRoute(_ HandlerCtx, data []byte) ([]byte, error) {
|
||||
var req backend.RouteRequest
|
||||
if err := json.Unmarshal(data, &req); err != nil {
|
||||
req.Task = strings.TrimSpace(string(data))
|
||||
}
|
||||
result, err := backend.Route(context.Background(), req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(fmt.Sprintf("backend=%s model=%s\n", result.Backend, result.Model)), nil
|
||||
}
|
||||
|
||||
func readAliases(_ HandlerCtx) ([]byte, error) {
|
||||
var sb strings.Builder
|
||||
for name, sess := range session.Sessions() {
|
||||
|
|
|
|||
20
fs/spec.go
20
fs/spec.go
|
|
@ -134,20 +134,14 @@ var treeSpec = DirNode("/",
|
|||
GID("agent"),
|
||||
),
|
||||
|
||||
FileNode("generate", 0666,
|
||||
Doc("One-shot LLM generation. Write JSON {prompt,...} or plain text. Read returns result."),
|
||||
Request(requestGenerate),
|
||||
GID("agent"),
|
||||
),
|
||||
FileNode("generate", 0666,
|
||||
Doc("One-shot LLM generation. Write JSON {prompt,...} or plain text. Read returns result."),
|
||||
Request(requestGenerate),
|
||||
GID("agent"),
|
||||
),
|
||||
|
||||
FileNode("route", 0666,
|
||||
Doc("Model routing. Write task description. Read returns 'backend=X model=Y' recommendation."),
|
||||
Request(requestRoute),
|
||||
GID("agent"),
|
||||
),
|
||||
|
||||
FileNode("aliases", 0444,
|
||||
Doc("Alias table for ID-based lookups. Format: id<tab>path per line."),
|
||||
FileNode("aliases", 0444,
|
||||
Doc("Alias table for ID-based lookups. Format: id<tab>path per line."),
|
||||
Read(readAliases),
|
||||
GID("agent"),
|
||||
),
|
||||
|
|
|
|||
1
justfile
1
justfile
|
|
@ -159,7 +159,6 @@ install-data:
|
|||
install -m755 data/tools/process_list {{cfg}}/tools/process_list
|
||||
install -m755 data/tools/process_output {{cfg}}/tools/process_output
|
||||
install -m755 data/tools/process_signal {{cfg}}/tools/process_signal
|
||||
install -m755 data/tools/route {{cfg}}/tools/route
|
||||
install -m755 data/tools/subagent_generate {{cfg}}/tools/subagent_generate
|
||||
install -m755 data/tools/subagent_spawn {{cfg}}/tools/subagent_spawn
|
||||
install -m755 data/tools/logseq {{cfg}}/tools/logseq
|
||||
|
|
|
|||
|
|
@ -43,30 +43,4 @@ func DiscoverTools() []ToolInfo {
|
|||
infos = append(infos, info)
|
||||
}
|
||||
return infos
|
||||
}
|
||||
|
||||
// ToolPrompt returns the full prompt content for a named tool.
|
||||
// Returns "" if the tool has no .meta file or no prompt.
|
||||
func ToolPrompt(name string) string {
|
||||
if strings.Contains(name, "/") || strings.Contains(name, "..") {
|
||||
return ""
|
||||
}
|
||||
m, err := LoadMetaFile(name)
|
||||
if err != nil || m == nil {
|
||||
return ""
|
||||
}
|
||||
return m.Prompt
|
||||
}
|
||||
|
||||
// ReadTool reads a named tool script from the tools directory.
|
||||
func ReadTool(name string) (string, error) {
|
||||
if strings.Contains(name, "/") || strings.Contains(name, "..") {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
path := filepath.Join(ToolsPath(), name)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
package toolsrv
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// ParseToolInfo builds a ToolInfo from the .meta sidecar file for the named tool.
|
||||
// Falls back to a minimal ToolInfo if no .meta exists.
|
||||
func ParseToolInfo(name, script string) ToolInfo {
|
||||
m, err := LoadMetaFile(name)
|
||||
if err == nil && m != nil {
|
||||
return ToolInfoFromMeta(name, m)
|
||||
}
|
||||
return ToolInfo{
|
||||
Name: name,
|
||||
Description: name,
|
||||
InputSchema: json.RawMessage(`{"type":"object","properties":{}}`),
|
||||
Tier: "hot",
|
||||
}
|
||||
}
|
||||
|
|
@ -346,7 +346,12 @@ func (e *Server) SignalDetached(pid int, sig syscall.Signal) error {
|
|||
defer e.detachMu.Unlock()
|
||||
for _, p := range e.detached {
|
||||
if p.PID == pid {
|
||||
return p.Signal(sig)
|
||||
p.Mu.Lock()
|
||||
defer p.Mu.Unlock()
|
||||
if p.Exited {
|
||||
return fmt.Errorf("process %d already exited", pid)
|
||||
}
|
||||
return syscall.Kill(-pid, sig)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("no detached process with pid %d", pid)
|
||||
|
|
@ -379,11 +384,9 @@ func (e *Server) DismissDetached(pid int) bool {
|
|||
|
||||
// cleanupDetached sends SIGTERM to all running detached processes.
|
||||
func (e *Server) cleanupDetached() {
|
||||
e.detachMu.Lock()
|
||||
defer e.detachMu.Unlock()
|
||||
for _, p := range e.detached {
|
||||
p.Mu.Lock()
|
||||
if !p.Exited && p.Cmd != nil {
|
||||
if !p.Exited {
|
||||
syscall.Kill(-p.PID, syscall.SIGTERM)
|
||||
}
|
||||
p.Mu.Unlock()
|
||||
|
|
|
|||
|
|
@ -244,12 +244,12 @@ func (e *Server) executeElevatedOpts(ctx context.Context, cmd, dir string, timeo
|
|||
ring := detach.NewRingBuffer(detach.RingBufSize)
|
||||
pid := int(time.Now().UnixNano() & 0x7FFFFFFF) // synthetic PID
|
||||
|
||||
proc := &detach.Process{
|
||||
proc := &detach.Process{
|
||||
PID: pid,
|
||||
Command: cmdStr,
|
||||
Started: time.Now(),
|
||||
Ring: ring,
|
||||
Done: make(chan struct{}),
|
||||
Command: cmdStr,
|
||||
Started: time.Now(),
|
||||
Ring: ring,
|
||||
Done: make(chan struct{}),
|
||||
}
|
||||
e.detachMu.Lock()
|
||||
e.detached = append(e.detached, proc)
|
||||
|
|
@ -614,13 +614,12 @@ func (e *Server) executeWithStdin(ctx context.Context, code, language string, ti
|
|||
lw.stream = nil
|
||||
lw.mu.Unlock()
|
||||
|
||||
proc := &detach.Process{
|
||||
PID: cmd.Process.Pid,
|
||||
Command: cmdStr,
|
||||
Started: time.Now(),
|
||||
Ring: ring,
|
||||
Cmd: cmd.Process,
|
||||
Done: make(chan struct{}),
|
||||
proc := &detach.Process{
|
||||
PID: cmd.Process.Pid,
|
||||
Command: cmdStr,
|
||||
Started: time.Now(),
|
||||
Ring: ring,
|
||||
Done: make(chan struct{}),
|
||||
}
|
||||
e.detachMu.Lock()
|
||||
e.detached = append(e.detached, proc)
|
||||
|
|
|
|||
Loading…
Reference in New Issue