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.