Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```

Expand All @@ -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).
Expand Down Expand Up @@ -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

Expand Down
16 changes: 15 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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"

Expand Down
7 changes: 7 additions & 0 deletions agents/pricewatcher/Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.PHONY: build clean

build:
tinygo build -target=wasi -no-debug -o agent.wasm .

clean:
rm -f agent.wasm
17 changes: 17 additions & 0 deletions agents/pricewatcher/agent.manifest.json
Original file line number Diff line number Diff line change
@@ -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
}
}
7 changes: 7 additions & 0 deletions agents/pricewatcher/go.mod
Original file line number Diff line number Diff line change
@@ -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
237 changes: 237 additions & 0 deletions agents/pricewatcher/main.go
Original file line number Diff line number Diff line change
@@ -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() {}
7 changes: 4 additions & 3 deletions internal/agent/instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
}
3 changes: 2 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep default tick timeout low for non-HTTP paths

Raising config.TickTimeout to 15s makes every hung agent_tick block far longer across normal execution, replay, and simulation (internal/agent/instance.go:400, internal/replay/engine.go:163, internal/simulator/simulator.go:245). In practice, any buggy or malicious agent that spins or deadlocks can now occupy execution for up to 15s per tick, which weakens the existing timeout safeguard and noticeably hurts liveness for agents that do not perform HTTP calls. A safer approach is to keep the global tick bound short and apply longer limits only where HTTP work is actually needed.

Useful? React with 👍 / 👎.


// Config holds the runtime configuration for an Igor node.
type Config struct {
Expand Down
Loading
Loading