303 lines
10 KiB
Markdown
303 lines
10 KiB
Markdown
# 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
|
|
|
|
```sh
|
|
#!/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
|
|
|
|
```sh
|
|
#!/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
|
|
|
|
```sh
|
|
#!/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:
|
|
|
|
```sh
|
|
# 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.
|