Fix: ElizaOS JS Runtime Bridge False “[cycle]” Serialization Bug
In autonomous multi-agent frameworks built on ElizaOS (formerly ai16z), the JS Runtime Bridge (packages/agent/src/services/js-runtime-bridge.ts) acts as the high-speed translation layer between the host environment and embedded sandboxed actions. It marshals complex runtime objects, plugin state, and on-chain responses between TypeScript services and agent execution contexts.
However, developers building Web3 arbitrage bots, liquidity rebalancing agents, or complex DeFi plugins have uncovered a destructive serialization defect: valid, non-cyclic data structures are corrupted into the string "[cycle]".
[ElizaOS:RuntimeBridge] Marshaling action parameters for EXECUTE_SWAP...
[ElizaOS:ActionHandler] Fatal RPC Exception:
Error: invalid address (argument="address", value="[cycle]", code=INVALID_ARGUMENT)
at Contract.balanceOf (ethers.js:284)
at executeSwapAction (plugin-defi/swap.ts:114)
When this bug triggers, the agent fails to execute transactions, hallucinates corrupted contract addresses, or corrupts persistent memory stored in Postgres/pgvector.
1. Code-Level Root Cause Analysis
The flaw exists within toJsValue in packages/agent/src/services/js-runtime-bridge.ts. The implementation attempts to protect against infinite recursion caused by circular JavaScript objects:
// VULNERABLE CODE in packages/agent/src/services/js-runtime-bridge.ts
function toJsValue(value: unknown, seen: Set<unknown> = new Set()): unknown {
if (value === null || typeof value !== 'object') {
return value;
}
// BUG: Checks if the object was visited ANYWHERE earlier in the entire tree!
if (seen.has(value)) {
return '[cycle]';
}
seen.add(value); // Object added to global set and NEVER removed!
if (Array.isArray(value)) {
return value.map((item) => toJsValue(item, seen));
}
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
result[key] = toJsValue(val, seen);
}
return result;
}
The Flaw: DAG vs. Circular Graph Confusion
In computer science, a Directed Acyclic Graph (DAG) allows multiple distinct paths to reach the same node without containing any cycles:
Payload
/ \
LegA LegB
\ /
Token (USDC) <-- Shared reference, NOT a cycle!
In the vulnerable implementation:
toJsValuevisitsLegA.Token(USDC object) and adds its reference toseen.- The traversal backtracks to
Payloadand entersLegB. - When reaching
LegB.Token,seen.has(value)evaluates totrue. - Instead of serializing the valid USDC token metadata, the function outputs
"[cycle]".
Because the set is shared across all sibling nodes and never pruned upon backtracking, any object referenced more than once in the data payload is systematically mutilated.
(For other settings and serialization anomalies in ElizaOS, see our guide on ElizaOS AgentSkillsService SKILLS_AUTO_LOAD Boolean Setting Bug).
2. Impact on Web3 Autonomous Operations
- Cross-DEX Arbitrage Quotes: Quotes containing duplicate intermediary token objects (e.g.,
WETH -> USDC -> DAI -> WETH) have subsequent hops replaced by"[cycle]". - Multi-Signature Proposals: Batched Safe transactions sharing the same recipient address fail schema validation.
- Agent Vector Memory Retain: When the agent stores conversation state containing referenced user profiles, the state database persists
[cycle]instead of user IDs, breaking retrieval-augmented generation (RAG) lookups.
3. Production Remediation Strategies
Strategy 1: Branch-Scoped Ancestor Tracking (Upstream Patch)
To correctly identify circular references without penalizing DAGs, track only ancestors along the active call stack.
Replace the naive Set.add() with a scoped Set or backtrack cleanup:
// HARDENED IMPLEMENTATION in packages/agent/src/services/js-runtime-bridge.ts
export function toJsValue(value: unknown, ancestors: Set<unknown> = new Set()): unknown {
if (value === null || typeof value !== 'object') {
return value;
}
// True cycle check: only triggers if the object is an ancestor of itself
if (ancestors.has(value)) {
return '[cycle]';
}
// Add to active branch stack
ancestors.add(value);
try {
if (Array.isArray(value)) {
return value.map((item) => toJsValue(item, ancestors));
}
const result: Record<string, unknown> = {};
for (const [key, val] of Object.entries(value)) {
result[key] = toJsValue(val, ancestors);
}
return result;
} finally {
// CRITICAL: Unwind active ancestor stack upon returning up the tree!
ancestors.delete(value);
}
}
Strategy 2: Defensive Cloning in Custom Action Plugins
If you are developing plugins for ElizaOS and cannot wait for an upstream package release, deep-clone your parameters using structuredClone() to break shared object references into independent memory instances before passing them to the bridge:
import { Action, IAgentRuntime, Memory } from '@elizaos/core';
export const safeSwapAction: Action = {
name: 'SAFE_EXECUTE_SWAP',
description: 'Executes token swap with sanitized non-DAG payload',
handler: async (runtime: IAgentRuntime, message: Memory, state: any) => {
// structuredClone produces independent object graphs, bypassing false cycle detection
const decoupledPayload = structuredClone({
tokenIn: state.tokenIn,
tokenOut: state.tokenOut,
route: state.optimalRoute,
});
return await runtime.executeAction('EXECUTE_SWAP', decoupledPayload);
},
};
4. Verification Test Case
Verify your fix by running a Node.js or Vitest unit test asserting that shared DAG references serialize intact:
import { describe, it, expect } from 'vitest';
import { toJsValue } from './js-runtime-bridge';
describe('toJsValue DAG serialization', () => {
it('preserves shared references without generating [cycle]', () => {
const token = { symbol: 'USDC', decimals: 6 };
const payload = {
legA: { token },
legB: { token },
};
const result = toJsValue(payload) as any;
// Both legs must retain true object properties
expect(result.legA.token.symbol).toBe('USDC');
expect(result.legB.token.symbol).toBe('USDC');
expect(result.legB.token).not.toBe('[cycle]');
});
it('correctly catches actual circular references', () => {
const circularObj: any = { name: 'InfiniteLoop' };
circularObj.self = circularObj;
const result = toJsValue(circularObj) as any;
expect(result.self).toBe('[cycle]');
});
});
5. Summary Checklist for ElizaOS Developers
- Audit Custom Bridges: Inspect any custom marshaling wrappers in
plugin-*packages for monotonicSetlookups. - Always Implement
finally { ancestors.delete(obj); }: Ensure stack unwinding occurs even if an exception is thrown during property enumeration. - Log Sanitized JSON: If an agent suddenly outputs
"[cycle]"in production logs, inspect whether shared memory state was passed throughtoJsValue.