Skip to content

Agent graph

bz-agent is a LangGraph state machine that turns a sentence into a structured intent. It has no chain access and executes nothing.

Entry: src/index.tssrc/app.ts · Port: 4000 · Graph: src/agent/graph.ts

Shape

Every leaf node has .addEdge('<node>', 'EXIT'). Only two nodes use conditional edges: entryNode (the router) and vectorNode.

entryNode — classification

src/template/entry.ts → getInitSystemTemplate(intents) builds a system prompt that injects every intent's name → description pair from AVAILABLE_INTENTS. The LLM picks one.

Intent descriptions are the classifier

There is no fine-tuned model and no embedding classifier. The description field on each intent is the training signal, which is why several of them carry explicit examples and "do NOT use this for…" clauses. Improving classification means editing a description, not retraining anything.

Model tier: extraction — fast, temperature: 0.

Intent nodes — extraction

One node per intent in src/agent/node.ts, plus four in their own files (bzTokenNode, createDappNode, modifyDappNode, goOnchainNode).

Each node is a small LLM call with a Zod schema for the fields that intent needs. For example swapNode returns { fromToken, toToken, chain, amount, text }.

The follow_up convention

Every node ends the same way:

ts
if (allRequiredFieldsPresent) {
  state.intent = AVAILABLE_INTENTS.SWAP.name
  state.text   = 'no-text'
} else {
  state.intent = 'follow_up'
  state.text   = 'How much ETH would you like to swap?'
}

So a partially-specified request produces a clarifying question, never a guessed value. 'no-text' is the sentinel telling bz-backend that the handler should produce the reply, not the agent.

Routing table

entryNode's conditional edge is a switch. Notable cases:

IntentRoutes to
SWAP, BRIDGE, BUY, LIMIT_ORDER, SEND_TOKEN, DCA, TX_HISTORY, TX_INFO, NFT_INFO, PORTFOLIO, CHECK_BALANCE, PRICE_ALERT, SWITCH_CHAIN, CONTRACT_AUDIT, TOKEN_SAFETY, WALLET_CARD, REVOKE_APPROVALS, AUTOPILOT, WORKFLOW, MARKET_INTEL, BATCHTheir own node
BUY_BZ_TOKENbzTokenNode
CREATE_DAPP, MODIFY_DAPP, GO_ONCHAINTheir own node
SHOW_DAPP, DEPLOY_CONTRACTSEXIT directly
CONVERSATIONEXIT directly
FALLBACKvectorNode
SYSTEMEXIT
anything elseEXIT

SHOW_DAPP, DEPLOY_CONTRACTS and CONVERSATION exit without an extraction node

They need no parameters beyond what the session already carries (activeDappProjectId) or are handled entirely by the backend. Adding a node for them would be dead weight.

Fallback and RAG

ts
.addConditionalEdges('vectorNode', (state) =>
  state.intent === AVAILABLE_INTENTS.FALLBACK.name ? 'conversationNode' : 'EXIT'
)

vectorNode (src/agent/vector.ts) does a semantic lookup against PGVector on PostgreSQL using STORE_SYSTEM_TEMPLATE. Two outcomes:

  • The lookup resolves the question → set a real intent → EXIT.
  • It does not → still FALLBACKconversationNode answers conversationally with the retrieved context.

The knowledge base is managed through src/controllers/store.controller.ts and src/routes/store.ts, with src/models/store.model.ts and @langchain/textsplitters for chunking.

Model tiers

src/utils/models.ts resolves a model per tier, not per vendor. Nodes ask for a tier; the concrete model is a deployment detail behind an env var, so the provider can change without touching a single node.

TierRoleEnv overrideUsed by
extractionFast, deterministic, cheapLLM_MODEL_EXTRACTIONentryNode and every intent node
conversationRicher prose for DeFi answersLLM_MODEL_CONVERSATIONconversationNode
complexMost capableLLM_MODEL_COMPLEXdApp creation, contract audit

All at temperature: 0.

Tiers are the abstraction; keep it that way

Reference a tier (getLLM('extraction')), never a model name. The whole point of the indirection is that swapping a provider is an env change. Concrete credentials are listed in environment variables.

The conversation node prefers the managed provider

getConversationLLM() builds a service-account-backed client for the managed conversation provider when all three of LLM_GOOGLE_PROJECT_ID, LLM_GOOGLE_CLIENT_EMAIL and LLM_GOOGLE_PRIVATE_KEY are set, constructing the credential object inline. Otherwise it falls back to the extraction provider.

The fallback is quieter than it looks

If any one of the three service-account env vars is missing, the node silently falls back to the extraction model instead of the conversation tier — a cheaper, terser model. A partially-configured environment therefore degrades answer quality with no error. Check all three together.

Compilation and checkpointing

src/agent/index.ts → getAgent() compiles the graph with a Redis checkpointer (src/utils/redisCheckPointer.ts, ioredis), keyed per session. That is what gives multi-turn context — a follow-up answer resumes the prior state rather than reclassifying from scratch.

HTTP surface

bz-backend calls exactly one endpoint:

POST {AGENT_URL}/chat/:sessionId
body: { input, chain, supported_chains, activeDappProjectId? }
→    { intent, data, text }

Routes are in src/routes/{index,agent,store}.ts, controllers in src/controllers/, input validated with Joi (src/validations/). Auth middleware at src/middleware/auth.ts.

supported_chains is passed in so the agent can validate a chain name against what the platform actually supports, rather than accepting any string.

Persistence

StorePurpose
MongoDB (@langchain/mongodb)agent.model.ts — agent-side records
PostgreSQL + PGVector (pg)Knowledge base for RAG
Redis (ioredis)LangGraph checkpoints

Chat history is not stored here — that is bz-backend's blazSession / blazMessage. The agent's Redis checkpoints are per-session graph state, not a transcript.

Templates

src/template/ — one file per intent, 23 files:

entry.ts          qoute.ts (swap)     transaction.ts
portfolio.ts      nft.ts              priceAlert.ts
switchChain.ts    limitOrder.ts       bzToken.ts
createDapp.ts     modifyDapp.ts       goOnchain.ts
contractAudit.ts  tokenSafety.ts      walletCard.ts
revokeApprovals.ts  autopilot.ts      workflow.ts
batch.ts          marketIntel.ts      conversation.ts
store.ts

Literal braces must be doubled in templates

LangChain treats {name} as a prompt variable. A template containing literal JSON needs , or the prompt fails to render with a missing-variable error.

Why the agent is execution-free

A deliberate blast-radius decision. A prompt injection can at worst produce a wrong intent object — which then fails token resolution in the backend, or produces a card the user declines.

Concretely: the agent has no RPC provider, no private key, no relayer ABI, and no HTTP client pointed at bz-backend. It is called and calls nothing back.

Operations

TaskCommand
Devnpm run dev (tsx watch)
Buildnpm run build (tsc → build/)
Startnpm start
Type checknpm run check-types
Deploy./scripts/deploy.gcp.sh deploy bz-agentBUILD=yes, compiles on the VM
Restart scriptdefi-set/restart_agent.sh

Logging is Winston (src/utils/logger.ts).

Adding an intent

Steps 1–4 of the nine-step checklist happen in this repo: add an AI intent.

Known limits

  • Classification quality is prompt-quality. No evaluation harness exists; the only signal is the non-fallback rate.
  • The non-fallback rate does not catch mis-classification. A message routed to the wrong intent counts as a success.
  • entryNode logs state.intent to stdout on every request — useful, and noisy in production logs.
  • The conversation-provider fallback silently downgrades the model. See the warning above.
  • LIMIT_ORDER has a node and a template but no backend handler, so it extracts parameters that go nowhere.
  • A Redis flush loses multi-turn context for in-flight conversations.

Internal + developer documentation. Usage figures are point-in-time snapshots — re-verify before publishing them.