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:
Ollie Agent 2026-08-05 21:51:55 +02:00
parent b703dcf79c
commit fc0c368e91
25 changed files with 93 additions and 1143 deletions

View File

@ -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

View File

@ -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.

View File

@ -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"`

View File

@ -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,

View File

@ -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
}

View File

@ -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.

View File

@ -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)
}

View File

@ -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) {

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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() {

View File

@ -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"),
),

View File

@ -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

View File

@ -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
}
}

View File

@ -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",
}
}

View File

@ -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()

View File

@ -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)