Files
ironclaw/crates/ironclaw_engine/prompts/codeact_preamble.md
Illia Polosukhin 77e746f683 feat(portfolio): complete tool, tests, widget, and share-gains flow (#2368)
* 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>
2026-04-20 14:47:51 +09:00

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. Optional model="..." 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. Pass model="gpt-4o" to apply one model to every prompt, or models=["gpt-4o", "claude-sonnet-4-20250514", ...] (parallel array, must match prompts length) to send each prompt to a different model. The "LLM council" pattern is prompts=[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. cadence is 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 from user_timezone; pass an explicit timezone param 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 by name, not by mission_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 description
  • step_number — Current execution step
  • state — 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

  1. 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.

  2. 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.

  3. 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".

  4. All tool calls are async — always use await (e.g. result = await web_search(...)). For parallel calls, use asyncio.gather().

  5. Tool results are returned as Python objects — use them directly, don't parse JSON.

  6. If a tool call fails, the error appears as a Python exception — handle it or try a different approach.

  7. For large data, process it in chunks using llm_query() on subsets rather than loading everything into context.

  8. Outputs are truncated to 8000 chars — use variables to store large intermediate results.

  9. Include the actual content in your FINAL() answer, not just a count or summary. Users want to see the details.

  10. Never reconstruct tool results manually. Prior tool outputs are already Python objects — reference them via state['<tool_name>'] or state['last_return'] or by the variable name you stored them in. Writing positions = [{"address": "...", ...}, ...] with hardcoded data from a previous step is wrong — use the variable.

  11. 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.

  12. Chain tool calls in a single block. If the task is scan → propose → build_intent, write one repl block that awaits all three in sequence, using the result of each as input to the next. Don't split across turns.

  13. 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 or asyncio.gather(tool1(...), tool2(...)) for parallel. Top-level await is supported (no need for asyncio.run()).
  • Limited standard library: import csv, import io etc. will fail with ModuleNotFoundError. import os loads but all operations raise OSError — 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 with statements: Use try/finally or just call functions directly.
  • No match statements: Use if/elif chains.
  • No del statement: Reassign to None instead.
  • No yield/yield from statements: 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() and datetime.date.today() both work and return the current UTC instant; pass tz=datetime.timezone.utc for an aware datetime. For other timezones or ISO string output, the time tool 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, and re.findall take positional args onlyre.search(pat, text, re.M) works, re.search(pat, text, flags=re.M) raises TypeError: re.search() takes no keyword arguments. (re.sub and re.split do accept kwargs.)
    • The engine is the Rust regex crate, not CPython's re. No lookaround ((?=...), (?!...)), no backreferences (\1), and some character-class shorthands differ — an invalid pattern raises re.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 json or work with dicts directly (tool results are already Python objects). For CSV parsing, split strings manually. For HTTP, use await http().