Files
wehub-resource-sync 426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

618 lines
19 KiB
Python

"""
Simulation Bridge Client
Python client for the TypeScript simulation bridge server.
Enables online training by calling TypeScript for scenarios and action execution.
Usage:
async with SimulationBridge("http://localhost:3001") as bridge:
await bridge.initialize(num_npcs=20, seed=12345)
for npc_id in bridge.npc_ids:
scenario = await bridge.get_scenario(npc_id)
action = generate_action(scenario)
outcome = await bridge.execute_action(npc_id, action)
await bridge.tick()
"""
import asyncio
import logging
import time
from dataclasses import dataclass, field
from typing import Any
import aiohttp
logger = logging.getLogger(__name__)
# =============================================================================
# Data Types
# =============================================================================
@dataclass
class PerpMarket:
"""Perpetual futures market data"""
ticker: str
current_price: float
change_percent_24h: float
volume_24h: float
@dataclass
class PredictionMarket:
"""Prediction market data"""
id: str
question: str
yes_price: float
no_price: float
@dataclass
class Position:
"""Agent's open position"""
id: str
market_type: str # "perp" or "prediction"
ticker: str | None = None
market_id: str | None = None
side: str = "long"
size: float = 0.0
unrealized_pnl: float = 0.0
@dataclass
class NewsItem:
"""Recent news or post"""
content: str
source: str
timestamp: str
sentiment: float | None = None
@dataclass
class Relationship:
"""Social relationship with another actor"""
actor_id: str
actor_name: str
sentiment: float # -1 to 1
@dataclass
class SocialContext:
"""Social context for agent"""
relationships: list[Relationship] = field(default_factory=list)
group_chats: list[str] = field(default_factory=list)
recent_messages: list[dict[str, str]] = field(default_factory=list)
@dataclass
class MarketState:
"""Current market state"""
perp_markets: list[PerpMarket] = field(default_factory=list)
prediction_markets: list[PredictionMarket] = field(default_factory=list)
@dataclass
class Scenario:
"""Complete scenario for agent decision-making"""
npc_id: str
archetype: str
market_state: MarketState
positions: list[Position]
balance: float
recent_news: list[NewsItem]
social_context: SocialContext
def to_prompt_context(self) -> str:
"""Convert scenario to text context for LLM prompt"""
lines = []
lines.append(f"Agent ID: {self.npc_id}")
lines.append(f"Archetype: {self.archetype}")
lines.append(f"Balance: ${self.balance:,.2f}")
lines.append("")
lines.append("=== MARKETS ===")
for m in self.market_state.perp_markets:
sign = "+" if m.change_percent_24h >= 0 else ""
lines.append(
f" {m.ticker}: ${m.current_price:.2f} ({sign}{m.change_percent_24h:.2f}%)"
)
if self.market_state.prediction_markets:
lines.append("")
lines.append("=== PREDICTIONS ===")
for m in self.market_state.prediction_markets:
lines.append(f" [{m.id}] {m.question[:50]}...")
lines.append(f" YES: {m.yes_price:.0f}¢ | NO: {m.no_price:.0f}¢")
if self.positions:
lines.append("")
lines.append("=== POSITIONS ===")
for p in self.positions:
symbol = p.ticker or f"Q{p.market_id}"
pnl_sign = "+" if p.unrealized_pnl >= 0 else ""
lines.append(
f" {symbol} {p.side.upper()}: ${p.size:.2f} (PnL: {pnl_sign}${p.unrealized_pnl:.2f})"
)
if self.recent_news:
lines.append("")
lines.append("=== RECENT NEWS ===")
for news in self.recent_news[:3]:
lines.append(f" [{news.source}]: {news.content[:80]}...")
return "\n".join(lines)
@dataclass
class ActionOutcome:
"""Result of executing an action"""
success: bool
pnl: float
new_balance: float
new_positions: list[Position]
social_impact: dict[str, int]
events: list[dict[str, str]]
error: str | None = None
@dataclass
class TickResult:
"""Result of advancing simulation"""
tick_number: int
events: list[dict[str, Any]]
market_changes: list[dict[str, Any]]
# =============================================================================
# Client Implementation
# =============================================================================
class SimulationBridge:
"""
Client for TypeScript simulation bridge.
Provides async methods for interacting with the simulation:
- initialize(): Start a new simulation
- get_scenario(): Get current scenario for an NPC
- execute_action(): Execute an action and get outcome
- tick(): Advance simulation by one tick
- reset(): Reset simulation state
- poll_trajectories(): Poll new trajectory records from the bridge
"""
def __init__(
self,
base_url: str = "http://localhost:3001",
timeout: float = 30.0,
max_retries: int = 3,
auth_token: str | None = None,
):
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.max_retries = max_retries
self.auth_token = auth_token
self._session: aiohttp.ClientSession | None = None
self._npc_ids: list[str] = []
self._archetypes: dict[str, str] = {}
self._initialized: bool = False
self._last_trajectory_id: str = ""
self._server_epoch: str = ""
self._trajectory_lock = asyncio.Lock()
@property
def is_initialized(self) -> bool:
return self._initialized
@property
def npc_ids(self) -> list[str]:
return self._npc_ids.copy()
@property
def archetypes(self) -> dict[str, str]:
return self._archetypes.copy()
async def __aenter__(self) -> "SimulationBridge":
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=self.timeout),
headers=self._auth_headers(),
)
return self
async def __aexit__(self, *args) -> None:
if self._session:
await self._session.close()
self._session = None
def _auth_headers(self) -> dict[str, str]:
"""Build auth headers if token is configured."""
if self.auth_token:
return {"Authorization": f"Bearer {self.auth_token}"}
return {}
async def _ensure_session(self) -> aiohttp.ClientSession:
if not self._session:
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=self.timeout),
headers=self._auth_headers(),
)
return self._session
async def _recreate_session(self) -> None:
if self._session:
try:
await self._session.close()
except Exception:
pass
self._session = None
self._session = aiohttp.ClientSession(
timeout=aiohttp.ClientTimeout(total=self.timeout),
headers=self._auth_headers(),
)
async def _request(
self,
method: str,
path: str,
json_data: dict | None = None,
) -> dict[str, Any]:
"""Make HTTP request with retry logic.
Retries on 5xx and network errors. Fails immediately on 4xx (client errors).
"""
session = await self._ensure_session()
url = f"{self.base_url}{path}"
last_error: Exception | None = None
for attempt in range(self.max_retries):
try:
if method == "GET":
async with session.get(url) as resp:
if resp.status == 200:
return await resp.json()
error_body = await resp.text()
if 400 <= resp.status < 500:
# Client error — don't retry
raise RuntimeError(f"HTTP {resp.status}: {error_body}")
# 5xx — retry
raise aiohttp.ClientResponseError(
resp.request_info, resp.history,
status=resp.status, message=error_body,
)
else:
async with session.post(url, json=json_data or {}) as resp:
if resp.status == 200:
return await resp.json()
error_body = await resp.text()
if 400 <= resp.status < 500:
raise RuntimeError(f"HTTP {resp.status}: {error_body}")
raise aiohttp.ClientResponseError(
resp.request_info, resp.history,
status=resp.status, message=error_body,
)
except asyncio.TimeoutError as e:
last_error = e
logger.warning(f"Request timeout (attempt {attempt + 1}/{self.max_retries})")
except aiohttp.ClientError as e:
last_error = e
logger.warning(f"Client error (attempt {attempt + 1}/{self.max_retries}): {e}")
if "Connector is closed" in str(e):
await self._recreate_session()
session = self._session # type: ignore[assignment]
await asyncio.sleep(min(0.5 * 2**attempt, 5.0)) # exponential backoff, max 5s
raise RuntimeError(f"Request failed after {self.max_retries} attempts: {last_error}")
async def health_check(self) -> dict[str, Any]:
"""Check server health"""
return await self._request("GET", "/health")
async def initialize(
self,
num_npcs: int = 20,
seed: int | None = None,
archetypes: list[str] | None = None,
) -> dict[str, Any]:
"""
Initialize a new simulation.
Args:
num_npcs: Number of NPCs to create
seed: Random seed for reproducibility
archetypes: List of archetypes to assign to NPCs
Returns:
Initialization result with NPC IDs and archetypes
"""
request_data = {
"numNPCs": num_npcs,
"seed": seed or int(time.time()),
}
if archetypes:
request_data["archetypes"] = archetypes
result = await self._request("POST", "/init", request_data)
if result.get("status") == "initialized":
self._npc_ids = result.get("npcIds", [])
self._archetypes = result.get("archetypes", {})
self._initialized = True
logger.info(f"Simulation initialized with {len(self._npc_ids)} NPCs")
else:
raise RuntimeError(f"Initialization failed: {result.get('message', 'Unknown error')}")
return result
async def get_scenario(self, npc_id: str) -> Scenario:
"""
Get current scenario for an NPC.
Args:
npc_id: NPC identifier
Returns:
Complete scenario with market state, positions, etc.
"""
data = await self._request("GET", f"/scenario/{npc_id}")
# Parse market state
market_state = MarketState(
perp_markets=[
PerpMarket(
ticker=m["ticker"],
current_price=m["currentPrice"],
change_percent_24h=m["changePercent24h"],
volume_24h=m["volume24h"],
)
for m in data.get("marketState", {}).get("perpMarkets", [])
],
prediction_markets=[
PredictionMarket(
id=m["id"],
question=m.get("question") or m.get("title", "Unknown"),
yes_price=m["yesPrice"],
no_price=m["noPrice"],
)
for m in data.get("marketState", {}).get("predictionMarkets", [])
],
)
# Parse positions
positions = [
Position(
id=p["id"],
market_type=p["marketType"],
ticker=p.get("ticker"),
market_id=p.get("marketId"),
side=p["side"],
size=p["size"],
unrealized_pnl=p.get("unrealizedPnL", 0),
)
for p in data.get("positions", [])
]
# Parse news
recent_news = [
NewsItem(
content=n["content"],
source=n["source"],
timestamp=n["timestamp"],
sentiment=n.get("sentiment"),
)
for n in data.get("recentNews", [])
]
# Parse social context
social_data = data.get("socialContext", {})
social_context = SocialContext(
relationships=[
Relationship(
actor_id=r["actorId"],
actor_name=r["actorName"],
sentiment=r["sentiment"],
)
for r in social_data.get("relationships", [])
],
group_chats=social_data.get("groupChats", []),
recent_messages=social_data.get("recentMessages", []),
)
return Scenario(
npc_id=data["npcId"],
archetype=data["archetype"],
market_state=market_state,
positions=positions,
balance=data["balance"],
recent_news=recent_news,
social_context=social_context,
)
async def execute_action(
self,
npc_id: str,
action_type: str,
ticker: str | None = None,
market_id: str | None = None,
amount: float | None = None,
side: str | None = None,
position_id: str | None = None,
reasoning: str | None = None,
) -> ActionOutcome:
"""
Execute an action for an NPC.
Args:
npc_id: NPC identifier
action_type: Type of action (open_long, open_short, buy_yes, etc.)
ticker: Ticker for perp trades
market_id: Market ID for prediction trades
amount: Trade amount
side: Trade side (long/short or yes/no)
position_id: Position ID for closing
reasoning: Reasoning for the action (for logging)
Returns:
Action outcome with PnL, new balance, etc.
"""
request_data = {
"npcId": npc_id,
"action": {
"type": action_type,
},
}
if ticker:
request_data["action"]["ticker"] = ticker
if market_id:
request_data["action"]["marketId"] = market_id
if amount is not None:
request_data["action"]["amount"] = amount
if side:
request_data["action"]["side"] = side
if position_id:
request_data["action"]["positionId"] = position_id
if reasoning:
request_data["reasoning"] = reasoning
data = await self._request("POST", "/execute", request_data)
# Parse positions
new_positions = [
Position(
id=p["id"],
market_type=p["marketType"],
ticker=p.get("ticker"),
market_id=p.get("marketId"),
side=p["side"],
size=p["size"],
)
for p in data.get("newPositions", [])
]
return ActionOutcome(
success=data["success"],
pnl=data["pnl"],
new_balance=data["newBalance"],
new_positions=new_positions,
social_impact=data.get("socialImpact", {}),
events=data.get("events", []),
error=data.get("error"),
)
async def tick(self) -> TickResult:
"""
Advance simulation by one tick.
Returns:
Tick result with events and market changes
"""
data = await self._request("POST", "/tick")
return TickResult(
tick_number=data["tickNumber"],
events=data.get("events", []),
market_changes=data.get("marketChanges", []),
)
async def reset(self) -> None:
"""Reset simulation state"""
await self._request("POST", "/reset")
self._npc_ids = []
self._archetypes = {}
self._initialized = False
logger.info("Simulation reset")
async def list_npcs(self) -> list[dict[str, str]]:
"""Get list of all NPCs with their archetypes"""
data = await self._request("GET", "/npcs")
return data.get("npcs", [])
async def get_all_scenarios(self) -> list[Scenario]:
"""Get scenarios for all NPCs (batch mode)"""
data = await self._request("GET", "/scenarios")
scenarios = []
for scenario_data in data.get("scenarios", []):
npc_id = scenario_data["npcId"]
scenario = await self.get_scenario(npc_id)
scenarios.append(scenario)
return scenarios
# ── Trajectory Streaming ──────────────────────────────────────────
async def poll_trajectories(self, limit: int = 100) -> list[dict[str, Any]]:
"""
Poll new trajectory records since last call.
Returns list of trajectory records. Automatically tracks the last
seen ID so subsequent calls only return new records.
Thread-safe via asyncio.Lock. Detects server restarts via epoch.
"""
async with self._trajectory_lock:
params = f"?limit={limit}"
if self._last_trajectory_id:
params += f"&since_id={self._last_trajectory_id}"
data = await self._request("GET", f"/trajectories{params}")
# Detect server restart — reset cursor if epoch changed
epoch = data.get("serverEpoch", "")
if epoch and self._server_epoch and epoch != self._server_epoch:
logger.warning(
f"Server restarted (epoch {self._server_epoch}{epoch}), "
f"resetting trajectory cursor"
)
self._last_trajectory_id = ""
# Re-fetch from beginning
data = await self._request("GET", f"/trajectories?limit={limit}")
if epoch:
self._server_epoch = epoch
records = data.get("trajectories", [])
if records:
self._last_trajectory_id = data.get("lastId", self._last_trajectory_id)
return records
async def trajectory_stats(self) -> dict[str, Any]:
"""Get trajectory buffer statistics."""
return await self._request("GET", "/trajectories/stats")
# =============================================================================
# Convenience Functions
# =============================================================================
async def create_bridge(
base_url: str = "http://localhost:3001",
num_npcs: int = 20,
seed: int | None = None,
archetypes: list[str] | None = None,
auth_token: str | None = None,
) -> SimulationBridge:
"""
Create and initialize a simulation bridge.
Convenience function for quick setup.
"""
bridge = SimulationBridge(base_url, auth_token=auth_token)
await bridge.__aenter__()
await bridge.initialize(num_npcs=num_npcs, seed=seed, archetypes=archetypes)
return bridge