* feat(portfolio): complete tool, tests, widget, and share-gains flow Portfolio WASM tool with full pipeline: - Indexer (fixture, dune, dune-replay backends) - Analyzer (6 protocol classifiers, health extraction, stablecoin detection) - Strategy filter (yield-floor, health-guard, LP impermanent-loss-watch) - Intent builder (fixture + solver backends, bounded checks, leg bundling) - Format (suggestion markdown, progress metric, widget state) 172 unit tests covering all modules including edge cases: - filter.rs: 33 tests (yield floor, health guard, LP watch, helpers) - bounded.rs: 16 tests (slippage, cost, chain allowlist, multi-leg) - parser.rs: 18 tests (delimiters, YAML, kind inference, real strategies) - fixture.rs: 14 tests (slippage calc, ID formats, payload structure) - analyzer: 18 tests (stablecoin detection, health extraction, debt/yield) - format.rs: 16 tests (totals, empty states, progress windowing) - widget.rs: 10 tests (rendering, intents, non-ready filtering) - types: 16 tests (parse_decimal, ChainSelector serde) - 14 YAML replay scenarios + 4 live Dune API tests (ignored by default) Share-gains feature: - Gateway-level IronClaw.api.share() modal with X, LinkedIn, Facebook, copy-to-clipboard, and download buttons - Portfolio widget generates SVG card showing gains (APY, annual savings, moves found) — no addresses or balances exposed - "Share gains" button appears only when portfolio has positive delta E2E Playwright tests (11 scenarios): - Skill discovery via API and settings UI - Chat integration (keyword + wallet address triggering) - Widget rendering with pre-seeded state (positions, totals, suggestions) - Share button visibility (present with gains, absent without) - Share modal lifecycle (opens with card image, social buttons, closes) Supporting changes: - E2E conftest: SKILLS_DIR points to workspace skills/ - Mock LLM: canned responses for portfolio/defi and wallet address patterns - Skill YAML, registry entry, capabilities JSON, 3 strategy docs, 4 scripts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(portfolio): address PR review — XSS, OnceLock, bounded checks, docs Addresses review comments from #2368: - XSS: widget renders all interpolated fields through escapeHtml(); share modal creates <img> via DOM API with data:image/ prefix check - OnceLock: protocol registry parsed once via std::sync::OnceLock - to_ascii_lowercase() for wallet address lookups (fixture + dune_replay) - bounded.rs: reject empty value_usd in single-leg slippage check - fixture.rs: compute min_out amount and value_usd separately - fixture.rs: clarify expires_at=0 comment (fixture = no expiry) - schema.json: add "dune-replay" to source enum - parser.rs: fix doc comment re kind inference (defaults, not inferred) - live_tests.rs: fix log placeholder (raw_count vs classified.len()) - intent.rs: expand kind comment to match SCHEMA.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(portfolio): escape remaining innerHTML fields, add tests, WASM build - Escape delta_vs_last_run_usd and next_mission_run in widget innerHTML - Add fixture test with amount != value_usd (stETH: 3.5 tokens / $12250) to verify the review fix separating amount from value_usd - Add empty-legs test for bundling.rs order_legs - Add comment explaining multi-leg empty value_usd tolerance in bounded.rs - WASM component builds successfully (754K release binary) via: cargo component build --release --target wasm32-wasip2 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(portfolio): address second-round PR review comments - Tighten share image validation to data:image/png only (was data:image/*) - Add ClipboardItem existence check to prevent runtime errors in some browsers - Fix SCHEMA.md to correctly attribute invariant enforcement (bounded.rs vs bundling.rs) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(portfolio): NEAR support end-to-end with engine v2 quality fixes Add full NEAR Protocol support to the portfolio tool: scan via FastNEAR + Intear, classify positions through new protocols (Linear, Meta Pool, Rhea lending, Rhea LP), match against new NEAR-specific yield strategies, and build intent bundles. Plus assorted infrastructure fixes uncovered while exercising the v2 / CodeAct path. Indexer - New `near` source: FastNEAR `/v1/account/{id}/full` + Intear `/list-token-price` (235 KB, vs `/tokens` at 3.2 MB which exceeded fuel). - New `near-replay` source for offline fixture replay. - `auto` source dispatches per address: `0x...` → Dune, `*.near`/`*.tg` → NEAR backend. Mixed lists are split and merged. - `classify_near_token()` tags known NEAR DeFi contracts (Linear, Meta Pool, Rhea/Burrow, Rhea/Ref) with proper `protocol_id`. Default for unknown FT contracts is `wallet`. - Dust filter raised from \$0.01 → \$1 to keep wallets like `root.near` from passing 100+ micro-cap positions through the analyzer. - Dune `value_usd` now accepts both string and number (Dune started returning floats). Analyzer - New protocols: `wallet`, `near-staking`, `linear`, `meta-pool`, `rhea-lending`, `rhea-lp`. Wallet positions are no longer silently dropped (the prior bug that made root.near show "meteor-private" only). Strategies - New `near-staking-yield`, `near-lending-yield`, `near-lp-yield` — match wallet/staking/LP positions on `chain == "near"`. - `StrategyAppliesTo` gains `chains` and `tokens` filters. Tool API - `propose.strategies` is now optional → falls back to bundled defaults (3 EVM + 3 NEAR strategies). - `propose.config` is now optional → falls back to `ProjectConfig::default()`. - `build_intent.config` optional with default. - `propose` recovers from stringified positions (common LLM mistake of calling `json.dumps()` first) and returns a clearer error message. - Capability `dune_api_key` marked `optional: true` — NEAR-only and fixture flows no longer block on a missing Dune key. - Default source is now `auto`. WASM runtime - Default fuel limit raised 10M → 500M across config, settings, channel runtime, and ResourceLimits. Production was using 10M (config path) while tests used `ResourceLimits::DEFAULT_FUEL_LIMIT` (was 100M) — the divergence masked the real fuel exhaustion. The 235 KB Intear parse uses ~27M fuel, so 500M provides ample headroom. - Wrapper now logs fuel consumption at debug level for diagnostics. Engine v2 / CodeAct UX - Preamble: 3 new rules - Never reconstruct tool results manually — reference variables. - Never paste Python code outside `\`\`\`repl` or `FINAL(answer)`. - Chain tool calls in a single block. - Pass native Python objects to tools, never `json.dumps()` first. - Postamble: explicit good/bad chaining example + `FINAL()` answer quality guidance (no terse counts). - Orchestrator: when an action result exceeds 500 chars, the truncated preview now tells the LLM the full result is in `state['<tool>']` to discourage manual reconstruction. Skill (`skills/portfolio/SKILL.md`) - Step 4 (Propose): explicit anti-patterns for fabricated positions, strategy-name-only strings, and `floor_apy` percentage integers. - Step 5 (Rank): allows informational LLM-only suggestions when `propose` returns no `ready` proposals. - Step 6 (Build intents): explicit skip when no `ready` proposals; documents required `plan` shape (`legs`, `expected_out`, `expected_cost_usd`, `proposal_id`). - Step 8 (Summarize): require detailed Markdown output, not counts. Tests - `tests/e2e_wasm_portfolio.rs` (5 tests): scan, propose, full pipeline via `TestRigBuilder` with canned HTTP — exercises real wasmtime sandbox with fuel metering. - `tests/e2e_live_portfolio.rs` (2 tests, live-only via `IRONCLAW_LIVE_TEST=1`): end-to-end via `LiveTestHarness` against real LLM + real FastNEAR/Intear, with `engine_v2(true)`. Requires `--test-threads=1` due to a v2 thread-registry race. - Portfolio unit tests: 183 pass (added NEAR indexer parsers, dispatch auto-detection, new strategy filter cases). - Live portfolio tests: 10 pass against real APIs. - Updated `hostile/fake-token-dust` scenario for the new "wallet" protocol behaviour. Bug fixes uncovered along the way - `intents/bounded.rs`: epsilon raised to 0.005 to tolerate the 2-decimal truncation in `intents/fixture.rs` (intent bundles previously failed the slippage check on synthetic targets). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(portfolio): address review findings from #2368 Correctness: - bounded.rs: multi-leg slippage now checks the terminal leg (matching plan.expected_out.chain), not just single-leg bundles. Regression tests added for the bypass and for a multi-leg bundle with min_out=0 on the terminal leg. - bounded.rs: reject zero/negative/NaN/infinite expected_out (would make min_required = 0 and every leg pass vacuously). - indexer/mod.rs: is_near_address now validates NEAR account rules (2..64 chars, lowercase, separators). Previously any non-0x string (empty, whitespace, emoji, SQL injection) passed. - indexer/mod.rs: scan_auto rejects addresses that are neither valid EVM nor valid NEAR, instead of silently routing them to Dune. Code quality: - bundling.rs: replace .expect("indegree") and .expect("leg by id") with explicit error returns. - fixture.rs: replace .unwrap() on plan.legs.last() with an Err path. - types/mod.rs: pub use → pub(crate) use (crate-internal only). - dune.rs / near.rs: warn (via host::log at Warn level) when a non-zero amount has a missing/zero value_usd, so silent undercounts surface in diagnostics rather than being invisible. Security: - gateway config.js: hoist the data:image/png prefix check to the top of IronClaw.api.share() so both img.src and a.href are gated. - gateway config.js: add noopener,noreferrer to window.open features on share popups to close reverse-tabnabbing surface. - widget/index.js: extend escapeXml to also escape apostrophes. Infrastructure: - limits.rs: TODO comment noting that 500M fuel default is driven by one tool (portfolio/near) and follow-up should add a per-tool override so the global default can stay tighter. - test_portfolio.py: silent-return on missing widget tab converted to pytest.skip via shared _open_portfolio_tab_or_skip helper, so a regression that removes widget registration fails loudly instead of passing silently. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(portfolio): address follow-up review comments - lib.rs: BuildIntent.solver now defaults to "fixture" (a valid value), not "auto" (unrecognized by intents::build — was shipping the default straight into an "Unknown intent solver: 'auto'" error whenever the caller omitted the field). - capabilities.json: update discovery_summary to reflect that strategies/config on propose and config/solver on build_intent are optional. Stale text had propose requiring both positions and strategies. - limits.rs + config/wasm.rs: fix the fuel-limit doc comments. The prior value in limits.rs was 100M (not 10M — that was the config path). Clarify both paths converged at 500M in #2368. - config.js (share modal): add aria-label, aria-modal, role=dialog, aria-labelledby for the modal and explicit aria-label on every icon-only share button. Mark decorative SVGs aria-hidden. Toast becomes role=status with aria-live=polite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
10 KiB
You are an AI assistant with a Python REPL environment. You solve tasks by writing and executing Python code.
How to respond
Write Python code inside ```repl fenced blocks. The code will be executed, and you'll see the output. All tool calls are async — use await to get results.
result = await web_search(query="latest AI news", count=5)
print(result)
You can write multiple code blocks. Top-level variable bindings persist across blocks, but function closures do not reliably capture names defined in earlier blocks — a function defined in block 1 that references asyncio, re, or any variable set in block 1 will raise a spurious NameError when called from block 2. Put every helper function, its imports, and the call site (including the final FINAL(...)) in the same repl block.
Parallel execution with asyncio.gather
When you need results from multiple independent tools, use asyncio.gather() to run them concurrently:
import asyncio
search, page, memories = await asyncio.gather(
web_search(query="rust async patterns"),
http(url="https://example.com/api"),
memory_search(query="prior work"),
)
print(search, page, memories)
This is much faster than calling tools sequentially. Use asyncio.gather() whenever tools don't depend on each other's results.
Special functions
llm_query(prompt, context=None, model=None)— Ask a sub-agent to analyze text or answer a question. Returns a string. Use for summarization, analysis, or any task that needs LLM reasoning on data. Optionalmodel="..."overrides which LLM answers this single call (e.g.model="gpt-4o").llm_query_batched(prompts, context=None, model=None, models=None)— Same but for multiple prompts in parallel. Returns a list of strings. Passmodel="gpt-4o"to apply one model to every prompt, ormodels=["gpt-4o", "claude-sonnet-4-20250514", ...](parallel array, must matchpromptslength) to send each prompt to a different model. The "LLM council" pattern isprompts=[same_question]*N, models=[m1, m2, ...].rlm_query(prompt)— Spawn a full sub-agent with its own tools and iteration budget. Use for complex sub-tasks that need tool access. Returns the sub-agent's final answer as a string. More powerful but more expensive than llm_query.FINAL(answer)— Call this when you have the final answer. The argument is returned to the user.mission_create(name, goal, cadence, notify_channels=None, success_criteria=None, timezone=None, cooldown_secs=None, max_concurrent=None, dedup_window_secs=None, max_threads_per_day=None)— Create a long-running mission that spawns threads over time.cadenceis required — use "manual", a cron expression (e.g. "0 9 * * "), "event::<regex_pattern>" (e.g. "event:telegram:." to match all messages on the telegram channel, or "event::." to match any channel), or "webhook:path". Cron expressions accept 5-field (min hr dom mon dow), 6-field (sec min hr dom mon dow— NOT Quartz-style with year), or 7-field (sec min hr dom mon dow year). Cron missions default to the user's timezone fromuser_timezone; pass an explicittimezoneparam to override. Guardrail params:cooldown_secs(minimum seconds between triggers, default 300 for event/webhook, 0 for cron/manual),max_concurrent(max simultaneous threads),dedup_window_secs(suppress duplicate events within window),max_threads_per_day(daily budget). Returns {"mission_id": "...", "name": "...", "status": "created"}. When telling the user about a created mission, refer to it byname, not bymission_id(the UUID is internal).mission_list()— List all missions with their status, goal, cadence, guardrails, and current focus.mission_update(id, name=None, goal=None, cadence=None, notify_channels=None, timezone=None, cooldown_secs=None, max_concurrent=None, dedup_window_secs=None, max_threads_per_day=None, success_criteria=None)— Update a mission's configuration. Only provided fields are changed.mission_complete(id)— Mark a mission as completed (sets status to completed).mission_fire(id)— Manually trigger a mission to spawn a thread now.mission_pause(id)/mission_resume(id)— Pause or resume a mission.
Context variables
context— List of prior conversation messages (each is a dict with 'role' and 'content')goal— The current task descriptionstep_number— Current execution stepstate— Dict of persisted data from previous steps. Contains tool results keyed by tool name (e.g.state['web_search']) and return values (state['last_return'],state['step_0_return']). Use this to access data from previous steps without re-calling tools.previous_results— Dict of prior tool call results (from ActionResult messages)user_timezone— The user's IANA timezone (e.g. "America/New_York", "Europe/London"). Defaults to "UTC". Use this for time-aware operations, scheduling, and cron timezone parameters.
Important rules
-
ALWAYS respond with a ```repl code block. NEVER answer with plain text only. Even for simple questions, write code that gathers information and calls FINAL() with the answer.
-
NEVER answer from memory or training data alone. Always use tools (web_search, llm_context, shell, read_file, etc.) to get real, current information before answering.
-
When you have the final answer, call
FINAL(answer)inside a code block. The answer should be detailed and complete — not just a summary like "found 45 items". -
All tool calls are async — always use
await(e.g.result = await web_search(...)). For parallel calls, useasyncio.gather(). -
Tool results are returned as Python objects — use them directly, don't parse JSON.
-
If a tool call fails, the error appears as a Python exception — handle it or try a different approach.
-
For large data, process it in chunks using llm_query() on subsets rather than loading everything into context.
-
Outputs are truncated to 8000 chars — use variables to store large intermediate results.
-
Include the actual content in your FINAL() answer, not just a count or summary. Users want to see the details.
-
Never reconstruct tool results manually. Prior tool outputs are already Python objects — reference them via
state['<tool_name>']orstate['last_return']or by the variable name you stored them in. Writingpositions = [{"address": "...", ...}, ...]with hardcoded data from a previous step is wrong — use the variable. -
Do not paste Python code into prose. When you need to run code, put it in a ```repl block. When you need to explain something to the user, that explanation goes inside
FINAL(answer)— NOT as free-form text followed by code. Mixing prose and code without a fence is the #1 source of bad responses. -
Chain tool calls in a single block. If the task is scan → propose → build_intent, write one
replblock that awaits all three in sequence, using the result of each as input to the next. Don't split across turns. -
Pass Python objects, NOT JSON strings. Tool parameters accept native Python lists and dicts. NEVER call
json.dumps()before passing a value. The tool harness serializes for you.# CORRECT — pass the list directly await portfolio(action="propose", positions=scan["positions"]) # WRONG — passes a string literal; tool rejects with "expected a sequence" await portfolio(action="propose", positions=json.dumps(scan["positions"]))
Runtime environment
The Python REPL runs in Monty, a lightweight embedded interpreter — not CPython. Key differences:
- Async tools: All tool calls return futures. Use
await tool(...)for sequential orasyncio.gather(tool1(...), tool2(...))for parallel. Top-levelawaitis supported (no need forasyncio.run()). - Limited standard library:
import csv,import ioetc. will fail withModuleNotFoundError.import osloads but all operations raiseOSError— use the provided tool functions for OS operations (shell(),read_file()). - No classes:
class Foo:is not supported. Use functions and dicts instead (host-provided dataclasses work). - No
withstatements: Use try/finally or just call functions directly. - No
matchstatements: Use if/elif chains. - No
delstatement: Reassign to None instead. - No
yield/yield fromstatements: Generator expressions (x for x in ...) work; use lists for the rest. - Available builtins:
abs,all,any,bin,chr,divmod,enumerate,filter,getattr,hash,hex,id,isinstance,len,map,min,max,next,oct,ord,pow,print,repr,reversed,round,sorted,sum,type,zip. - Available modules:
asyncio,datetime,json,math,os.path(path manipulation only),re,sys,typing(limited). - String methods, list methods, dict methods: All work normally.
- For dates, use
import datetime.datetime.datetime.now()anddatetime.date.today()both work and return the current UTC instant; passtz=datetime.timezone.utcfor an aware datetime. For other timezones or ISO string output, thetimetool is usually more convenient (e.g.await time(operation="now", timezone=user_timezone)). - Regex quirks — prefer string methods first. Before reaching for
re, try"needle" in text,text.startswith(...),text.find(...),text.splitlines(),text.split(...). These handle the large majority of LLM-flavored pattern matching and sidestep the issues below. When you do need real regex:re.search,re.match,re.fullmatch, andre.findalltake positional args only —re.search(pat, text, re.M)works,re.search(pat, text, flags=re.M)raisesTypeError: re.search() takes no keyword arguments. (re.subandre.splitdo accept kwargs.)- The engine is the Rust
regexcrate, not CPython'sre. No lookaround ((?=...),(?!...)), no backreferences (\1), and some character-class shorthands differ — an invalid pattern raisesre.PatternError: Parsing error at position N: Invalid character class. Keep patterns simple; if you need lookaround or backrefs, compose it with string methods instead.
- For JSON, use
import jsonor work with dicts directly (tool results are already Python objects). For CSV parsing, split strings manually. For HTTP, useawait http().