Asoba Asoba Open Source

Engine & Turn Loop

The engine is the brain of Nehanda CLI. It runs entirely in-process as a single Node.js application — no separate server daemon, no background HTTP relay. Every user interaction flows through runUserTurn, the central function in lib/orchestrate.mjs.

The Turn Lifecycle

TURN LIFECYCLE — runUserTurn(db, rt, userText, io) Hook: UserPromptSubmit Can block the prompt entirely Append to transcript_entries User message written to SQLite Jev-Mem Hot Path ingest → Laya System-1 → retrieve evidence → pinned context → System-2 messages Build System Prompt + Tools Phase-specific prompt · filtered tool list Stream API Call to Provider Anthropic SDK or OpenAI-compat · native or [TOOL_CALL] rescue Tool Calls Execute → append → loop back Plain Text Response Render to user · end turn LOOP UNTIL DONE lib/orchestrate.mjs · runUserTurn()

runUserTurn — The Main Entry Point

runUserTurn(db, rt, userText, io) is the single function that drives every interaction. It receives:

Parameter Type Description
db better-sqlite3 The SQLite database connection
rt object Runtime context: sessionId, conversationId, cwd, settings, _model
userText string The user’s input message
io object I/O adapter: write(), println(), ask(), spinner, onToolStart(), onToolResult()

Steps

  1. Hook: UserPromptSubmit — Fires before anything else. A hook can block the prompt entirely (e.g., content policy).

  2. SDLC routing — If the conversation is in idle phase and the I/O layer supports asking, the user is prompted: “Use SDLC workflow (plan → implement → test)?” If yes, the phase transitions to explore and the user’s message is wrapped in an exploration prompt template. If no, the message is passed through as-is for a free-form turn.

  3. Jev-Mem hot path — The latest transcript tail is ingested as an observation (write path: type scoring → relation edges). The System-1 state is refreshed via extractCanonicalState (Laya). Evidence is retrieved via retrieveEvidence (read path: FTS + recency → sufficiency loop). Pinned context blocks are fetched from pinned_context. These are assembled into the compact System-2 message list: pinned blocks → canonical header → retrieved evidence → last 2 turns + last failed tool result.

  4. Provider resolution — The active provider is read from settings, and the wire model name is resolved. This determines whether the Anthropic SDK path or the OpenAI-compatible path is taken.

  5. Provider dispatch — Control flows to either runSdlcAnthropic() or runSdlcOpenAICompat(). Both implement the same phase-driven loop but use different SDKs.

The Phase Loop

Inside the provider-specific functions, a while loop drives the SDLC phases:

while (['explore', 'planning', 'implement', 'test'].includes(phase)) {
  const text = await runPhase(db, rt, io, hookRt, ...tools, phase)
  if (text === null) return  // model error

  let advanced = false
  if (phase === 'explore')     advanced = await exploreGate(...)
  if (phase === 'planning')    advanced = await planGate(...)
  if (phase === 'implement')   advanced = await implementGate(...)
  if (phase === 'test')        advanced = await testGate(...)

  if (!advanced) break
  phase = getPhase(db, conversationId)
}

Each phase in the loop:

  1. Builds a phase-specific system prompt (see SDLC Workflow for prompt templates)
  2. Filters tools by phase (explore → read-only; planning → none; implement/test → all)
  3. Sends the transcript to the API with streaming enabled
  4. Collects the response (text + any tool calls)
  5. If tool calls exist, executes each one through the tool orchestrator
  6. Repeats until the model stops calling tools
  7. Passes the final text to the appropriate human gate

Tool Execution Pipeline

For each tool call the model produces:

TOOL EXECUTION PIPELINE PreToolUse Hook Can block the tool call Permission Gate evaluatePermission(): allow / deny / ask Deny Error result to model Ask Prompt user in TUI Allow Execute immediately Execute Tool Built-in · dynamic/declarative · MCP PostToolUse Hook or PostToolUseFailure · append to transcript lib/orchestrate.mjs · tool results appended to transcript_entries

Phase-based Tool Filtering

Before sending tools to the model, the orchestrator filters them:

Phase Available Tools Rationale
explore Read, Glob, Grep + explore_only dynamic tools Only discovery — no mutations
planning None Model writes plan as text, no tool calls
implement All tools Plan approved, execute freely
test All tools Run tests + mandatory safety checkers
idle All tools (with permission gate) Free-form interaction

Local-only Tool Subset

When connected to local models (LM Studio, or Ollama without native tool support), the tool set is reduced to six core tools to conserve context window: Read, Write, Edit, Bash, Glob, Grep. Web tools, notebook tools, worktree tools, and agent tools are excluded.

Subagent Spawning

The Agent tool creates a child turn within the same process. The subagent:

  1. Gets its own session_id (UUID) but shares the conversation_id
  2. Runs runUserTurn recursively with the subagent’s prompt
  3. Collects all output into a string
  4. Fires SubagentStart and SubagentStop hooks
  5. Returns the collected output as the tool result

This enables the model to delegate subtasks without any external process management.

Error Handling

The io Adapter

The I/O layer abstracts away whether the engine is running in an interactive TUI or headless pipe mode:

Method Interactive (Ink TUI) Pipe Mode
io.write(text) Renders in the terminal component Writes to stdout
io.println(text) Renders with newline Writes to stdout
io.ask(question) Shows inline prompt, waits for input Returns 'y' (auto-approve)
io.confirmBlock(payload) Raises BlockConfirmMenu ([A]/[S]/[C]) in Ink event loop; resolves with 'approve', 'sandbox', or 'cancel' Absent — toolBash auto-denies
io.spinner.start(msg) Shows spinning indicator No-op
io.spinner.stop() Hides spinner No-op
io.onToolStart(name) Updates tool indicator UI No-op
io.onToolResult(name, content, isError) Updates tool result display No-op

io.confirmBlock is the mechanism that ties the bashguard and safety-checker structured rejection payloads directly into the Ink event loop. When toolBash receives a blocked result and io.confirmBlock is present, it awaits the user’s decision before either proceeding, sandboxing, or cancelling — without blocking the Node.js event loop. When io.confirmBlock is absent (pipe/headless mode), the command is auto-denied immediately.