/** * 0G Compute-backed brain. Provider-agnostic: it owns the tool loop, the * ``/dash sanitizers, compaction, channel history + persistence, and * the OpenAI-compatible `fetch` + request-body + response parse. Everything * provider-specific (endpoint+model resolution, per-request auth headers, * HTTP-error mapping, the prepaid ledger) lives behind a {@link CompletionTransport}. * * The default transport is {@link DirectTransport} (0G serving-broker, EIP-712 * per-request vouchers), built automatically from `privkeyHex`/`rpcUrl`/ * `providerAddress` when no `transport` is injected, so every existing caller * is unchanged. The 0G Router (`Bearer sk-`) is the opt-in second transport. * ethers/broker imports now live in DirectTransport, not here. */ import type { BrokerLedgerLike } from '../economy/auto-topup' import type { ToolSchema } from '../tools/types' import { type CompactionOpts, DEFAULT_COMPACTION_OPTS, SUMMARY_SYSTEM_PROMPT, compactHistory, estimateTokens, shouldCompact, } from './compaction' import type { CompletionTransport, ListedService } from './completion-transport' import { DirectTransport } from './direct-transport' import { type FrozenPrefix, renderFrozenPrefix, renderUserContext } from './frozen-prefix' import type { HistoryPersist } from './history-persist' import { sanitizeDashes } from './sanitize' import type { Brain, BrainInferInput, BrainMessage, BrainTurn } from './types' // Re-exported for back-compat: callers (brain/index.ts, ledger-error.test.ts, // plugin-telegram) import these from './og-compute'. The implementation moved // to direct-transport.ts with the rest of the Direct/serving-broker surface. export { LedgerInsufficientError, parseLedgerInsufficientError } from './direct-transport' export type { CompletionTransport, ListedService } from './completion-transport' export { DirectTransport } from './direct-transport' /** Channel key used when none is specified, preserves legacy single-history behavior. */ export const DEFAULT_CHANNEL_KEY = 'default' /** Default cap on the assistant output tokens per turn. Bumped from 1024 in v0.20.0. */ export const DEFAULT_MAX_OUTPUT_TOKENS = 4096 /** Stored in the private `broker` init sentinel once {@link OGComputeBrain.init} completes. */ const BRAIN_INITIALIZED = Symbol('og-brain-initialized') export interface OGComputeBrainOpts { privkeyHex: `0x${string}` rpcUrl: string providerAddress: string tools: ToolSchema[] prefix: FrozenPrefix /** * Seed history for the legacy single-history (`'default'`) channel. * Backward-compat: prior callers passed this as the entire conversation. * In v0.20.0+, prefer per-channel APIs (`setChannelHistory`). */ history?: BrainMessage[] /** Default 4096 (was 1024 prior to v0.20.0). */ maxOutputTokens?: number /** * Pre-flight auto-compaction config. Omit to use {@link DEFAULT_COMPACTION_OPTS}. * Pass `null` to disable compaction entirely. */ compaction?: CompactionOpts | null /** * Optional persistence handle. When set, channel histories are seeded from * disk during {@link OGComputeBrain.init} and every committed turn is * appended via {@link HistoryPersist.appendTurn}. */ persist?: HistoryPersist onToolCall?: (call: { id: string; name: string; args: unknown }) => Promise /** * Provider transport. When omitted (the default for every existing caller), * a {@link DirectTransport} is built from `privkeyHex`/`rpcUrl`/ * `providerAddress`, i.e. the 0G serving-broker path, unchanged. Inject a * `RouterTransport` to run the agent against the 0G Router instead. */ transport?: CompletionTransport } export class OGComputeBrain implements Brain { /** * Init sentinel: non-null once {@link init} has wired the transport. The * real broker (Direct) lives inside the transport now; this stays named * `broker` so the cheap `if (!this.broker)` init-skip guard, and tests that * poke `(brain as any).broker = {}` to bypass init, remain valid. */ private broker: unknown = null private readonly transport: CompletionTransport private readonly histories = new Map() private readonly lastUsage = new Map() private readonly renderedPrefix: string private userContextText: string | null private persistHydrated = false constructor(private readonly opts: OGComputeBrainOpts) { if (opts.history && opts.history.length > 0) { this.histories.set(DEFAULT_CHANNEL_KEY, [...opts.history]) } this.transport = opts.transport ?? new DirectTransport({ privkeyHex: opts.privkeyHex, rpcUrl: opts.rpcUrl, providerAddress: opts.providerAddress, }) this.renderedPrefix = renderFrozenPrefix(opts.prefix) this.userContextText = renderUserContext(opts.prefix) } /** * v0.21.0: expose the lazily-initialized broker.ledger so AutoTopupManager * can read the provider envelope balance and call deposit/transfer using * the same wallet binding as the brain. Returns null until init() runs. */ getLedger(): BrokerLedgerLike | null { return this.transport.getLedger() } /** * Refresh the per-turn user-context payload from the latest local state. * Called by the chat loop right before every `infer()` so MEMORY.md updates * land in the next turn without rebuilding the system prompt (preserves * prefix cache hits). */ refreshUserContext(prefix: FrozenPrefix): void { this.userContextText = renderUserContext(prefix) } async init(): Promise { if (this.broker) return await this.transport.init() await this.hydrateFromPersist() this.broker = BRAIN_INITIALIZED } private async hydrateFromPersist(): Promise { if (this.persistHydrated || !this.opts.persist) return this.persistHydrated = true try { const loaded = await this.opts.persist.loadAll() for (const [key, history] of loaded) { // Don't clobber a constructor-seeded history if the same channel was // pre-populated. This preserves legacy `opts.history` for the default // channel. if (this.histories.has(key) && (this.histories.get(key)?.length ?? 0) > 0) continue this.histories.set(key, [...history]) } } catch { // Persist load failures must never block brain startup, chat works // in-memory only and persist resumes on next clean run. } } /** Snapshot of a channel's current history (read-only, defensive copy). */ getChannelHistory(channelKey: string = DEFAULT_CHANNEL_KEY): readonly BrainMessage[] { return [...(this.histories.get(channelKey) ?? [])] } /** Wholesale replace a channel's history. Used by tests and by persist hydration. */ setChannelHistory(channelKey: string, history: BrainMessage[]): void { this.histories.set(channelKey, [...history]) } /** Clear a single channel. Best-effort persist clear. */ async clearChannel(channelKey: string = DEFAULT_CHANNEL_KEY): Promise { this.histories.set(channelKey, []) this.lastUsage.delete(channelKey) if (this.opts.persist) { try { await this.opts.persist.clearChannel(channelKey) } catch { // best-effort } } } /** Distinct channel keys with non-empty history. */ listChannels(): string[] { const out: string[] = [] for (const [k, v] of this.histories) { if (v.length > 0) out.push(k) } return out } private getOrCreateHistory(channelKey: string): BrainMessage[] { let h = this.histories.get(channelKey) if (!h) { h = [] this.histories.set(channelKey, h) } return h } /** * Fetch live catalog without requiring a specific provider to be pre-set. * Direct-only (serving-broker `listService()`); delegated to the transport * layer. The Router catalog (`GET /v1/models`) is read in the init picker. */ static listServicesFor(args: { privkeyHex: `0x${string}` rpcUrl: string }): Promise { return DirectTransport.listServicesFor(args) } async listServices(): Promise { if (!this.broker) await this.init() return this.transport.listServices() } /** Live ledger balance in 0G (the prepaid compute credit). null on failure. */ async getLedgerBalance(): Promise { if (!this.broker) await this.init() return this.transport.getLedgerBalance() } async infer(input: BrainInferInput): Promise { if (!this.broker) await this.init() const signal = input.signal if (signal?.aborted) { throw new DOMException('aborted before infer started', 'AbortError') } const channelKey = input.channelKey ?? DEFAULT_CHANNEL_KEY await this.maybeCompact(channelKey, input) const history = this.getOrCreateHistory(channelKey) const userText = normalizeUserContent(input) const messages: BrainMessage[] = [{ role: 'system', content: this.renderedPrefix }, ...history] // Per-turn user-context (MEMORY.md, etc.) injected just before the live // user message. Treated as a separate user turn so MEMORY.md churn doesn't // invalidate the system-prompt cache. if (this.userContextText) { messages.push({ role: 'user', content: this.userContextText }) } messages.push({ role: 'user', content: userText }) let turnResult: BrainTurn | null = null // No round-trip cap: the brain exits naturally when it returns a // content-only response (no tool_calls). Capping here truncated multi-step // browser drives before the final answer. let recoveredFromSafetyBlock = false while (true) { if (signal?.aborted) { throw new DOMException('aborted between round-trips', 'AbortError') } const resp = await this.callCompletion(messages, signal) turnResult = resp if (!resp.toolCalls.length) { // qwen3.6-plus on the 0G broker sometimes returns its safety-filter // reject as a plain assistant turn (no tool_calls). The string looks // like: "An error occurred while generating a tool call: Unauthorized: // is a blocked tool." That happens when the model generates a // tool name that doesn't match any registered tool (typically the // bare prefix of a dotted name, e.g. "browser" instead of // "browser.snapshot"). Auto-recover once: inject a corrective user // message naming the valid tools, then continue the loop so the brain // can re-issue with the correct dotted name. const blockedName = detectBlockedToolError(resp.content ?? '') if (blockedName && !recoveredFromSafetyBlock) { recoveredFromSafetyBlock = true const validNames = this.opts.tools .map(t => (t as { name?: string }).name ?? '') .filter(n => n.startsWith(`${blockedName}.`) || n.startsWith(`${blockedName}_`)) .slice(0, 12) const hint = validNames.length > 0 ? `Your last tool call used the bare name "${blockedName}", which is not a registered tool. Use the full name with subname (one of: ${validNames.join(', ')}). Retry now.` : `Your last tool call used the bare name "${blockedName}", which is not a registered tool. Use the full namespaced name (e.g., something.action). Retry now.` messages.push({ role: 'user', content: hint }) continue } messages.push({ role: 'assistant', content: resp.content ?? '' }) break } messages.push({ role: 'assistant', content: resp.content ?? '', toolCalls: resp.toolCalls, }) for (const call of resp.toolCalls) { if (signal?.aborted) { throw new DOMException('aborted between tool calls', 'AbortError') } // Qwen3.6 occasionally emits a tool_call with empty `function.name` // or truncated `function.arguments` JSON. Don't dispatch, inject a // corrective tool-result so the brain can re-issue cleanly. const isMalformed = !call.name || (typeof call.args === 'string' && call.args !== '' && !looksLikeValidJsonString(call.args)) if (isMalformed) { const toolLabel = call.name || MALFORMED_TOOL_LABEL if (input.onToolEvent) { try { input.onToolEvent({ kind: 'start', tool: toolLabel, callId: call.id, argsPreview: previewToolArgs(call.args), }) input.onToolEvent({ kind: 'end', tool: toolLabel, callId: call.id, ok: false }) } catch { /* swallow */ } } messages.push({ role: 'tool', toolCallId: call.id, content: JSON.stringify({ error: 'Tool call envelope was malformed (empty name or truncated arguments). Re-emit with a complete tool name and a parseable JSON args object.', }), }) continue } if (!this.opts.onToolCall) { messages.push({ role: 'tool', toolCallId: call.id, content: JSON.stringify({ error: 'Tool handler not wired' }), }) continue } if (input.onToolEvent) { try { input.onToolEvent({ kind: 'start', tool: call.name, callId: call.id, argsPreview: previewToolArgs(call.args), }) } catch { /* observer errors must never block tool execution */ } } const toolMsg = await this.opts.onToolCall(call) if (input.onToolEvent) { try { input.onToolEvent({ kind: 'end', tool: call.name, callId: call.id, ok: inferToolOk(toolMsg.content ?? ''), }) } catch { /* swallow */ } } messages.push({ ...toolMsg, toolCallId: call.id }) } } const finalAssistant = findLastAssistantContent(messages) const userMsg: BrainMessage = { role: 'user', content: userText } const assistantMsg: BrainMessage = { role: 'assistant', content: finalAssistant } history.push(userMsg) history.push(assistantMsg) if (turnResult?.usage) this.lastUsage.set(channelKey, turnResult.usage) if (this.opts.persist) { try { await this.opts.persist.appendTurn(channelKey, userMsg, assistantMsg) } catch { // Persist failure is non-fatal for the live turn. } } // v0.22.1: backstop em-dash/en-dash hard rule. Frozen-prefix forbids // these characters but qwen3.6-plus occasionally slips. Sanitize at the // single brain-output point so every surface (TUI/TG/A2A/market) gets // clean text without per-surface duplication. if (turnResult?.content) { turnResult.content = sanitizeDashes(turnResult.content) } return turnResult ?? { content: null, toolCalls: [] } } /** * Pre-flight compaction check. When threshold is breached, summarize older * messages (everything before the last `keepRecent * 2`) via a sub-call * with no tools and replace the channel history. Caller's `onCompactionEvent` * fires AFTER successful fold. */ private async maybeCompact(channelKey: string, input: BrainInferInput): Promise { if (this.opts.compaction === null) return const cfg = this.opts.compaction ?? DEFAULT_COMPACTION_OPTS const history = this.histories.get(channelKey) if (!history || history.length === 0) return const lastUsage = this.lastUsage.get(channelKey) const trigger = shouldCompact(history, lastUsage?.promptTokens ?? null, cfg) if (trigger == null) return let compacted: BrainMessage[] try { compacted = await compactHistory(history, cfg, async older => this.summarizeOlder(older)) } catch { // Compaction is best-effort; failure means we proceed with the original // history this turn and try again next time. return } if (compacted.length >= history.length) return // nothing folded this.histories.set(channelKey, compacted) this.lastUsage.delete(channelKey) // estimate-based until next usage lands if (this.opts.persist) { try { await this.opts.persist.rewriteChannel(channelKey, compacted) } catch { // best-effort; rehydration on restart will reflect the on-disk state } } if (input.onCompactionEvent) { try { input.onCompactionEvent({ channelKey, from: history.length, to: compacted.length, promptTokens: trigger, }) } catch { /* observer errors swallowed */ } } } /** * Summarize the older portion of a channel's history via a fresh sub-call * to the same broker, with no tools, lower max_tokens, and the * compaction-specific system prompt. This is the only place the brain * recursively calls its own provider for housekeeping. */ private async summarizeOlder(older: readonly BrainMessage[]): Promise { if (!this.broker) { throw new Error('Brain not initialized; call init() first.') } // Flatten tool-call metadata into a readable transcript. The sub-call // doesn't need full structure, just enough text to summarize accurately. const flat = older .map(m => { const tag = m.role.toUpperCase() if (m.toolCalls && m.toolCalls.length > 0) { const calls = m.toolCalls .map( tc => `${tc.name}(${typeof tc.args === 'string' ? tc.args : JSON.stringify(tc.args ?? {})})`, ) .join(' | ') return `${tag}: ${m.content || ''}\n[TOOL_CALLS] ${calls}` } return `${tag}: ${m.content || ''}` }) .join('\n\n') const headers = await this.transport.authHeaders(flat) const body = { model: this.transport.modelId, messages: [ { role: 'system', content: SUMMARY_SYSTEM_PROMPT }, { role: 'user', content: flat }, ], max_tokens: 1024, } let resp = await fetch(`${this.transport.baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), }) // Mirror callCompletion: re-establish credentials once on 401/403 (Router // re-mints its sk-) so a mid-session key expiry doesn't silently break the // compaction sub-call. Direct has no reprovision hook → never fires. if ((resp.status === 401 || resp.status === 403) && this.transport.reprovision) { const reprovisioned = await this.transport.reprovision() if (reprovisioned) { const freshHeaders = await this.transport.authHeaders(flat) resp = await fetch(`${this.transport.baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...freshHeaders }, body: JSON.stringify(body), }) } } if (!resp.ok) { const errBody = await resp.text() // Surface the typed provider error (e.g. RouterCreditError on 402) so the // best-effort caller can classify it the same way callCompletion does. const mapped = this.transport.mapHttpError(resp.status, errBody) if (mapped) throw mapped throw new Error(`Compaction summarize HTTP ${resp.status}`) } const json = (await resp.json()) as { choices: Array<{ message: { content?: string | null } }> } const summary = (json.choices[0]?.message.content ?? '').trim() // Strip MiniMax-M3 inline so reasoning doesn't leak into the // persisted . Gated on the transport opt-in → // Direct summaries are returned verbatim (byte-identical). return this.transport.stripInlineThink && summary.includes('') ? stripThinkBlocks(summary) : summary } private async callCompletion(messages: BrainMessage[], signal?: AbortSignal): Promise { if (!this.broker) { throw new Error('Brain not initialized; call init() first.') } const last = messages.at(-1)! const headers = await this.transport.authHeaders( typeof last.content === 'string' ? last.content : JSON.stringify(last.content), ) const body: Record = { model: this.transport.modelId, messages: messages.map(m => { if (m.role === 'tool') { return { role: 'tool', tool_call_id: m.toolCallId, content: m.content } } if (m.role === 'assistant' && m.toolCalls && m.toolCalls.length > 0) { return { role: 'assistant', content: m.content || null, tool_calls: m.toolCalls.map(tc => ({ id: tc.id, type: 'function', function: { name: tc.name, arguments: typeof tc.args === 'string' ? tc.args : JSON.stringify(tc.args), }, })), } } return { role: m.role, content: m.content } }), max_tokens: this.opts.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS, } // 0G's broker (DashScope) rejects an empty tools array (`[] is too short`). // Only include the fields when at least one tool is in the schema list. if (this.opts.tools.length > 0) { body.tools = this.opts.tools body.tool_choice = 'auto' } let resp = await fetch(`${this.transport.baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), signal, }) // Auth failure (401/403): give the transport one chance to re-establish // credentials (Router re-mints its sk- via SIWE when a cached/expired key // is rejected) and retry once with fresh headers. Direct has no reprovision // hook (its per-request voucher never expires), so this never fires for // Direct and the path stays byte-identical. if ((resp.status === 401 || resp.status === 403) && this.transport.reprovision) { const reprovisioned = await this.transport.reprovision() if (reprovisioned) { const freshHeaders = await this.transport.authHeaders( typeof last.content === 'string' ? last.content : JSON.stringify(last.content), ) resp = await fetch(`${this.transport.baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...freshHeaders }, body: JSON.stringify(body), signal, }) } } if (!resp.ok) { const errBody = await resp.text() // Provider-specific error mapping. Direct maps its "insufficient // balance" HTTP 400 to a typed LedgerInsufficientError so dispatchers // (TUI + TG) render an actionable "anima topup --compute N" hint instead // of the raw HTTP body (see `feedback-compute-ledger-total-vs-provider.md`); // Router maps 402/401. null → fall through to a generic error. const mapped = this.transport.mapHttpError(resp.status, errBody) if (mapped) throw mapped throw new Error(`Brain HTTP ${resp.status}: ${errBody}`) } const json = (await resp.json()) as { choices: Array<{ finish_reason?: string message: { content?: string | null tool_calls?: Array<{ id: string function: { name: string; arguments: string } }> reasoning_content?: string } }> usage?: { prompt_tokens?: number completion_tokens?: number total_tokens?: number prompt_tokens_details?: { cached_tokens?: number } } } const choice = json.choices[0]! const msg = choice.message // Qwen3.6 (Direct) sometimes routes the visible response into // reasoning_content instead of content when thinking mode doesn't // transition out cleanly. Fall back to stripped reasoning so the operator // sees the answer. MiniMax-M3 (Router) instead emits its chain-of-thought // INLINE in content as a ... block, strip that, but ONLY // for transports that opt in (`transport.stripInlineThink`). The Direct // path leaves stripInlineThink falsy, so its `content` is returned verbatim // exactly as before this seam, byte-identical even for the rare Direct // response that happens to contain the literal substring ``. const rawContent = msg.content const reasoning = msg.reasoning_content const fallbackFromReasoning = !rawContent && reasoning && reasoning.length > 0 ? stripThinkBlocks(reasoning) : null const cleanedContent = rawContent ? this.transport.stripInlineThink && rawContent.includes('') ? stripThinkBlocks(rawContent) : rawContent : fallbackFromReasoning return { content: cleanedContent, toolCalls: (msg.tool_calls ?? []).map(tc => ({ id: tc.id, name: tc.function.name, args: safeParseJson(tc.function.arguments), })), reasoningContent: msg.reasoning_content, finishReason: choice.finish_reason, usage: { promptTokens: json.usage?.prompt_tokens, completionTokens: json.usage?.completion_tokens, totalTokens: json.usage?.total_tokens, cachedTokens: json.usage?.prompt_tokens_details?.cached_tokens, }, } } } function normalizeUserContent(input: BrainInferInput): string { const d = input.event.payload.data if (typeof d === 'string') return d return JSON.stringify(d) } function safeParseJson(raw: string): unknown { try { return JSON.parse(raw) } catch { return raw } } /** * Cheap probe: does this string look like complete JSON? Used to detect * truncated tool_call.function.arguments where Qwen's emission cut off * mid-token (e.g. `{"query": "browser navigate"` with no closing brace). */ export function looksLikeValidJsonString(raw: string): boolean { if (!raw || raw.length === 0) return true try { JSON.parse(raw) return true } catch { return false } } const THINK_BLOCK_RE = /[\s\S]*?<\/think>/g const MALFORMED_TOOL_LABEL = '' /** * Strip `...` wrappers that Qwen3.x reasoning mode emits * around chain-of-thought. Used as fallback when the broker routes the * visible answer into reasoning_content instead of content. */ export function stripThinkBlocks(text: string): string { if (!text) return text return text.replace(THINK_BLOCK_RE, '').trim() } /** * Detect the 0G broker's safety-filter reject ("An error occurred while * generating a tool call: Unauthorized: is a blocked tool.") and * return the blocked tool name. Returns null when the content is a normal * response. */ export function detectBlockedToolError(content: string): string | null { if (!content) return null const m = content.match(/Unauthorized:\s+(\S+)\s+is a blocked tool/) return m ? m[1]! : null } function findLastAssistantContent(messages: BrainMessage[]): string { for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i] if (m && m.role === 'assistant') return m.content } return '' } /** * Format a compact preview of tool args for the per-turn observer. Keeps the * essence of "what the brain is doing" without dumping the full payload to a * UI surface (TG progress message especially). * * - String: pass through, truncated. * - Object: top-level keys joined; first scalar value preview if short. * - Array: count + first element preview. * - Other: best-effort JSON stringify. */ export function previewToolArgs(args: unknown): string { if (args == null) return '' if (typeof args === 'string') return truncatePreview(args) if (Array.isArray(args)) return `[${args.length}]` if (typeof args === 'object') { const o = args as Record const keys = Object.keys(o) if (keys.length === 0) return '' // Prefer a single short scalar field if present. for (const k of ['url', 'path', 'command', 'query', 'name', 'address']) { const v = o[k] if (typeof v === 'string' && v.length > 0) return truncatePreview(`${k}=${v}`) } return truncatePreview(keys.join(',')) } try { return truncatePreview(String(args)) } catch { return '' } } function truncatePreview(s: string): string { const max = 60 if (s.length <= max) return s return `${s.slice(0, max - 1)}…` } /** * Heuristic ok-detection for the per-turn observer. Tools return JSON content * with `{ok, error, ...}` shape; a missing `ok` AND missing `error` is * considered a soft success (rare). */ export function inferToolOk(content: string): boolean { if (!content) return true try { const o = JSON.parse(content) as Record if (typeof o.ok === 'boolean') return o.ok if (typeof o.error === 'string' && o.error.length > 0) return false return true } catch { return !content.toLowerCase().includes('error') } } // Re-export estimateTokens so callers that want to measure prompt size // without a full BrainTurn round-trip can use the same heuristic. export { estimateTokens }