Nsys Cortex

Agent Runtime (@nsys-cortex/runtime)

The agent runtime transforms Nsys Cortex from a sandbox orchestration platform into an autonomous AI agent. It provides the LLM integration loop, response streaming, context window management, loop detection, and session persistence.

Architecture

User Message
     │
     ▼
┌───────────────────────┐
│   CortexAgentLoop     │
│                       │
│  1. Load history      │
│  2. Build prompt      │
│  3. Stream to LLM     │──────▶ LLM Provider (Anthropic / OpenAI / Gemini / Grok / Mistral / Ollama / vLLM / LM Studio / SGLang)
│  4. Collect response  │◀──────
│  5. Execute tools     │──────▶ Tool Registry (27 tools)
│  6. Inject results    │◀──────
│  7. Repeat or stop    │
└───────────────────────┘
     │
     ▼
SSE Stream / JSON Response

LLM Providers

Nine providers are included: Anthropic, OpenAI, Gemini, Grok, Mistral, Ollama (local CLI runtime), vLLM and SGLang (self-hosted GPU servers), and LM Studio (desktop GUI runtime). The provider is selected via NSYS_CORTEX_LLM_PROVIDER (default: anthropic). For the local providers, see Ollama vs vLLM vs LM Studio — which to pick before deciding (SGLang is a vLLM alternative — covered in its own section below).

Anthropic Claude

import { AnthropicProvider } from '@nsys-cortex/runtime';

const provider = new AnthropicProvider({ apiKey: process.env.ANTHROPIC_API_KEY });

Supported models (Oct 2026): claude-opus-5-5 (the current Opus and the default — $4 / $20 per MTok), claude-fable-5-1 (Anthropic's most capable model, Fable-tier pricing), claude-sonnet-5-5, claude-haiku-5-5, claude-opus-5, claude-sonnet-5, claude-fable-5, and the 4.x line (claude-opus-4-8, claude-opus-4-7, claude-opus-4-6, claude-sonnet-4-6, claude-sonnet-4-5, claude-haiku-4-5-20251001). Any future Anthropic model also works — the provider passes the model string through verbatim, picks the request shape by generation (anthropicRequestShape(): no temperature on 4.6+, adaptive thinking with an effort level, output_config.format for structured outputs where forced tool use is rejected), and reads the budget from MODEL_CONTEXT_BUDGETS (falls back to DEFAULT_CONTEXT_BUDGET for unknown names). On Opus 5.5, Fable and the other 5.x models thinking is always on (adaptive; the provider never sends disabled), and a safety classifier may decline a request with stop_reason: refusal — the provider surfaces that as an error event naming the category instead of a blank reply.

Anthropic-specific features wired through LLMProviderOptions:

OpenAI-Compatible

import { OpenAIProvider } from '@nsys-cortex/runtime';

const provider = new OpenAIProvider({
  apiKey: process.env.OPENAI_API_KEY,
  baseUrl: 'https://api.openai.com/v1',
});

Supported OpenAI models (Oct 2026): gpt-6.1-sol (the default — "near-Astra performance for complex work at a lower cost", 1.05M context, $2 / $10 per MTok), gpt-6-astra (OpenAI's flagship, $10 / $50), gpt-6.1-terra (fast), gpt-6-luna (most efficient), and the still-served gpt-5 / gpt-5-mini / gpt-4o family. The GPT-6 and GPT-5 lines and the o-series are reasoning models: the provider never sends temperature to them (openaiSendsTemperature()) and maps --think to reasoning_effort; gpt-4o and the OpenAI-compatible siblings (Grok, Mistral, the local runtimes) keep receiving temperature.

Gemini (Google)

import { GeminiProvider } from '@nsys-cortex/runtime';

const provider = new GeminiProvider({ apiKey: process.env.GEMINI_API_KEY });

Uses raw fetch() + SSE parsing against the Gemini streamGenerateContent API. Supported models (Oct 2026): gemini-4-argon (the default — Google's flagship reasoning model, Stable, 1M context), gemini-3.8-flash (the current Flash, Stable, 1,048,576 context), gemini-3.6-flash, gemini-3.5-flash-lite, gemini-3.1-pro-preview (still a preview; gemini-3.1-pro is not a real id), and the limited-access gemini-2.5-pro / gemini-2.5-flash (retiring); the 2.0 line is shut down. On Gemini 3.x and 4 the provider sends no temperature (Google: remove the sampling parameters from every 3.x request) and maps --think to thinkingConfig.thinkingLevel (geminiThinkingStyle()); on 2.5 it sends temperature and thinkingBudget as before.

Grok (xAI)

Reuses OpenAIProvider with the xAI base URL (https://api.x.ai/v1). Set NSYS_CORTEX_LLM_PROVIDER=grok and XAI_API_KEY. Supported models (Oct 2026): grok-4.7 (current flagship, 500k context, $2 / $6 per MTok), grok-4.6, grok-4.5 (500k), grok-4.3 (1M, cheaper); grok-4 and grok-3 stay registered while xAI serves them. Grok keeps receiving temperature — xAI's model page says nothing against it.

Mistral

Reuses OpenAIProvider with the Mistral base URL (https://api.mistral.ai/v1). Set NSYS_CORTEX_LLM_PROVIDER=mistral and MISTRAL_API_KEY. The default model is mistral-large-latest — Mistral's own moving pointer to the current Large flagship; pin to a dated id (mistral-large-4 is Large 4, public preview since 2026-10-06 with a 1M window; mistral-medium-3-5, mistral-small-2603) only if you need reproducibility across their version cuts. mistral-large-2411 is in Mistral's deprecated table.

Registered models in MODEL_CONTEXT_BUDGETS: mistral-large-4 (1M), mistral-large-latest / mistral-medium-3-5 / mistral-medium-latest / mistral-small-2603 / mistral-small-latest (256k — the pointers are budgeted at the smallest model they can now resolve to), mistral-large-2411 / ministral-8b-latest / ministral-3b-latest (128k), and codestral-latest (256k, code-specialist). Any other model string passes through to the API verbatim and falls through to DEFAULT_CONTEXT_BUDGET (128k/4096).

Limitations vs the other OpenAI-compat providers:

Ollama (Local)

Reuses OpenAIProvider with Ollama's OpenAI-compatible endpoint (http://localhost:11434/v1). Set NSYS_CORTEX_LLM_PROVIDER=ollama and NSYS_CORTEX_LLM_MODEL to whichever model name you ollama pull locally (defaults to llama4 — Llama 4 Scout, Ollama's llama4:16x17b build, 67 GB, the last open Llama Meta released; llama3.3 at 43 GB remains the lighter pick, llama4:maverick the 245 GB larger one). No API key required. Override the base URL with OLLAMA_BASE_URL.

vLLM (Local / Self-Hosted)

Reuses OpenAIProvider with vLLM's built-in OpenAI-compatible HTTP server (vllm serve, default http://localhost:8000/v1). Set NSYS_CORTEX_LLM_PROVIDER=vllm and NSYS_CORTEX_LLM_MODEL to the id you started vLLM with — the Hugging Face id, or the --served-model-name alias when the server sets one (defaults to Qwen/Qwen3.8-27B, the BF16 release, ~54 GB; Qwen/Qwen3.8-27B-FP8 is ~28 GB, and a GPTQ-Int4 build such as sokada4/Qwen3.8-27B-GPTQ-Int4 (~18 GB) fits one 24 GB Ampere card — the KB's two-A10 box serves it as qwen3.8-27b; 262K native context, thinking on by default; the default config's temperature is 1, Qwen's thinking-mode sampling). vLLM has no auto-pull — the model must already be loaded on the server; set NSYS_CORTEX_LLM_CONTEXT_TOKENS to its --max-model-len when that is below the model's window. API key is optional and only enforced if vLLM was started with --api-key; set VLLM_API_KEY to mirror that. Override the base URL with VLLM_BASE_URL.

SGLang (Local / Self-Hosted)

Reuses OpenAIProvider with SGLang's OpenAI-compatible server (python -m sglang.launch_server, default http://localhost:30000/v1). Set NSYS_CORTEX_LLM_PROVIDER=sglang and NSYS_CORTEX_LLM_MODEL to the id you launched with — the Hugging Face id, or the --served-model-name alias when the server sets one (defaults to Qwen/Qwen3.8-27B; the same checkpoints and the same thinking-mode temperature 1 as vLLM above). Like vLLM, it's a GPU-server runtime with no auto-pull — the model must already be loaded; set NSYS_CORTEX_LLM_CONTEXT_TOKENS to its context length when that is below the model's window. API key is optional and only enforced if you passed --api-key; set SGLANG_API_KEY to mirror that. Override the base URL with SGLANG_BASE_URL.

SGLang vs vLLM: both are datacenter-GPU OpenAI-compatible servers optimized for throughput; they're direct alternatives, and Cortex treats them identically at the wire level. SGLang's distinguishing feature is RadixAttention — automatic prefix caching across requests that share a common prefix. That's a strong fit for an agent platform: every turn of a tool-use loop resends the same growing system-prompt-plus-history prefix, and RadixAttention lets the server skip recomputing it. If your workload is agentic (long multi-turn tool loops on one model) rather than many-short-independent-prompts, SGLang is worth benchmarking against vLLM. Deployment shape is otherwise the same: pick by which server your infra team already runs and which one wins your throughput benchmark on your model + hardware.

LM Studio (Local / Desktop GUI)

Reuses OpenAIProvider with LM Studio's built-in local server (default http://localhost:1234/v1, configurable in the UI under Developer → Local Server). Set NSYS_CORTEX_LLM_PROVIDER=lmstudio and NSYS_CORTEX_LLM_MODEL to the identifier LM Studio reports in GET /v1/models for the loaded model (defaults to qwen/qwen3.8-27b, LM Studio's catalog key for Qwen3.8-27B — the GGUF is lmstudio-community/Qwen3.8-27B-GGUF: Q4_K_M 16.8 GB, Q6_K 22.4 GB, Q8_0 29 GB; the default config's temperature is 1, Qwen's thinking-mode sampling and LM Studio's own default for the model). LM Studio handles downloads and quantization selection through its UI — there's nothing to pull from the CLI; the identifier is the catalog key for a catalog download, not the Hugging Face path. No API key is required by default; set LMSTUDIO_API_KEY if your setup is behind a reverse proxy that enforces one. Override the base URL with LMSTUDIO_BASE_URL.

LM Studio overlaps with Ollama on hardware (both target consumer machines) but is aimed at users who want a GUI for model browsing, quant comparison, and parameter tuning — Ollama is CLI-first and opinionated about quants.

Ollama vs vLLM vs LM Studio — which to pick

All the local providers speak the same OpenAI-compatible wire format, so the agent loop and tools don't care which one is upstream. They're not really competing for the same use case — pick by hardware, concurrency profile, and how you like to manage models. (SGLang isn't a separate column: it occupies the same slot as vLLM in every row — datacenter GPUs, HF model IDs, high-concurrency batching — differing mainly in RadixAttention prefix caching; see SGLang for when to prefer it.)

Question Ollama vLLM LM Studio
Target hardware Laptop / single workstation (Apple Silicon, single consumer GPU, CPU) Datacenter GPUs (A100/H100/H200) or beefy consumer GPUs Laptop / single workstation (Apple Silicon, single consumer GPU)
Interface CLI / API only CLI / API only Desktop GUI for model discovery + chat, plus API server
Setup friction ollama pull <model> — quantization defaults sized for consumer VRAM Server-side vllm serve <hf-model-id> with explicit GPU config Search/download in UI; pick quant from a list; toggle the local server
Concurrent requests 1 — requests are serialized by default 10s–100s — paged attention + continuous batching 1 — single-user desktop assumption
Throughput at high concurrency Baseline Typically 10–24× Ollama / naive servers Baseline (not its target)
Model swapping Hot-swap between pulled models One model per server process; restart to swap Swap from UI; one model loaded at a time
Quantization story Opinionated defaults (Q4 typical), few knobs Full-precision FP16/BF16; quantization is opt-in Browse all GGUF quants and pick — full control
Multi-GPU No tensor parallelism Tensor + pipeline parallelism across GPUs No tensor parallelism
CPU / Metal inference Yes — works without a GPU No — requires CUDA / ROCm Yes — Metal on Apple Silicon, CUDA on Windows, Vulkan/ROCm elsewhere
Best fit "My model" — single-user CLI dev, scripted setups, headless servers "Our model" — shared inference for a team, multi-tenant agent backend "My model with knobs" — UI-first users, comparing quants, demo machines

For nsys-cortex specifically: if you're driving nsys-cortex chat from your terminal, use Ollama — least friction, scriptable, no GUI. If you're a UI-first user who wants to browse models and try different quants without learning CLI tags, use LM Studio — same hardware envelope, friendlier discovery. If you're pointing the HTTP API at a shared internal inference server hosting one workhorse model for a team, use vLLM. There's no reason to pick one universally — that's why we wired them as siblings.

Rule of thumb: Ollama for "my model" in the terminal, LM Studio for "my model" with a UI, vLLM or SGLang for "our model" behind an API (SGLang when the workload is agentic — its prefix caching pays off on long tool-use loops).

Provider Factory

import { createProvider } from '@nsys-cortex/runtime';

// Auto-selects based on NSYS_CORTEX_LLM_PROVIDER env var
const provider = createProvider();

// Or specify explicitly
createProvider({ provider: 'gemini', apiKey: 'key' });
createProvider({ provider: 'grok', apiKey: 'key' });
createProvider({ provider: 'mistral', apiKey: 'key' });
createProvider({ provider: 'ollama' });
createProvider({ provider: 'vllm', baseUrl: 'http://gpu-host:8000/v1' });
createProvider({ provider: 'lmstudio' });
createProvider({ provider: 'sglang' });
Provider Class API Key Env Base URL
anthropic AnthropicProvider ANTHROPIC_API_KEY Anthropic default
openai OpenAIProvider OPENAI_API_KEY https://api.openai.com/v1
gemini GeminiProvider GEMINI_API_KEY https://generativelanguage.googleapis.com/v1beta
grok OpenAIProvider XAI_API_KEY https://api.x.ai/v1
mistral OpenAIProvider MISTRAL_API_KEY https://api.mistral.ai/v1
ollama OpenAIProvider — (optional) http://localhost:11434/v1
vllm OpenAIProvider VLLM_API_KEY (optional, only if vLLM started with --api-key) http://localhost:8000/v1
lmstudio OpenAIProvider LMSTUDIO_API_KEY (optional, only if behind an auth proxy) http://localhost:1234/v1
sglang OpenAIProvider SGLANG_API_KEY (optional, only if started with --api-key) http://localhost:30000/v1

Model Failover Chain

FailoverProvider wraps an ordered list of (provider, model?) candidates behind the standard LLMProvider interface. When a candidate fails before yielding its first event (auth error, rate limit, model not found, network), the next candidate is tried immediately — no backoff, that's the point of a fallback. Once any event has been yielded, errors propagate unchanged: switching models mid-response would stitch torn output from two models together.

Configure via env var or config file — when set, the chain fully defines provider selection (takes precedence over NSYS_CORTEX_LLM_PROVIDER):

# provider[:model], comma-separated; only the FIRST colon splits, so
# HF-style model paths (vllm:Qwen/Qwen3.8-27B) work.
export NSYS_CORTEX_LLM_FAILOVER="anthropic:claude-opus-5-5,openai:gpt-6.1-sol,ollama:llama4"
{ "llm": { "failover": ["anthropic:claude-opus-5-5", "openai:gpt-6.1-sol", "ollama:llama4"] } }

A candidate without a model uses the run's requested model (sensible for same-provider chains); cross-provider candidates almost always need an explicit model, since names don't transfer between providers. Each candidate reaches its own endpoint: the factory reads the per-provider <PROVIDER>_BASE_URL (exported from the config file's llm.providers.<p>.baseUrl), never the shared NSYS_CORTEX_LLM_BASE_URL, which overrides only the provider selected by NSYS_CORTEX_LLM_PROVIDER. Unknown provider names fail fast at createProvider() time. Each failover hop is logged (runtime:failover, warn level) with from/to and the triggering error.

Semantics worth knowing:

Retry fix shipped alongside this: providers are async generators, so the agent loop's previous withRetry(() => provider.stream(...)) retried only the (never-failing) generator construction — a single 529 killed the turn despite the documented backoff. The loop now prefetches the first stream event inside withRetry, so pre-first-token failures genuinely retry; post-first-token errors still propagate (torn output can't be redone).

Structured Outputs

On Opus 5.5, Sonnet 5.5, Fable 5.1 and Mythos 5.1 the Anthropic provider uses the GA output_config.format JSON schema instead of the forced-tool translation below — those models reject forced tool_choice with a 400. That path coexists with tools and thinking, so nothing is dropped; the older models keep the translation.

AgentRunOptions.responseFormat / LLMProviderOptions.responseFormat constrain the model's final text response to a JSON schema:

{ type: 'json_schema', name: 'ticket', schema: { type: 'object', properties: {...}, required: [...], additionalProperties: false }, strict: true }

Per-provider mapping:

Provider Mechanism Coexists with tools?
OpenAI / Grok / Mistral / vLLM / LM Studio / Ollama Native response_format: { type: 'json_schema', json_schema: {...} } — constrained decoding (strict defaults true) Yes — schema constrains assistant text; tool calls unaffected
Gemini generationConfig.responseJsonSchema + responseMimeType: application/json (2.5+) No — controlled generation conflicts with function calling; dropped with a warning when tools are present
Anthropic GA-safe forced-tool-use translation — a synthetic structured_output tool whose input_schema is your schema, with tool_choice forced; the streamed tool input is re-emitted as text_delta events and stop_reason maps to end_turn, so consumers see a plain JSON text response No — forced tool_choice conflicts with the request's own tools and with extended thinking; dropped with a warning in either case

Notes:

Vision Input

AgentRunOptions.images attaches base64 images to the current user message:

agentLoop.run({ agentId, sessionId, message: 'what is in this screenshot?', images: [{ data: base64Png, mimeType: 'image/png' }] });

Per-provider mapping — all native, no preprocessing:

Provider User-message shape
Anthropic base64 image content blocks (was already supported by the converter)
OpenAI-compat (OpenAI, Grok, Mistral, vLLM, LM Studio, Ollama) multimodal content parts with image_url data URLs
Gemini inline_data parts (mime_type + base64 data)

Semantics:

Surfaces: HTTP API images: [{ data, mimeType }] on /message + /run; CLI --image <file> (repeatable, non-interactive) and inline @path/to/image.png tokens in interactive chat.

Provider Interface

All providers implement LLMProvider:

interface LLMProvider {
  readonly name: string;
  stream(messages: LLMMessage[], options: LLMProviderOptions): AsyncIterable<LLMStreamEvent>;
  countTokens(text: string): number;
  countMessageTokens(messages: LLMMessage[]): number;
}

Message Conversion

Each provider converts the internal LLMMessage[] format to the provider-specific API format:

Anthropic:

OpenAI (and every OpenAI-compat sibling: Grok, Mistral, Ollama, vLLM, LM Studio, SGLang):

Gemini:

Agent Loop

The core execution loop is an AsyncGenerator that yields events as they happen. The caller decides what to do with them (stream to SSE, log, persist, ignore).

import { CortexAgentLoop } from '@nsys-cortex/runtime';

const loop = new CortexAgentLoop({
  provider,
  toolRegistry: registry,
  sessionManager,
  systemPromptBuilder: (agentId, sessionId) => buildCortexSystemPrompt({ ... }),
});

for await (const event of loop.run({
  agentId: 'my-agent',
  sessionId: 'ses-123',
  message: 'Create a hello.txt file',
  model: 'claude-sonnet-4-6',
  maxTurns: 25,
  timeout: 600_000,
})) {
  console.log(event.type, event);
}

Event Types

Event When Key Fields
run_start Run begins runId
text_delta Model outputs text text
thinking_delta Extended-thinking / reasoning text delta (Anthropic, OpenAI GPT-5, Gemini 2.5+ when enabled) text
tool_call_start Tool call begins toolCallId, toolName, effectClass
tool_call_end Tool call completes (success OR error) toolCallId, toolResult, durationMs, effectClass
tool_error Tool returned isError: true (also fires tool_call_end after it) toolCallId, toolResult, durationMs, effectClass
turn_complete One model turn finishes turnNumber, usage, stopReason
retrying Provider stream call was retried (transient 5xx/529) (reserved)
memory_flush Pre-compaction memory save —
compaction History was compacted —
loop_detected Tool loop detected loopPattern, text
run_complete Run finished (ALWAYS yielded, even on errors) totalTurns, totalUsage, stopReason
error Error occurred error

run_complete is guaranteed to fire on every termination path because the main loop is wrapped in try/finally. tool_call_end always fires after a tool finishes; tool_error fires additionally when result.isError === true so UIs can show a distinct failure marker. The three tool-call events carry the tool's effectClass (ADR-011: read, mutate-reversible, mutate-irreversible or destructive, read from the registry) so a consumer routes approvals and audits from the stream itself; the events of a tool the registry does not know carry none. (#12)

Tool Execution

When the model returns multiple tool calls in one response, the agent loop partitions them into parallel-safe groups by parallelSafe flag + disjoint resourceKeys. Each group is executed via Promise.all; groups run serially.

Read tools (read, grep, find, ls, stat) parallelize by default. Mutating tools (write, edit, apply_patch, process_kill, memory_append, memory_replace) declare parallelSafe: false and surface their target path/PID/file as resource keys so the planner serializes on overlap.

If a tool is unknown or blocked by policy, the agent loop returns a CortexToolResult with isError: true to the model. If a tool's execute method throws, the error is caught and returned with isError: true as well. The Anthropic provider propagates isError as is_error: true on the tool_result block so the LLM can reliably distinguish failure from prose.

Malformed tool input JSON from the model is silently replaced with {} — the tool still executes with empty input rather than crashing the run.

Hooks and approvals

Before every tool dispatch and before every model call the loop consults the hooks its caller installed (ADR-012, #16): AgentLoopOptions.hooks is { beforeToolCall?(ctx, signal), beforeLlmCall?(ctx, signal) }, each answering { action: 'allow' } or { action: 'deny', reason } — synchronously, or as a promise the loop awaits with the run's signal (a deferred decision; an abort ends the wait). A beforeToolCall deny yields hook_denied and a tool_call_end whose isError result is denied: <reason>, so the model is told why and history stays tool-pair balanced — the call never starts (tool_call_start is emitted at dispatch, after the hook and the gate, with the call's input). A beforeLlmCall deny ends the run (hook_denied, error hook denied the model call: <reason>, run_complete): there is no model to tell. beforeLlmCall is consulted once per attempt sequence, before withRetry, never inside it. Hooks narrow: the policy engine's own deny inside executeTool stays, and a hook cannot widen it.

The product's approval gate rides on the same point: a tool whose effect class AgentLoopOptions.approvalPolicy.requireFor lists (default ['destructive']; agent.approval.requireFor / NSYS_CORTEX_APPROVAL_REQUIRE_FOR), a tool whose policy.requiresApproval is set, and the elevated tool at the ask level (createElevatedTool(hostExec, policy, { level: 'ask' }); off returns no tool) are announced as approval_required (approvalId, toolCallId, toolName, toolInput, effectClass, expiresAt) and run only after an allow: AgentLoopOptions.approvals is an ApprovalBroker (request() / decide() / pending(); InMemoryApprovalBroker ships) and the decision arrives as approval_decided (decision, reason, decidedBy). No decision within approvalPolicy.timeoutMs (default 300 000; NSYS_CORTEX_APPROVAL_TIMEOUT_MS) is a deny (timeout), an abort is a deny (aborted), and without a broker a required approval is an immediate deny (no approval surface) — a gated tool never runs on silence. A parallel group is consulted as a whole: its approvals are requested together and decided independently, then the calls that passed are dispatched together. readApprovalPolicy() reads the two variables (a bad value is ignored with a warn). The surfaces — the interactive chat's prompt, the HTTP decision route — are #16 stage 2.

Run Lifecycle

  1. Generate runId, resolve context budget for the model
  2. Load session history (up to 200 messages), append user message
  3. Build system prompt, convert tools to LLM definitions
  4. Main loop (try/finally) (up to maxTurns iterations):
    • Check abort signal and timeout
    • If contextManager.needsCompaction() → run memory flush, then compact (tool-pair-safe — never splits tool_use from its tool_result)
    • Assert tool-pair balance before each provider call (assertToolPairsBalanced())
    • Stream model response via withRetry() (auto-retries 408/425/429/500/502/503/504/529 + Node net errors with exponential backoff + jitter, up to 4 attempts; honors Retry-After). The first stream event is prefetched inside the retry wrapper — async-generator providers run no code until iteration, so this is what makes pre-first-token failures actually retryable
    • Collect text + tool calls; if stopReason is end_turn → mark completedNormally, return
    • Plan parallel-safe groups; execute tool calls per group; fire tool_error for any failures
    • If loop detected → yield loop_detected, return
  5. If maxTurns exceeded → yield error
  6. finally — always yield run_complete with totalTurns, totalUsage, and stopReason ('aborted_or_error' if the run didn't complete normally)

Abort, Timeout & Cancellation

Prompt Caching

Provider-level prompt caching dramatically reduces input-token cost on multi-turn sessions.

Extended Thinking

Reasoning-capable models on Anthropic (Claude 4.x), OpenAI (o-series, GPT-5), and Gemini (2.5+) can spend extra tokens reasoning before they answer. Enable via AgentRunOptions.thinkingBudgetTokens (CLI: chat --think [budget]).

Wire (top to bottom):

Layer Behavior
Caller (CLI / API) Sets thinkingBudgetTokens: N on agentLoop.run(opts). The CLI's --think flag normalizes Commander's [budget] optional-arg form (true for bare flag → default 4000, integer for explicit value).
Agent loop Forwards as LLMProviderOptions.thinking: { type: 'enabled', budgetTokens: N }. Conditionally sets temperature: 1 because the pre-4.6 Claude models 400 on temperature < 1 when thinking is enabled — the Anthropic provider drops temperature entirely on 4.6+ (below), OpenAI / Gemini providers compensate internally. Accumulates thinking_end events into a thinkingBlocks buffer for the turn.
Anthropic provider Request shape depends on the model generation (anthropicRequestShape()): on Opus 4.6+ / Sonnet 4.6+ / Fable / every 5.x model it sends thinking: { type: 'adaptive', display: 'summarized' } plus output_config: { effort } bucketed from the budget (≤1024 low, ≤4096 medium, else high — the OpenAI thresholds), and never sends temperature (a 400 on 4.7+ and 5.x, deprecated on 4.6); on Haiku 4.5 / Sonnet 4.5 and earlier it sends the legacy thinking: { type: 'enabled', budget_tokens: N } and the temperature. Stream loop tracks inThinking state machine: content_block_start(thinking) → buffer thinking_delta text + signature_delta HMAC chunks → content_block_stop emits thinking_end with full text + signature.
OpenAI provider Maps budgetTokens to reasoning_effort (≤1024 → low, ≤4096 → medium, else high) and drops temperature because reasoning models reject the param. Streams any delta.reasoning_content as thinking_delta, emits a single thinking_end (signature: '') on transition to text or finish. Surfaces usage.completion_tokens_details.reasoning_tokens as TokenUsage.reasoningTokens.
Gemini provider On Gemini 3.x / 4 sets generationConfig.thinkingConfig.thinkingLevel (≤1024 → low, ≤4096 → medium, else high) + includeThoughts: true and sends no temperature; on 2.5 sets thinkingBudget = N + includeThoughts: true. Stream parts marked thought: true are emitted as thinking_delta; transition to a non-thought part flushes thinking_end (signature: ''). Surfaces usageMetadata.thoughtsTokenCount as TokenUsage.reasoningTokens.
Grok / Ollama / vLLM / LM Studio / SGLang All reuse OpenAIProvider: reasoning_effort goes on the wire (xhigh instead of high on Qwen3.8), delta.reasoning_content streams as thinking_delta when the server runs a reasoning parser (vLLM --reasoning-parser qwen3). A model without an effort knob ignores the field.
Mistral Reuses OpenAIProvider. Mistral's reasoning lineup (Magistral) uses a non-OpenAI-compat API surface, so --think is silently dropped on this provider for now. Function calling and streaming work unchanged.
Stream events thinking_delta (incremental text) and thinking_end (full text + optional signature) on LLMStreamEvent. thinking_delta on AgentRunEvent is consumed by the CLI UI; thinking_end is internal.
Assistant history When thinking is on for a turn, the assistant message is stored with structured content: [ {type:'thinking', thinking, signature}, {type:'text', text} ] instead of a plain string. When thinking is off, content stays a string — preserves on-disk session JSONL shape for existing consumers. OpenAI / Gemini emit an empty signature; only Anthropic uses it for cross-turn integrity.
Provider conversion (next turn) Anthropic's convertContentBlocks round-trips thinking blocks verbatim into ContentBlockParam. This is load-bearing for Anthropic: it signs every thinking block with an HMAC, and a subsequent tool-use request that omits the signature, mutates the text, or reorders the blocks returns 400: thinking blocks must be preserved in subsequent turns. OpenAI / Gemini converters filter to type === 'text' blocks, so the empty-signature thinking blocks are silently dropped on round-trip (harmless — they don't carry cross-turn state on those providers).

Constraints to be aware of (intentional, not bugs):

CLI rendering: streaming thinking is shown inline as dim italic with a [thinking] gutter, transitioning cleanly into the markdown-rendered response on first text_delta. The non-interactive -p flow drops thinking output (it's reasoning, not response — including it would garble the JSON / piped text contract).

Context Window Management

The ContextManager tracks token usage and triggers automatic compaction when the conversation approaches the model's context limit.

Budget Defaults

Model Family Context Window Output Reserve Compaction Threshold
Claude Sonnet 4.5 / 4.6, Opus 4.7 / 4.8, Fable 5 1,000,000 8,192 85%
Claude Opus 4.6, Haiku 4.5 200,000 8,192 85%
GPT-5 / GPT-5-mini 400,000 4,096-8,192 85%
GPT-4o family 128,000 4,096 85%
Gemini 3.1 / 2.5 1,000,000 8,192 85%
Grok 4 256,000 4,096-8,192 85%
Grok 3 128,000 4,096 85%
Mistral Large / Medium / Small / Ministral 128,000 4,096-8,192 85%
Codestral 256,000 8,192 85%
Ollama (default) 128,000, or NSYS_CORTEX_LLM_CONTEXT_TOKENS 4,096 85%
vLLM (default; depends on loaded model) 128,000, or NSYS_CORTEX_LLM_CONTEXT_TOKENS 4,096 85%
LM Studio (default; depends on loaded model) 128,000, or NSYS_CORTEX_LLM_CONTEXT_TOKENS 4,096 85%
SGLang (default; depends on loaded model) 128,000, or NSYS_CORTEX_LLM_CONTEXT_TOKENS 4,096 85%

1M context on Claude Sonnet 4.x, Opus 4.7+, and Fable 5 is GA — the Anthropic provider does not send any beta header. The older anthropic-beta: context-1m-2025-08-07 opt-in is no longer needed and would pin you to a legacy SKU; we deliberately omit it.

For operators who need to enable a different Anthropic beta (extended caching variants, computer use, etc.), set NSYS_CORTEX_ANTHROPIC_BETA to a comma-separated list — the provider attaches it as anthropic-beta: <list> on every request. The default is empty.

Compaction Threshold

usableBudget = maxContextTokens - reserveForOutput
needsCompaction = (systemPromptTokens + historyTokens) >= usableBudget * compactionThreshold

Token Counting

ContextManager accepts any Tokenizer implementation; three are shipped:

Tokenizer Backend When to use
SimpleTokenizer chars / 4 heuristic Fallback — no dependency. Off by up to ~40% on code-heavy content.
AnthropicTokenizer SDK client.messages.countTokens with periodic recalibration Anthropic provider — exact counts, cheap because we cache the result and only re-query when the message set changes substantially.
OpenAITokenizer js-tiktoken with o200k_base encoder OpenAI / GPT-4o / GPT-5 / Grok / Mistral / Ollama / vLLM / LM Studio / SGLang (anything OpenAI-compatible) — exact counts.

Selection is automatic: every provider exposes getTokenizer(model) on the LLMProvider interface, and the agent loop injects the result into the ContextManager it builds per run. Anthropic hands back AnthropicTokenizer; the OpenAI-compatible family hands back OpenAITokenizer; Gemini also hands back OpenAITokenizer as an offline proxy (its REST countTokens endpoint would put a network round-trip on the hot compaction check; o200k_base is within ~10-15% of Gemini's SentencePiece counts, which the conservative 85% threshold absorbs). FailoverProvider delegates to its primary candidate. SimpleTokenizer is only used when a provider offers nothing (e.g. test mocks). The CLI's /context readout measures with the same tokenizer via agentLoop.getContextTokenizer(model), so what you see is what compaction acts on.

Per-element overheads (used by SimpleTokenizer and OpenAITokenizer.countMessages):

Element Overhead
Per message 3 tokens
text block ceil(text.length / N) where N is the encoder rate
tool_use block 20 + token count of JSON.stringify(input)
tool_result block 10 + text content tokens
image block 1,000 (fixed overestimate)

Compaction triggers on the result, so an under-counted tokenizer can spill over the model's actual budget — which is why the loop injects the provider's calibrated tokenizer automatically rather than leaving SimpleTokenizer as the effective default.

Compaction Process

  1. Context exceeds compactionThreshold * (maxContextTokens - reserveForOutput)
  2. Memory flush: A silent turn prompts the agent to save important context to memory files (see below)
  3. Snap tail boundary: The desired tail-start index is moved outward via snapTailBoundary() so the cut never falls between a tool_use block and its matching tool_result. Without this, the next provider call would 400 with tool_use ids must be followed by a tool_result whenever an old turn was a tool-use followed by a tool-result.
  4. Split: History is divided into three parts using the snapped boundary:
    • First — the original user message (always preserved)
    • Middle — messages between first and the snapped tail start (to be summarized)
    • Tail — preserved tail (10 messages by default, possibly more if the snap moved the boundary outward)
  5. Serialize middle: Each message is serialized with its role, text content, [tool_use: name(input)] markers, [tool_result: text] markers, and [called: name(input)] for tool calls. If the serialized text exceeds 50,000 characters, it is truncated with a [...truncated...] marker.
  6. Summarize: The serialized middle is sent to the LLM with a summarization prompt (max 1,024 output tokens by default, configurable via summaryMaxTokens). The prompt instructs the model to preserve key decisions, file paths, errors, and task state.
  7. Fallback: If summarization fails (provider error), a fallback message is used: "[Compaction summary unavailable — N messages were removed to free context space]"
  8. Reassemble: [first message] + [user message with [CONTEXT_SUMMARY] prefix] + [tail messages]. The summary is injected as a user-role message, not system — several providers' message converters mishandle interleaved system messages.
  9. Invariant assertion: assertToolPairsBalanced() runs on the reassembled history; throws loudly in dev if the snap logic ever produces an unbalanced result, so bugs surface as a clear assertion instead of an opaque provider 400.

Memory Flush

Before compaction, the agent runs a silent memory flush turn — an internal turn that is not exposed to the caller's event stream:

  1. A user message is injected: "Context is about to be compacted. If there are important decisions, file paths, errors, or task state that should be preserved, write them to memory now using memory_update."
  2. Only memory group tools are made available (all other tools are filtered out)
  3. The agent can execute up to 3 sub-turns of tool use
  4. If the model stops calling tools or an error occurs, the flush ends silently
  5. This is best-effort — failures are caught and ignored

Tool Loop Detection

The ToolLoopDetector monitors tool call patterns and aborts runs that enter infinite loops.

Detection Patterns

Pattern Default Threshold Description
identical_repeat 3 Same tool with identical input N times consecutively
no_progress 5 Same tool returns identical output N times
ping_pong 3 Two tools alternating A→B→A→B N times
max_calls_exceeded 100 Total tool calls in one run exceeds limit

All thresholds are configurable via LoopDetectorConfig.

How Detection Works

Session Persistence

CortexSessionManager (in @nsys-cortex/tools) accepts an optional SessionStorage adapter at construction time. When wired, every state-changing method (createSession, sendToSession, addHistory, addHistoryEntry, destroySession) persists to disk. FileSessionStorage (in @nsys-cortex/runtime) is the canonical implementation.

import { CortexSessionManager } from '@nsys-cortex/tools';
import { FileSessionStorage } from '@nsys-cortex/runtime';

const storage = new FileSessionStorage('~/.nsys-cortex/sessions');
const sessionManager = new CortexSessionManager({
  storage,
  config: { idleTimeoutMs: 4 * 3600_000, maxAgeMs: 24 * 3600_000, maxHistoryEntries: 200 },
});
await sessionManager.initialize(); // rehydrate caches; drops expired sessions

Both the CLI chat and the HTTP API server wire FileSessionStorage and call initialize() before use. The CLI uses it for resume (--continue picks the most recent session for the agent, --session <id> a specific one, /sessions lists them); the API server reads NSYS_CORTEX_SESSION_IDLE_TIMEOUT / NSYS_CORTEX_SESSION_MAX_AGE from the env and rehydrates before accepting traffic.

Faithful history + reconstruction

HistoryEntry carries the flattened content string plus optional structured fields so tool interactions survive a reload: toolCalls and thinking on assistant turns, and toolCallId / toolContent / isError on tool_result turns. The agent loop persists these via addHistoryEntry after each assistant turn and each tool result. Only truthy structured fields are serialized, so plain text entries stay compact and pre-existing transcripts round-trip unchanged.

On load, the agent loop's reconstructHistory() rebuilds provider-shaped LLMMessage[]:

Storage errors are logged but never surface to callers — the in-memory state stays consistent regardless of disk state.

Cumulative usage

SessionInfo.usage accumulates token spend across the session's whole lifetime — { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, reasoningTokens, turns, runs }. The agent loop calls sessionManager.recordUsage(sessionId, delta) in its run_complete finally with the run's totalUsage, merging it into the running total and persisting it (best-effort — wrapped so a usage-accounting failure never breaks a run; recorded even for aborted/errored runs since partial spend still counts). Because it lives on the persisted session, it survives reload (CLI --continue, HTTP-API restart) and rides along in GET /sessions/:id and the CLI /cost command. Token counts only — dollar cost depends on per-provider pricing and would go stale in the codebase.

Storage layout

~/.nsys-cortex/sessions/        # 0o700
├── sessions.json               # Session metadata index (atomic via tmp+rename)
├── <agentId-1>/                # 0o700
│   ├── ses-abc123.jsonl        # Per-agent namespaced transcripts
│   ├── ses-abc123.events.jsonl # The session's event record (ADR-014) — same lifetime, same mode
│   └── ses-def456.jsonl
└── <agentId-2>/
    └── ses-ghi789.jsonl

Per-agent namespacing (PR 2) means a prompt-injected agent reading filesystem entries cannot enumerate or read another agent's session log. The agentId is looked up from the sessions.json index, not from caller input, so callers can't forge a session ID to redirect into another agent's directory.

Each JSONL line:

{"role":"user","content":"Create a file","timestamp":"2026-02-19T10:00:00Z"}

loadHistory() wraps each line's JSON.parse and skips malformed lines instead of failing the whole transcript — a crash mid-append can leave one bad line, but the rest of the conversation survives.

The event record

Every run's events are recorded per session with a sequence number (ADR-014, #14), so a consumer can replay a session from any point and follow it live without having started the run. SessionStorage gains two optional methods, appendEvent(sessionId, event) and loadEvents(sessionId, afterSeq?); FileSessionStorage keeps the record in <agentId>/<sessionId>.events.jsonl beside the transcript — 0o600, deleted with the session, a malformed line skipped with a warning like the transcript's. SessionInfo.lastEventSeq carries the last sequence number issued, so the next run continues after it, a restart included.

recordRun(run, { sessionId, storage, bus, nextSeq }) in @nsys-cortex/runtime is the seam: it wraps CortexAgentLoop.run() without editing the loop. Every event goes to the consumer and onto the SessionEventBus (in-process, per session: subscribe(sessionId, fn) returns the unsubscribe; a throwing listener is logged and never fails the run); a non-delta event gets the next seq, is appended to the record and is yielded with it; text_delta / thinking_delta are published and yielded as they are and never recorded — at turn_complete the recorder appends and publishes one thinking event (when any) and one text event holding the turn's coalesced text, so the record stays bounded by the transcript. A storage adapter without the two methods keeps the stream live-only (logged once). seq is per session, monotonic from 1; the caller's nextSeq is per session and advances the manager's in-memory lastEventSeq, the recorder persists the last seq it issued at the end of the run, and lastRecordedSeq(storage, sessionId) reads the record's tail so a restart after a crash mid-run continues after the last line actually written. error events are recorded with their text (server-side, 0o600); the HTTP route sanitises on the way out. The attach route is #14 stage 2.

Session lifecycle

SessionConfig (also in @nsys-cortex/tools) controls expiry:

Field Default What it does
idleTimeoutMs 14400000 (4h) Active sessions flip to idle status on read once now - lastMessageAt > idleTimeoutMs. Activity flips back to active.
maxAgeMs 86400000 (24h) Sessions past this age (since createdAt) are reported as absent by getSession/listSessions and auto-deleted from storage.
maxHistoryEntries 200 In-memory working set per session is trimmed from the head once it exceeds this. Disk retains everything via SessionStorage.appendEntry.

Lifecycle checks happen lazily on read (no background sweep timer) — keeps the manager stateless from the operator's perspective and means tests run deterministically.

Configuration

Variable Default Description
NSYS_CORTEX_LLM_PROVIDER anthropic anthropic, openai, gemini, grok, mistral, ollama, vllm, lmstudio, or sglang
NSYS_CORTEX_LLM_FAILOVER — Ordered failover chain provider[:model],… — takes precedence over NSYS_CORTEX_LLM_PROVIDER
NSYS_CORTEX_LLM_MODEL claude-opus-5-5 Default model
NSYS_CORTEX_LLM_MAX_TOKENS 8192 Max output tokens per call
NSYS_CORTEX_LLM_TEMPERATURE 0 Sampling temperature
NSYS_CORTEX_LLM_CONTEXT_TOKENS — The context window the run budgets against (a self-hosted server's --max-model-len); replaces the registered budget or the 128K default (#38)
NSYS_CORTEX_LLM_BASE_URL — Override the selected provider's base URL only (failover candidates read <PROVIDER>_BASE_URL)
ANTHROPIC_API_KEY — Required for Anthropic provider
OPENAI_API_KEY — Required for OpenAI provider
GEMINI_API_KEY — Required for Gemini provider
XAI_API_KEY — Required for Grok (xAI) provider
MISTRAL_API_KEY — Required for Mistral provider
ANTHROPIC_BASE_URL https://api.anthropic.com Override Anthropic base URL (mirrored from the config file; env wins)
OPENAI_BASE_URL https://api.openai.com/v1 Override OpenAI base URL (mirrored from the config file; env wins)
GEMINI_BASE_URL https://generativelanguage.googleapis.com/v1beta Override Gemini base URL (mirrored from the config file; env wins)
XAI_BASE_URL https://api.x.ai/v1 Override Grok (xAI) base URL (mirrored from the config file; env wins)
MISTRAL_BASE_URL https://api.mistral.ai/v1 Override Mistral base URL (mirrored from the config file; env wins)
OLLAMA_BASE_URL http://localhost:11434/v1 Override Ollama base URL
VLLM_BASE_URL http://localhost:8000/v1 Override vLLM base URL
VLLM_API_KEY — Only required if vLLM was started with --api-key
LMSTUDIO_BASE_URL http://localhost:1234/v1 Override LM Studio base URL
LMSTUDIO_API_KEY — Only required if LM Studio is behind an auth proxy
SGLANG_BASE_URL http://localhost:30000/v1 Override SGLang base URL
SGLANG_API_KEY — Only required if SGLang was started with --api-key
NSYS_CORTEX_AGENT_MAX_TURNS 25 Max tool-use turns per run
NSYS_CORTEX_AGENT_TIMEOUT 600000 Run timeout (ms)
NSYS_CORTEX_SESSION_STORAGE_DIR ~/.nsys-cortex/sessions Session storage path
NSYS_CORTEX_SESSION_IDLE_TIMEOUT 14400000 Idle timeout (4h)
NSYS_CORTEX_SESSION_MAX_AGE 86400000 Max session age (24h)