diff --git a/CLAUDE.md b/CLAUDE.md index 57c7d08..2ab2395 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,16 +15,18 @@ Igor is the runtime for portable, immortal software agents. The checkpoint file ```bash make bootstrap # Install toolchain (Go, golangci-lint, goimports, TinyGo) make build # Build igord → bin/igord -make agent # Build example WASM agent → agents/example/agent.wasm -make agent-heartbeat # Build heartbeat WASM agent → agents/heartbeat/agent.wasm +make agent # Build example WASM agent → agents/example/agent.wasm +make agent-heartbeat # Build heartbeat WASM agent → agents/heartbeat/agent.wasm +make agent-pricewatcher # Build price watcher WASM agent → agents/pricewatcher/agent.wasm make test # Run tests: go test -v ./... make lint # golangci-lint (5m timeout) make vet # go vet make fmt # gofmt + goimports make check # fmt-check + vet + lint + test (same as precommit) make run-agent # Build + run example agent with budget 1.0 -make demo # Build + run bridge reconciliation demo -make demo-portable # Build + run portable agent demo (run → stop → copy → resume → verify) +make demo # Build + run bridge reconciliation demo +make demo-portable # Build + run portable agent demo (run → stop → copy → resume → verify) +make demo-pricewatcher # Build + run price watcher demo (fetch prices → stop → resume → verify) make clean # Remove bin/, checkpoints/, agent.wasm ``` @@ -47,7 +49,7 @@ Legacy mode (P2P/migration): ## Architecture ### Execution model -Agents export 5 WASM functions: `agent_init`, `agent_tick`, `agent_checkpoint`, `agent_checkpoint_ptr`, `agent_resume`. TinyGo agents provide `malloc` automatically. The runtime drives an adaptive tick loop: 1 Hz default, 10ms fast path when `agent_tick` returns 1 (more work pending). Each tick is budgeted: `cost = elapsed_nanoseconds × price_per_second / 1e9`. Checkpoints save every 5 seconds. Tick timeout: 100ms. +Agents export 5 WASM functions: `agent_init`, `agent_tick`, `agent_checkpoint`, `agent_checkpoint_ptr`, `agent_resume`. TinyGo agents provide `malloc` automatically. The runtime drives an adaptive tick loop: 1 Hz default, 10ms fast path when `agent_tick` returns 1 (more work pending). Each tick is budgeted: `cost = elapsed_nanoseconds × price_per_second / 1e9`. Checkpoints save every 5 seconds. Tick timeout: 15s (increased from 100ms to accommodate HTTP hostcalls). ### Checkpoint format (binary, little-endian) Current version is v0x04 (209-byte header). Supports reading v0x02 (57 bytes) and v0x03 (81 bytes). @@ -79,6 +81,7 @@ Atomic writes via temp file → fsync → rename. Every checkpoint is also archi - `pkg/receipt/` — Payment receipt data structure, Ed25519 signing, binary serialization - `sdk/igor/` — Agent SDK: hostcall wrappers (ClockNow, RandBytes, Log, WalletBalance), lifecycle plumbing (Agent interface), Encoder/Decoder with Raw/FixedBytes/ReadInto for checkpoint serialization - `agents/heartbeat/` — Demo agent: logs heartbeat with tick count and age, milestones every 10 ticks +- `agents/pricewatcher/` — Demo agent: fetches BTC/ETH prices from CoinGecko, tracks high/low/latest across checkpoint/resume - `agents/example/` — Original demo agent (Survivor) from research phases - `scripts/demo-portable.sh` — End-to-end portable agent demo diff --git a/Makefile b/Makefile index f0b561f..4b634f0 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help bootstrap build build-lab clean test lint vet fmt fmt-check tidy agent agent-heartbeat agent-reconciliation run-agent demo demo-portable gh-check gh-metadata gh-release +.PHONY: help bootstrap build build-lab clean test lint vet fmt fmt-check tidy agent agent-heartbeat agent-reconciliation agent-pricewatcher run-agent demo demo-portable demo-pricewatcher gh-check gh-metadata gh-release .DEFAULT_GOAL := help @@ -8,6 +8,7 @@ BINARY_DIR := bin AGENT_DIR := agents/example HEARTBEAT_AGENT_DIR := agents/heartbeat RECONCILIATION_AGENT_DIR := agents/reconciliation +PRICEWATCHER_AGENT_DIR := agents/pricewatcher # Go commands GOCMD := go @@ -54,6 +55,7 @@ clean: ## Remove build artifacts rm -f agents/example/agent.wasm.checkpoint rm -f agents/heartbeat/agent.wasm rm -f agents/reconciliation/agent.wasm + rm -f agents/pricewatcher/agent.wasm @echo "Clean complete" test: ## Run tests (with race detector) @@ -118,6 +120,13 @@ agent-reconciliation: ## Build reconciliation agent WASM cd $(RECONCILIATION_AGENT_DIR) && $(MAKE) build @echo "Agent built: $(RECONCILIATION_AGENT_DIR)/agent.wasm" +agent-pricewatcher: ## Build price watcher demo agent WASM + @echo "Building pricewatcher agent..." + @which tinygo > /dev/null || \ + (echo "tinygo not found. See docs/governance/DEVELOPMENT.md for installation" && exit 1) + cd $(PRICEWATCHER_AGENT_DIR) && $(MAKE) build + @echo "Agent built: $(PRICEWATCHER_AGENT_DIR)/agent.wasm" + demo: build agent-reconciliation ## Build and run reconciliation demo @echo "Building demo runner..." @mkdir -p $(BINARY_DIR) @@ -130,6 +139,11 @@ demo-portable: build agent-heartbeat ## Run the portable agent demo (run, stop, @chmod +x scripts/demo-portable.sh @./scripts/demo-portable.sh +demo-pricewatcher: build agent-pricewatcher ## Run the price watcher demo (fetch prices, stop, resume, verify) + @echo "Running Price Watcher Demo..." + @chmod +x scripts/demo-pricewatcher.sh + @./scripts/demo-pricewatcher.sh + check: fmt-check vet lint test ## Run all checks (formatting, vet, lint, tests) @echo "All checks passed" diff --git a/agents/pricewatcher/Makefile b/agents/pricewatcher/Makefile new file mode 100644 index 0000000..7fa1ed3 --- /dev/null +++ b/agents/pricewatcher/Makefile @@ -0,0 +1,7 @@ +.PHONY: build clean + +build: + tinygo build -target=wasi -no-debug -o agent.wasm . + +clean: + rm -f agent.wasm diff --git a/agents/pricewatcher/agent.manifest.json b/agents/pricewatcher/agent.manifest.json new file mode 100644 index 0000000..1ab749f --- /dev/null +++ b/agents/pricewatcher/agent.manifest.json @@ -0,0 +1,17 @@ +{ + "capabilities": { + "clock": { "version": 1 }, + "log": { "version": 1 }, + "http": { + "version": 1, + "options": { + "allowed_hosts": ["api.coingecko.com"], + "timeout_ms": 10000, + "max_response_bytes": 4096 + } + } + }, + "resource_limits": { + "max_memory_bytes": 67108864 + } +} diff --git a/agents/pricewatcher/go.mod b/agents/pricewatcher/go.mod new file mode 100644 index 0000000..14e20ae --- /dev/null +++ b/agents/pricewatcher/go.mod @@ -0,0 +1,7 @@ +module github.com/simonovic86/igor/agents/pricewatcher + +go 1.24 + +require github.com/simonovic86/igor/sdk/igor v0.0.0 + +replace github.com/simonovic86/igor/sdk/igor => ../../sdk/igor diff --git a/agents/pricewatcher/main.go b/agents/pricewatcher/main.go new file mode 100644 index 0000000..ef49d8a --- /dev/null +++ b/agents/pricewatcher/main.go @@ -0,0 +1,237 @@ +// SPDX-License-Identifier: Apache-2.0 + +//go:build tinygo || wasip1 + +package main + +import ( + "github.com/simonovic86/igor/sdk/igor" +) + +// stateSize is the fixed checkpoint size in bytes: +// TickCount(8) + BirthNano(8) + LastNano(8) + +// BTCLatest(8) + BTCHigh(8) + BTCLow(8) + +// ETHLatest(8) + ETHHigh(8) + ETHLow(8) + +// ObservationCount(4) + ErrorCount(4) +const stateSize = 80 + +const apiURL = "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum&vs_currencies=usd" + +// fetchInterval is how many ticks between API calls (CoinGecko free tier: ~30 req/min). +const fetchInterval = 10 + +// PriceWatcher fetches BTC and ETH prices from CoinGecko on every tick, +// tracks all-time high/low, and logs price updates. All state survives +// checkpoint/resume across machines — proving portable, immortal agents +// that accumulate real-world knowledge over time. +type PriceWatcher struct { + TickCount uint64 + BirthNano int64 + LastNano int64 + BTCLatest uint64 // price in millicents (USD × 100) + BTCHigh uint64 + BTCLow uint64 + ETHLatest uint64 + ETHHigh uint64 + ETHLow uint64 + ObservationCount uint32 + ErrorCount uint32 +} + +func (p *PriceWatcher) Init() {} + +func (p *PriceWatcher) Tick() bool { + p.TickCount++ + + now := igor.ClockNow() + if p.BirthNano == 0 { + p.BirthNano = now + } + p.LastNano = now + + ageSec := (p.LastNano - p.BirthNano) / 1_000_000_000 + + // Fetch prices on tick 1 and every fetchInterval ticks after that. + if p.TickCount == 1 || p.TickCount%fetchInterval == 0 { + status, body, err := igor.HTTPGet(apiURL) + if err != nil { + p.ErrorCount++ + igor.Logf("[pricewatcher] tick=%d ERROR: http failed: %s (errors=%d)", + p.TickCount, err.Error(), p.ErrorCount) + return false + } + if status < 200 || status >= 300 { + p.ErrorCount++ + igor.Logf("[pricewatcher] tick=%d ERROR: http status %d (errors=%d)", + p.TickCount, status, p.ErrorCount) + return false + } + + btc := extractPrice(string(body), "bitcoin") + eth := extractPrice(string(body), "ethereum") + + if btc == 0 && eth == 0 { + p.ErrorCount++ + igor.Logf("[pricewatcher] tick=%d ERROR: could not parse prices (errors=%d)", + p.TickCount, p.ErrorCount) + return false + } + + p.ObservationCount++ + + if btc > 0 { + p.BTCLatest = btc + if p.BTCHigh == 0 || btc > p.BTCHigh { + p.BTCHigh = btc + } + if p.BTCLow == 0 || btc < p.BTCLow { + p.BTCLow = btc + } + } + + if eth > 0 { + p.ETHLatest = eth + if p.ETHHigh == 0 || eth > p.ETHHigh { + p.ETHHigh = eth + } + if p.ETHLow == 0 || eth < p.ETHLow { + p.ETHLow = eth + } + } + + igor.Logf("[pricewatcher] tick=%d age=%ds obs=%d FETCH | BTC=$%d.%02d (high=$%d.%02d low=$%d.%02d) | ETH=$%d.%02d (high=$%d.%02d low=$%d.%02d)", + p.TickCount, ageSec, p.ObservationCount, + p.BTCLatest/100, p.BTCLatest%100, p.BTCHigh/100, p.BTCHigh%100, p.BTCLow/100, p.BTCLow%100, + p.ETHLatest/100, p.ETHLatest%100, p.ETHHigh/100, p.ETHHigh%100, p.ETHLow/100, p.ETHLow%100) + } else if p.TickCount%5 == 0 { + // Log a heartbeat every 5 ticks with cached prices. + igor.Logf("[pricewatcher] tick=%d age=%ds obs=%d | BTC=$%d.%02d | ETH=$%d.%02d", + p.TickCount, ageSec, p.ObservationCount, + p.BTCLatest/100, p.BTCLatest%100, + p.ETHLatest/100, p.ETHLatest%100) + } + + if p.TickCount%10 == 0 { + igor.Logf("[pricewatcher] MILESTONE: %d observations across %ds — this agent remembers everything", + p.ObservationCount, ageSec) + } + + return false +} + +func (p *PriceWatcher) Marshal() []byte { + return igor.NewEncoder(stateSize). + Uint64(p.TickCount). + Int64(p.BirthNano). + Int64(p.LastNano). + Uint64(p.BTCLatest). + Uint64(p.BTCHigh). + Uint64(p.BTCLow). + Uint64(p.ETHLatest). + Uint64(p.ETHHigh). + Uint64(p.ETHLow). + Uint32(p.ObservationCount). + Uint32(p.ErrorCount). + Finish() +} + +func (p *PriceWatcher) Unmarshal(data []byte) { + d := igor.NewDecoder(data) + p.TickCount = d.Uint64() + p.BirthNano = d.Int64() + p.LastNano = d.Int64() + p.BTCLatest = d.Uint64() + p.BTCHigh = d.Uint64() + p.BTCLow = d.Uint64() + p.ETHLatest = d.Uint64() + p.ETHHigh = d.Uint64() + p.ETHLow = d.Uint64() + p.ObservationCount = d.Uint32() + p.ErrorCount = d.Uint32() + if err := d.Err(); err != nil { + panic("unmarshal checkpoint: " + err.Error()) + } +} + +// extractPrice parses a CoinGecko simple/price JSON response to find +// the USD price for a given coin. Returns price in cents (USD × 100). +// Uses simple string scanning — no encoding/json needed (TinyGo-safe). +// +// Expected format: {"bitcoin":{"usd":83456.78},"ethereum":{"usd":1923.45}} +func extractPrice(body, coin string) uint64 { + // Find "bitcoin":{"usd": or "ethereum":{"usd": + key := `"` + coin + `":{"usd":` + idx := indexOf(body, key) + if idx < 0 { + return 0 + } + + // Skip past the key to the number. + start := idx + len(key) + if start >= len(body) { + return 0 + } + + // Parse the number (integer part and optional decimal). + var whole uint64 + var frac uint64 + var fracDigits int + inFrac := false + i := start + + // Skip whitespace. + for i < len(body) && body[i] == ' ' { + i++ + } + + for i < len(body) { + c := body[i] + if c >= '0' && c <= '9' { + if inFrac { + if fracDigits < 2 { + frac = frac*10 + uint64(c-'0') + fracDigits++ + } + // Skip additional decimal digits. + } else { + whole = whole*10 + uint64(c-'0') + } + } else if c == '.' && !inFrac { + inFrac = true + } else { + break + } + i++ + } + + // Pad fractional part to 2 digits. + for fracDigits < 2 { + frac *= 10 + fracDigits++ + } + + return whole*100 + frac +} + +// indexOf returns the index of the first occurrence of needle in s, or -1. +func indexOf(s, needle string) int { + if len(needle) > len(s) { + return -1 + } + for i := 0; i <= len(s)-len(needle); i++ { + match := true + for j := 0; j < len(needle); j++ { + if s[i+j] != needle[j] { + match = false + break + } + } + if match { + return i + } + } + return -1 +} + +func init() { igor.Run(&PriceWatcher{}) } +func main() {} diff --git a/internal/agent/instance_test.go b/internal/agent/instance_test.go index 5a8fee5..d16011f 100644 --- a/internal/agent/instance_test.go +++ b/internal/agent/instance_test.go @@ -14,6 +14,7 @@ import ( "testing" "time" + "github.com/simonovic86/igor/internal/config" "github.com/simonovic86/igor/internal/eventlog" "github.com/simonovic86/igor/internal/runtime" "github.com/simonovic86/igor/internal/storage" @@ -930,8 +931,8 @@ func TestTick_TimeoutEnforcement(t *testing.T) { } t.Logf("Tick timed out after %v with error: %v", elapsed, err) - // Should complete within a reasonable bound (timeout is 100ms, allow up to 1s for CI) - if elapsed > 1*time.Second { - t.Fatalf("tick took too long: %v (expected < 1s)", elapsed) + // Should complete within a reasonable bound (allow generous margin for CI) + if elapsed > config.TickTimeout+5*time.Second { + t.Fatalf("tick took too long: %v (expected < %v)", elapsed, config.TickTimeout+5*time.Second) } } diff --git a/internal/config/config.go b/internal/config/config.go index fbb8f19..a284481 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,7 +11,8 @@ import ( // TickTimeout is the maximum duration for a single agent tick. // Used by agent execution, replay verification, and the simulator. -const TickTimeout = 100 * time.Millisecond +// Set to 15s to accommodate agents making HTTP requests during ticks. +const TickTimeout = 15 * time.Second // Config holds the runtime configuration for an Igor node. type Config struct { diff --git a/internal/replay/engine_test.go b/internal/replay/engine_test.go index 3b96b29..1371d4d 100644 --- a/internal/replay/engine_test.go +++ b/internal/replay/engine_test.go @@ -697,9 +697,9 @@ func TestReplayTick_Timeout(t *testing.T) { } t.Logf("Replay tick timed out after %v with error: %v", elapsed, result.Error) - // Should complete within a reasonable bound (timeout is 100ms, allow up to 1s for CI) - if elapsed > 1*time.Second { - t.Fatalf("replay took too long: %v (expected < 1s)", elapsed) + // Should complete within a reasonable bound (allow generous margin for CI) + if elapsed > replayTickTimeout+5*time.Second { + t.Fatalf("replay took too long: %v (expected < %v)", elapsed, replayTickTimeout+5*time.Second) } } diff --git a/scripts/demo-pricewatcher.sh b/scripts/demo-pricewatcher.sh new file mode 100755 index 0000000..df9c6c8 --- /dev/null +++ b/scripts/demo-pricewatcher.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# demo-pricewatcher.sh — Demonstrate a portable agent that watches crypto prices. +# +# This agent fetches BTC and ETH prices from CoinGecko on every tick, +# tracks all-time high/low, and accumulates observations. When stopped +# and resumed on a different "machine", it continues with its full +# price history intact — same DID, same memory, cryptographically verified. +# +# Flow: +# 1. Run pricewatcher on "Machine A" +# 2. Let it fetch prices for several ticks +# 3. Stop it, copy checkpoint to "Machine B" +# 4. Resume — continuous observation count, same DID +# 5. Verify cryptographic lineage across both machines +set -euo pipefail + +IGORD="./bin/igord" +WASM="./agents/pricewatcher/agent.wasm" +DIR_A="/tmp/igor-demo-prices-a" +DIR_B="/tmp/igor-demo-prices-b" + +# Cleanup from previous runs. +rm -rf "$DIR_A" "$DIR_B" +mkdir -p "$DIR_A" "$DIR_B" + +echo "" +echo "==========================================" +echo " Igor: Price Watcher Demo" +echo " Portable agent with real-world memory" +echo "==========================================" +echo "" + +# --- Machine A --- +echo "[Machine A] Starting price watcher agent..." +echo "[Machine A] Fetching BTC & ETH prices from CoinGecko..." +echo "" +$IGORD run --budget 100.0 --checkpoint-dir "$DIR_A" --agent-id pricewatcher "$WASM" & +PID_A=$! + +# Let it fetch prices for ~15 seconds. +sleep 15 + +echo "" +echo "[Machine A] Stopping agent (sending SIGINT)..." +kill -INT "$PID_A" 2>/dev/null || true +wait "$PID_A" 2>/dev/null || true + +echo "" +echo "[Machine A] Checkpoint saved. Agent's memory preserved." +echo "" + +# --- Copy to Machine B --- +CKPT_FILE="$DIR_A/pricewatcher.checkpoint" +if [ ! -f "$CKPT_FILE" ]; then + echo "ERROR: Checkpoint file not found at $CKPT_FILE" + exit 1 +fi + +echo "==========================================" +echo " Simulating Transfer to Machine B" +echo "==========================================" +echo "" +echo "[Transfer] Copying checkpoint + identity to Machine B..." +cp "$CKPT_FILE" "$DIR_B/" +if [ -f "$DIR_A/pricewatcher.identity" ]; then + cp "$DIR_A/pricewatcher.identity" "$DIR_B/" +fi +echo "[Transfer] Done. The checkpoint IS the agent." +echo "" + +# --- Machine B --- +echo "==========================================" +echo " Machine B: Resuming Agent" +echo "==========================================" +echo "" +echo "[Machine B] Resuming price watcher from checkpoint..." +echo "[Machine B] Same DID, continuous observation count, all price history intact." +echo "" +$IGORD resume --checkpoint "$DIR_B/pricewatcher.checkpoint" --wasm "$WASM" --checkpoint-dir "$DIR_B" --agent-id pricewatcher & +PID_B=$! + +# Let it fetch more prices for ~10 seconds. +sleep 10 + +echo "" +echo "[Machine B] Stopping agent..." +kill -INT "$PID_B" 2>/dev/null || true +wait "$PID_B" 2>/dev/null || true + +echo "" + +# --- Verify Lineage --- +VERIFY_DIR="/tmp/igor-demo-prices-verify" +rm -rf "$VERIFY_DIR" +mkdir -p "$VERIFY_DIR" + +if [ -d "$DIR_A/history/pricewatcher" ]; then + cp "$DIR_A/history/pricewatcher/"*.ckpt "$VERIFY_DIR/" 2>/dev/null || true +fi +if [ -d "$DIR_B/history/pricewatcher" ]; then + cp "$DIR_B/history/pricewatcher/"*.ckpt "$VERIFY_DIR/" 2>/dev/null || true +fi + +CKPT_COUNT=$(ls "$VERIFY_DIR/"*.ckpt 2>/dev/null | wc -l | tr -d ' ') +echo "==========================================" +echo " Lineage Verification ($CKPT_COUNT checkpoints)" +echo "==========================================" +echo "" + +if [ "$CKPT_COUNT" -gt 0 ]; then + $IGORD verify "$VERIFY_DIR" +else + echo "No checkpoint history found for verification." + echo "(Agent may not have run long enough to produce history.)" +fi + +echo "" +echo "==========================================" +echo " Demo Complete" +echo "==========================================" +echo "" +echo "What you just saw:" +echo " - An agent fetching real crypto prices from CoinGecko" +echo " - Stopped on Machine A, copied to Machine B" +echo " - Resumed with full price history + same DID identity" +echo " - Cryptographic lineage verified across both machines" +echo " - The checkpoint IS the agent. Copy it anywhere." +echo "" + +# Cleanup. +rm -rf "$DIR_A" "$DIR_B" "$VERIFY_DIR"