Skip to main content

Overview

The AI agent is an event-driven ReAct loop implemented in TypeScript using the Vercel AI SDK. It connects to the sidecar via Server-Sent Events and fires a decision cycle on three trigger conditions:

tx_enqueued

A new user transaction arrived in the queue.

bundle_settled(failed)

A bundle terminated with a failure class that requires a retry decision.

30s watchdog

Safety net for SSE reconnect gaps — agent checks for any missed work.

The decision cycle

Data gathering is parallelized out of the LLM hot path. Before the model is invoked, the outer loop fetches getState, getTip, getLifecycle, and getPendingTxs in a single Promise.all and injects the results directly into the cycle prompt. The agent reads pre-loaded context and goes straight to the decision — typically two tool calls total.
trigger fires

preflight: queue depth > 0? inflight guard clear?

Promise.all([getState, getTip, getLifecycle, getPendingTxs])  ← parallel, ~50ms

inject into prompt as PRE-LOADED CONTEXT block

generateText (LLM call with tool set)

  [THINK] read pre-loaded slot, tip percentiles, queue items, history
  [THINK] select percentile, form reason string
  [CALL]  submit({ tip_lamports, reason, tx_ids })   ← step 1
  [CALL]  writeTrace({ id, trace })                  ← step 2

terminal: submitted / held / error
This cuts the typical cycle from 6+ sequential LLM steps (~7s) to 2 steps (~2s). The reasoning quality is unchanged — the agent still reads the same data; it just doesn’t burn a round-trip per field to fetch it.

Tip selection

The agent selects from five percentiles on the live Jito tip oracle: p25, p50, p75, p95, p99. The escalation policy embedded in the system prompt:
Consecutive fee_too_low failuresPercentile selected
0p50 (baseline)
1p75
2p95
≥ 3p99
This is not hardcoded logic — the agent reads the lifecycle history and reaches this conclusion through its reasoning trace. The trace is written to the lifecycle entry so every decision is auditable. Example reasoning trace from a real cycle:
“fee_too_low at p75=5000. Pattern: 2 consecutive fee_too_low. Oracle p99=1,000,000. Tx age=34/50 slots near TTL. Escalating to p95=100,000. If this fails: p99.”

The clean boundary

The architectural invariant that makes the system independently upgradeable:

Rust never calls an LLM

All execution — signing, serialization, Jito submission — happens in the Rust sidecar. The agent is an external observer that issues commands.

Agent never touches a keypair

The agent calls POST /internal/submit with a tip amount and list of tx_ids. The sidecar does the rest.
This means you can swap the AI provider — GPT-4o, Claude, Grok, or a local Ollama model — without any change to the execution layer.

Configuring the model

The agent model is configured via environment variables in the agent process:
# Use Anthropic Claude
ANTHROPIC_API_KEY=sk-ant-...
MODEL_PROVIDER=anthropic
MODEL_NAME=claude-sonnet-4-5

# Use OpenAI
OPENAI_API_KEY=sk-...
MODEL_PROVIDER=openai
MODEL_NAME=gpt-4o

# Use a local Ollama model (no API key needed)
MODEL_PROVIDER=ollama
MODEL_NAME=llama3.1:8b
OLLAMA_BASE_URL=http://localhost:11434

Retry race condition

A non-obvious concurrency hazard: the bundle_settled(failed) SSE event fires while the original submit cycle is still executing. The agent handles this with a pendingRetry field:
if (state.runningCycle && retryContext) {
  state.pendingRetry = { ...retryContext, slot };
  return; // don't start a new cycle yet
}

// ... cycle runs ...

} finally {
  state.runningCycle = false;
  if (state.pendingRetry) {
    const pending = state.pendingRetry;
    state.pendingRetry = null;
    await maybeRunCycle(pending.slot, state, pending); // drain
  }
}
This guarantees the retry cycle fires immediately after the current cycle’s finally block with zero additional delay.