cyberine
UsageRun recipes

Steering a running loop worker (between tool rounds)

control.json carries one of two actions — stop (above) or steer. A steer message injects free-text operator guidance into a running worker between tool rounds for loop workers, or at the next step boundary for one-shot CLI workers — never mid-generation and never aborting a running HTTP stream.

Loop workers (LoopAgentAdapter — DeepSeek, OpenRouter, Kimi, etc.)

After each completed tool round, the adapter polls the engine's steer queue. If a steer arrived since the last poll, it is injected as a user turn into the conversation context before the next API call, so the model sees the guidance immediately and can change course in the same step. Consumed steers are recorded in events.jsonl (kind: worker:steered, detail: the steer text) and in run.json under steers[] for audit.

# From cyberine chat — steers the most recently dispatched run:
/steer focus on the migration path; skip UI polish

# Optional explicit runId (e.g. after /agents showed you another run):
/steer abc12345 rewrite the tests using the new fixtures

Multiple steers queued before the next tool-round boundary are all injected in order. The [Operator steering]: prefix labels each injected turn so the model can distinguish operator guidance from normal tool results.

CLI workers (OpenCode, Claude Code, Codex — one-shot)

Bound a claude worker's bash-tool output (kqq2g) when a task runs noisy verifies (test suites, greps) that would otherwise flood the worker's own context:

CYBERINE_CLAUDE_BASH_OUTPUT_MAX_CHARS=8000 cyberine run --fleet claude "run the test suite and fix the first failure"

Known Limitations and Deferred Work: the cap is per-tool-call, not per-turn, and only the claude worker honours it (codex and muse have no equivalent setting wired); the default argv is unchanged when the variable is unset.

CYBERINE_CLAUDE_TASK_OUTPUT_MAX_CHARS (kb8rd) is RETIRED (khv8j, 2026-09-25): Claude Code 2.1.278 removed the TaskOutput tool and states that the taskOutputMaxChars setting and TASK_MAX_OUTPUT_LENGTH no longer have any effect, so Cyberine no longer reads the variable or forwards the key. Setting it does nothing. CYBERINE_CLAUDE_BASH_OUTPUT_MAX_CHARS above is unaffected.

Known Limitations and Deferred Work: two related v2.1.267 surfaces were reviewed in the kb8rd pass and deliberately NOT adopted: --system-prompt-snapshot off re-renders the system prompt on every request, which is a deliberate prompt-cache MISS and works against the cache-reuse the adapter already buys with --exclude-dynamic-system-prompt-sections; and --append-subagent-system-prompt-file does not appear in claude --help on 2.1.267, so it is treated as unverified rather than wired from a changelog line. Also recorded, not fixed: v2.1.267's maxEffortLevel setting caps effort across every provider, so a user-level or managed cap can silently lower a worker below CYBERINE_CLAUDE_EFFORT with nothing visible to the adapter.

Known Limitations and Deferred Work -- a claude worker's Agent subagents do NOT inherit the task contract (khvzt, measured on 2.1.263, transcripts under .local/tmp/khvzt/, write-up in .local/research/claude-worker-subagent-contract-2026-09-07.md): neither --append-system-prompt nor the --system-prompt the cyberine persona uses reaches a subagent; a --agents redefinition of the built-in general-purpose type DOES reach it, but only without --safe-mode, which makeClaudeAdapter passes unconditionally (T492) -- under the adapter's real flag set the redefinition is dropped. --append-subagent-system-prompt-file does not exist on this binary. So a subagent runs in the worker's cwd with Claude Code's stock instructions plus whatever the parent typed into the delegation prompt, bounded by the host sandbox and the worktree, not by the prompt contract. Nothing is wired for it; the lever is an operator decision (keep --safe-mode vs drop it for fan-out workers and pass an --agents contract) recorded in the research note.

CLI workers are one-shot subprocesses: they run to completion without an inter-round hook, so a steer cannot interrupt them mid-flight. The steer text stays queued and is prepended to the next step's rendered prompt when the step-boundary drain fires (same effect as before T74). For CLI workers backed by a standby pool slot, the queued steer is also stored on the slot itself and prepended to the NEXT task dispatched through that slot (regardless of run boundary).

Programmatic steer (any worker)

printf '%s' '{"action":"steer","text":"prioritise the migration path; skip the UI polish"}' \
  > .local/runs/<runId>/control.json

The engine reads it on the same 500ms poll, queues the text, and renames the file to control.json.done (processed exactly once). Loop workers inject it at the next tool-round boundary; CLI workers inject it at the next step boundary. Multiple steers queued before a boundary are joined under a single [Operator steering]: heading.

Run-to-run delivery uses the message bus: a step can await a child run's completion via RunStore.awaitInbox(addr, match, opts), which polls the parent run's bus inbox (<CYBERINE_HOME>/bus/inbox/run-<parentRunId>.jsonl, routed by the Phase 0 publish seam) until a matching run:completed envelope arrives or it times out (returns null when CYBERINE_BUS_BACKEND=none). This is wired into the awaitRun workflow step type — see "Define a workflow" below — so a step can delegate to a child run and block on it.

The view is a live fleet monitor: a bordered header shows a breadcrumb (fleet name, or the selected run's workflow) plus live stats (run counts, total tokens, cost, ticking elapsed), and a footer always lists the keys for the current screen. Running runs/workers animate a braille spinner and tick their elapsed time; finished rows dim and freeze to ✔ (done) or ✕ (error); the selected row carries an accent gutter bar ▌. State is color-coded (cyan = running, green = done, red = error, yellow = cancelled, gray = queued). The animation runs off a single clock that ticks fast while any run is live and slows when the fleet is idle, so smooth spinners never trigger extra disk reads.

Token usage is shown throughout: the fleet list shows each run's total tokens (and cost when a provider reports it), run detail shows per-worker in/out counts, and the peek header shows the selected worker's tokens plus a live throughput sparkline (chars/sec, derived from the output file's growth so it moves token-by-token). Counts roll up WorkerRun.usage across every step.

NOTE (macOS / terminal): the TUI needs a REAL TTY - run it directly in your terminal, do not pipe its output (Ink renders nothing to a pipe). Both the CLI adapters (opencode / claude / codex) and the loop adapter (DeepSeek-direct / OpenRouter) emit live progress. The loop adapter now streams the model's answer token-by-token (stream: true) straight into the worker peek view, on top of its per-tool-loop-iteration events; usage is captured from the final stream chunk. For DeepSeek "Think" models (deepseek-v4-pro) the chain-of-thought streams too, labelled [thinking] then [answer] in the peek tail — but only the answer is kept as the worker's final output. Note that reasoning consumes the LOOP_MAX_TOKENS budget, so set it high (thousands) for reasoning tasks.

Since T692 (2026-08-25) that [thinking] stream also shows up on DeepSeek runs that CALL TOOLS. The loop adapter used to force thinking:{type:'disabled'} on every DeepSeek-direct request that carried tools, on the (incorrect) belief that thinking mode ignores tools; both v4 models in fact return tool_calls and reasoning_content in the same message. Practical consequences:

  • loop-deepseek-direct and loop-deepseek-build now reason while working, and cost more output tokens for it (measured ~1.4–1.8x completion tokens on a small probe). Set CYBERINE_DEEPSEEK_TOOL_THINKING=off to get the old cheaper behaviour back with no code change.
  • Keep LOOP_MAX_TOKENS at or above the default 16000. Below 8000 the adapter disables thinking on its own, because max_tokens caps CoT and the answer together and a hard task can burn the whole budget on CoT and return no tool call at all.
  • LlmJudge / AdversarialJudge are unaffected either way: they send no tools, so the old gate never applied to them.
  • Verify the policy any time with npm run selftest:deepseek-thinking-tools (see below).

Known limitations and deferred work. There is no runtime recovery when a round comes back finish_reason:'length' with zero tool calls because the chain-of-thought consumed the budget — the 8000-token floor prevents the pathological small-budget case, but an unusually long CoT above the floor can still waste a round (the loop just retries). A "retry this round with thinking off" path is the obvious follow-up and was deliberately left out of T692. The chat planner (src/chat/providerTuning.ts) still forces thinking off for DeepSeek; its own reasons were not re-litigated by T692 and changing interactive chat behaviour needs a real-TTY session to verify. FIM's documented thinking-mode incompatibility was not re-probed (Cyberine does not use FIM), and the /beta strict-schema endpoint was not probed with thinking on.

Run recipe — check the DeepSeek thinking/tool_choice policy without dispatching a fleet:

npm run selftest:deepseek-thinking-tools

It pins the whole request-body truth table offline (no provider key needed, which is why CI can run it) and, when DEEPSEEK_API_KEY is set, ALSO re-asserts the live provider contract: tools plus thinking coexist, tool_choice:'required' with thinking on returns HTTP 400, and 'required' with thinking:{type:'disabled'} succeeds. Without a key those three checks report skip, not FAIL.