ollie/doc/IDEAS_CONTROLLER.md

10 KiB

IDEAS: Fit-for-Purpose Session Controller

Ollie's 9P session filesystem exposes enough observable state that an external controller process can enforce production-grade loop discipline without touching the agent loop, the model, or any prompt. The controller is a first-class citizen of the design — it reads structured synthetic files and actuates via ctl writes.

Background

Production agent systems avoid "smart shell script with amnesia" behavior by applying explicit control policies:

  • A planner/executor split where one component sets the next bounded action and another performs it.
  • Per-step budgets: max tool calls before replanning, max context tokens before compaction, max output bytes admitted back into context, max turns without net state change.
  • Structured tool results: the model sees a normalized observation record (status, key findings, changed files, exit condition) instead of raw terminal output.

Ollie's system prompt enforces the planner/executor split at the prompt layer (mandatory PLAN: checklist, persisted to s/$SID/plan). The per-step budgets and output normalization are not enforced mechanically — they are the gap that an external controller closes.

Observable Surfaces

All paths are relative to $OLLIE/s/$SID/.

Path Mode Signal class Description
state read state idle / thinking / calling:<tool>
statewait blocking read state Blocks until state changes — the event pump
ctxsz read budget Current tokens / window ceiling as N/M
ctxszwait blocking read budget Blocks until context size changes — fires after every tool call
usage read budget Token usage counters (input / output / total)
plan r/w stall Markdown checklist; [x] count delta = net progress signal
ctl write actuator stop / kill / compact / clear — controller's actuator
prompt write actuator Inject a prompt into the session
fifo.in write actuator Queue a prompt to execute after the current turn
chat read stall Full chat history — tail from offset to measure response size
offset read stall Byte offset of last user prompt

Key principle: use the *wait blocking-read variants, not polling. A controller that polls state every second wastes cycles and can miss fast transitions. Block on statewait; wake only when state actually changes.

Signal Taxonomy

State signals

Derived from statewait. Fires on every idle → thinking → calling:tool → idle transition. Gives turn boundaries without polling.

Context budget

Derived from ctxszwait and usage. Fires after each tool call, not just at idle. Enforce a ceiling as a fraction of the window before the model starts silently degrading from context loss.

Output byte budget

Computed from offset + chat. After each turn transitions to idle, measure wc -c on the chat file from the saved offset. A large delta with no corresponding plan checkbox change is raw terminal sludge.

Stall detection

Computed from the [x] line count in plan across consecutive idle events. Count idle turns where the completed-task count didn't change. That's the no-progress counter.

Controller Types

Each controller is a small independent process. They compose via process supervision — no shared framework, no IPC, just the filesystem.

budget-ctl

Watches ctxszwait. When context crosses a threshold, writes compact to ctl. When a step ceiling is crossed (counted as calling:* → idle transitions), writes a queued replan prompt to fifo.in.

  • Watches: ctxszwait, usage
  • Actuates: ctl ← compact, fifo.in ← replan prompt
  • Implementation: ~30 lines of sh + awk

stall-ctl

Blocks on statewait to get idle events. Reads the plan file on each idle, diffs the [x] count. After N idle turns with no delta, writes stop and logs the stall.

  • Watches: statewait, plan
  • Actuates: ctl ← stop
  • Implementation: ~40 lines of sh

output-ctl

After each idle, computes the chat delta from offset. If the raw response exceeds MAX_BYTES, injects a normalization prompt:

Compress your last tool output into a structured observation: status, key findings, changed files, next action.

  • Watches: statewait, offset, chat
  • Actuates: prompt ← normalize instruction
  • Implementation: ~50 lines of sh

turn-auditor

Accumulates tool-call events from state transitions (calling:<tool> → idle). Counts parallel vs sequential calls, flags turns where the agent re-read a file it already read, emits a structured turn trace for post-hoc review. Observes only — does not actuate.

  • Watches: statewait (all states)
  • Actuates: write to log file
  • Implementation: Go or Python; passive observer

Controller Loop Design

The controller state machine per session:

WATCHING  → block on statewait
          → wake on "idle"
IDLE_FIRED → read ctxsz, plan; compute chat delta
EVALUATING → check all policies: budget, stall, output size
ACTING    → write to ctl, prompt, or fifo.in (at most one action per idle)
WATCHING  → block on statewait again

For multi-signal controllers, fan out checks in parallel after each idle event, then apply a priority ordering before actuating (budget > stall > output) to avoid conflicting writes to ctl.

Reference Sketches

stall-ctl

#!/bin/sh
# stall-ctl: kill session after N idle turns with no plan progress
# Usage: stall-ctl --sid SID [--no-progress-turns N]
set -e
SID=""  MAX_STALL=3
while [ $# -gt 0 ]; do
  case $1 in
    --sid)               SID=$2;         shift 2 ;;
    --no-progress-turns) MAX_STALL=$2;   shift 2 ;;
    *) shift ;;
  esac
done
[ -z "$SID" ] && { echo "stall-ctl: --sid required" >&2; exit 1; }

CTL="$OLLIE/s/$SID"
stall_count=0
prev_checked=-1

while true; do
  # Block until state changes
  new_state=$(cat "$CTL/statewait")
  [ "$new_state" != "idle" ] && continue

  # Count completed tasks in the plan checklist
  checked=$(grep -c '^- \[x\]' "$CTL/plan" 2>/dev/null || echo 0)

  if [ "$prev_checked" -ge 0 ] && [ "$checked" -eq "$prev_checked" ]; then
    stall_count=$((stall_count + 1))
    if [ "$stall_count" -ge "$MAX_STALL" ]; then
      echo "stall-ctl: $SID stalled for $stall_count turns, stopping"
      echo "stop" > "$CTL/ctl"
      exit 0
    fi
  else
    stall_count=0
  fi
  prev_checked=$checked
done

budget-ctl

#!/bin/sh
# budget-ctl: compact when context exceeds pct of window; replan after steps-max
# Usage: budget-ctl --sid SID [--ctx-pct N] [--steps-max N] [--replan-prompt FILE]
set -e
SID=""  CTX_PCT=75  STEPS_MAX=0  REPLAN_PROMPT=""
while [ $# -gt 0 ]; do
  case $1 in
    --sid)            SID=$2;             shift 2 ;;
    --ctx-pct)        CTX_PCT=$2;         shift 2 ;;
    --steps-max)      STEPS_MAX=$2;       shift 2 ;;
    --replan-prompt)  REPLAN_PROMPT=$2;   shift 2 ;;
    *) shift ;;
  esac
done
[ -z "$SID" ] && { echo "budget-ctl: --sid required" >&2; exit 1; }

CTL="$OLLIE/s/$SID"
step=0

while true; do
  # Wake on any ctx change (fires after every tool call, not just idle)
  raw=$(cat "$CTL/ctxszwait")         # e.g. "12847/65536"
  used=$(echo "$raw" | cut -d/ -f1)
  total=$(echo "$raw" | cut -d/ -f2)
  pct=$(awk "BEGIN{print int($used*100/$total)}")

  if [ "$pct" -ge "$CTX_PCT" ]; then
    echo "budget-ctl: ctx $pct% >= threshold, compacting"
    echo "compact" > "$CTL/ctl"
  fi

  # Count steps: calling:* → idle counts as one step
  state=$(cat "$CTL/state")
  case $state in idle) step=$((step+1)) ;; esac
  if [ $STEPS_MAX -gt 0 ] && [ $step -ge $STEPS_MAX ]; then
    echo "budget-ctl: step limit reached, injecting replan"
    cat "$REPLAN_PROMPT" > "$CTL/prompt"
    step=0
  fi
done

output-ctl

#!/bin/sh
# output-ctl: inject summarize prompt when turn output exceeds MAX_BYTES
# Usage: output-ctl --sid SID [--max-bytes N]
set -e
SID=""  MAX_BYTES=4096
while [ $# -gt 0 ]; do
  case $1 in
    --sid)       SID=$2;        shift 2 ;;
    --max-bytes) MAX_BYTES=$2;  shift 2 ;;
    *) shift ;;
  esac
done
[ -z "$SID" ] && { echo "output-ctl: --sid required" >&2; exit 1; }

CTL="$OLLIE/s/$SID"
prev_offset=0

while true; do
  new_state=$(cat "$CTL/statewait")
  [ "$new_state" != "idle" ] && continue

  cur_offset=$(cat "$CTL/offset")
  chat_size=$(wc -c < "$CTL/chat")
  delta=$((chat_size - prev_offset))

  if [ "$delta" -gt "$MAX_BYTES" ]; then
    echo "output-ctl: response ${delta}B > ${MAX_BYTES}B, injecting normalize"
    printf 'Compress your last tool output into a structured observation: '\
'status, key findings, changed files, next action.' > "$CTL/prompt"
  fi

  prev_offset=$cur_offset
done

Composition

All three controllers watch the same session directory as independent processes. They share no state. Compose them per task type in a mkfile recipe:

# Start a coding session with full controller suite
SID=$(ollie session new cwd=$PWD agent=coding)
CTL="$OLLIE/s/$SID"

budget-ctl  --sid "$SID" --ctx-pct 75 --steps-max 12 \
            --replan-prompt "$OLLIE/p/replan.md" &
stall-ctl   --sid "$SID" --no-progress-turns 3 &
output-ctl  --sid "$SID" --max-bytes 4096 &

echo "$TASK" > "$CTL/prompt"
wait

Each controller process exits when it writes kill/stop to ctl or when the session dies. No daemon, no framework, no shared memory.

Gap Closure

Control gap Closed by Signal used
Max tool calls before replan budget-ctl step counter statewait → count calling:* → idle
Max context before compact budget-ctl ctx ceiling ctxszwait → threshold → compact
Max output bytes in context output-ctl offset + chat delta
Stall detection stall-ctl plan checkbox delta across idle events
Structured tool results output-ctl normalize prompt Reactive: normalizes after the fact
Hard mid-turn enforcement Not closeable externally Controller acts at turn boundaries only

Limitations

The controller acts at turn boundaries — it sees output after it is already in context. If a tool produces 100KB of stdout, output-ctl can inject a normalization prompt on the next turn, but the current context window already contains the raw output. Fully preventive output filtering would require a proxy at the tool execution layer, not the session filesystem layer.

The planner/executor discipline (mandatory PLAN: before tool calls) remains prompt-layer only. The controller can stop a session after a bad turn but cannot prevent a bad action mid-turn.