Skip to content

Commit e0c04bd

Browse files
simonovic86claude
andauthored
feat(agent): add price watcher demo agent with HTTP hostcall (#25)
Add a new demo agent that fetches BTC/ETH prices from CoinGecko via the HTTP hostcall, tracking high/low/latest across checkpoint/resume cycles. Increase tick timeout from 100ms to 15s to accommodate HTTP requests. Includes build targets, demo script, and updated docs. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1ed7cd9 commit e0c04bd

10 files changed

Lines changed: 431 additions & 13 deletions

File tree

CLAUDE.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,18 @@ Igor is the runtime for portable, immortal software agents. The checkpoint file
1515
```bash
1616
make bootstrap # Install toolchain (Go, golangci-lint, goimports, TinyGo)
1717
make build # Build igord → bin/igord
18-
make agent # Build example WASM agent → agents/example/agent.wasm
19-
make agent-heartbeat # Build heartbeat WASM agent → agents/heartbeat/agent.wasm
18+
make agent # Build example WASM agent → agents/example/agent.wasm
19+
make agent-heartbeat # Build heartbeat WASM agent → agents/heartbeat/agent.wasm
20+
make agent-pricewatcher # Build price watcher WASM agent → agents/pricewatcher/agent.wasm
2021
make test # Run tests: go test -v ./...
2122
make lint # golangci-lint (5m timeout)
2223
make vet # go vet
2324
make fmt # gofmt + goimports
2425
make check # fmt-check + vet + lint + test (same as precommit)
2526
make run-agent # Build + run example agent with budget 1.0
26-
make demo # Build + run bridge reconciliation demo
27-
make demo-portable # Build + run portable agent demo (run → stop → copy → resume → verify)
27+
make demo # Build + run bridge reconciliation demo
28+
make demo-portable # Build + run portable agent demo (run → stop → copy → resume → verify)
29+
make demo-pricewatcher # Build + run price watcher demo (fetch prices → stop → resume → verify)
2830
make clean # Remove bin/, checkpoints/, agent.wasm
2931
```
3032

@@ -47,7 +49,7 @@ Legacy mode (P2P/migration):
4749
## Architecture
4850

4951
### Execution model
50-
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.
52+
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).
5153

5254
### Checkpoint format (binary, little-endian)
5355
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
7981
- `pkg/receipt/` — Payment receipt data structure, Ed25519 signing, binary serialization
8082
- `sdk/igor/` — Agent SDK: hostcall wrappers (ClockNow, RandBytes, Log, WalletBalance), lifecycle plumbing (Agent interface), Encoder/Decoder with Raw/FixedBytes/ReadInto for checkpoint serialization
8183
- `agents/heartbeat/` — Demo agent: logs heartbeat with tick count and age, milestones every 10 ticks
84+
- `agents/pricewatcher/` — Demo agent: fetches BTC/ETH prices from CoinGecko, tracks high/low/latest across checkpoint/resume
8285
- `agents/example/` — Original demo agent (Survivor) from research phases
8386
- `scripts/demo-portable.sh` — End-to-end portable agent demo
8487

Makefile

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.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
1+
.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
22

33
.DEFAULT_GOAL := help
44

@@ -8,6 +8,7 @@ BINARY_DIR := bin
88
AGENT_DIR := agents/example
99
HEARTBEAT_AGENT_DIR := agents/heartbeat
1010
RECONCILIATION_AGENT_DIR := agents/reconciliation
11+
PRICEWATCHER_AGENT_DIR := agents/pricewatcher
1112

1213
# Go commands
1314
GOCMD := go
@@ -54,6 +55,7 @@ clean: ## Remove build artifacts
5455
rm -f agents/example/agent.wasm.checkpoint
5556
rm -f agents/heartbeat/agent.wasm
5657
rm -f agents/reconciliation/agent.wasm
58+
rm -f agents/pricewatcher/agent.wasm
5759
@echo "Clean complete"
5860

5961
test: ## Run tests (with race detector)
@@ -118,6 +120,13 @@ agent-reconciliation: ## Build reconciliation agent WASM
118120
cd $(RECONCILIATION_AGENT_DIR) && $(MAKE) build
119121
@echo "Agent built: $(RECONCILIATION_AGENT_DIR)/agent.wasm"
120122

123+
agent-pricewatcher: ## Build price watcher demo agent WASM
124+
@echo "Building pricewatcher agent..."
125+
@which tinygo > /dev/null || \
126+
(echo "tinygo not found. See docs/governance/DEVELOPMENT.md for installation" && exit 1)
127+
cd $(PRICEWATCHER_AGENT_DIR) && $(MAKE) build
128+
@echo "Agent built: $(PRICEWATCHER_AGENT_DIR)/agent.wasm"
129+
121130
demo: build agent-reconciliation ## Build and run reconciliation demo
122131
@echo "Building demo runner..."
123132
@mkdir -p $(BINARY_DIR)
@@ -130,6 +139,11 @@ demo-portable: build agent-heartbeat ## Run the portable agent demo (run, stop,
130139
@chmod +x scripts/demo-portable.sh
131140
@./scripts/demo-portable.sh
132141

142+
demo-pricewatcher: build agent-pricewatcher ## Run the price watcher demo (fetch prices, stop, resume, verify)
143+
@echo "Running Price Watcher Demo..."
144+
@chmod +x scripts/demo-pricewatcher.sh
145+
@./scripts/demo-pricewatcher.sh
146+
133147
check: fmt-check vet lint test ## Run all checks (formatting, vet, lint, tests)
134148
@echo "All checks passed"
135149

agents/pricewatcher/Makefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.PHONY: build clean
2+
3+
build:
4+
tinygo build -target=wasi -no-debug -o agent.wasm .
5+
6+
clean:
7+
rm -f agent.wasm
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
{
2+
"capabilities": {
3+
"clock": { "version": 1 },
4+
"log": { "version": 1 },
5+
"http": {
6+
"version": 1,
7+
"options": {
8+
"allowed_hosts": ["api.coingecko.com"],
9+
"timeout_ms": 10000,
10+
"max_response_bytes": 4096
11+
}
12+
}
13+
},
14+
"resource_limits": {
15+
"max_memory_bytes": 67108864
16+
}
17+
}

agents/pricewatcher/go.mod

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
module github.com/simonovic86/igor/agents/pricewatcher
2+
3+
go 1.24
4+
5+
require github.com/simonovic86/igor/sdk/igor v0.0.0
6+
7+
replace github.com/simonovic86/igor/sdk/igor => ../../sdk/igor

agents/pricewatcher/main.go

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
//go:build tinygo || wasip1
4+
5+
package main
6+
7+
import (
8+
"github.com/simonovic86/igor/sdk/igor"
9+
)
10+
11+
// stateSize is the fixed checkpoint size in bytes:
12+
// TickCount(8) + BirthNano(8) + LastNano(8) +
13+
// BTCLatest(8) + BTCHigh(8) + BTCLow(8) +
14+
// ETHLatest(8) + ETHHigh(8) + ETHLow(8) +
15+
// ObservationCount(4) + ErrorCount(4)
16+
const stateSize = 80
17+
18+
const apiURL = "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,ethereum&vs_currencies=usd"
19+
20+
// fetchInterval is how many ticks between API calls (CoinGecko free tier: ~30 req/min).
21+
const fetchInterval = 10
22+
23+
// PriceWatcher fetches BTC and ETH prices from CoinGecko on every tick,
24+
// tracks all-time high/low, and logs price updates. All state survives
25+
// checkpoint/resume across machines — proving portable, immortal agents
26+
// that accumulate real-world knowledge over time.
27+
type PriceWatcher struct {
28+
TickCount uint64
29+
BirthNano int64
30+
LastNano int64
31+
BTCLatest uint64 // price in millicents (USD × 100)
32+
BTCHigh uint64
33+
BTCLow uint64
34+
ETHLatest uint64
35+
ETHHigh uint64
36+
ETHLow uint64
37+
ObservationCount uint32
38+
ErrorCount uint32
39+
}
40+
41+
func (p *PriceWatcher) Init() {}
42+
43+
func (p *PriceWatcher) Tick() bool {
44+
p.TickCount++
45+
46+
now := igor.ClockNow()
47+
if p.BirthNano == 0 {
48+
p.BirthNano = now
49+
}
50+
p.LastNano = now
51+
52+
ageSec := (p.LastNano - p.BirthNano) / 1_000_000_000
53+
54+
// Fetch prices on tick 1 and every fetchInterval ticks after that.
55+
if p.TickCount == 1 || p.TickCount%fetchInterval == 0 {
56+
status, body, err := igor.HTTPGet(apiURL)
57+
if err != nil {
58+
p.ErrorCount++
59+
igor.Logf("[pricewatcher] tick=%d ERROR: http failed: %s (errors=%d)",
60+
p.TickCount, err.Error(), p.ErrorCount)
61+
return false
62+
}
63+
if status < 200 || status >= 300 {
64+
p.ErrorCount++
65+
igor.Logf("[pricewatcher] tick=%d ERROR: http status %d (errors=%d)",
66+
p.TickCount, status, p.ErrorCount)
67+
return false
68+
}
69+
70+
btc := extractPrice(string(body), "bitcoin")
71+
eth := extractPrice(string(body), "ethereum")
72+
73+
if btc == 0 && eth == 0 {
74+
p.ErrorCount++
75+
igor.Logf("[pricewatcher] tick=%d ERROR: could not parse prices (errors=%d)",
76+
p.TickCount, p.ErrorCount)
77+
return false
78+
}
79+
80+
p.ObservationCount++
81+
82+
if btc > 0 {
83+
p.BTCLatest = btc
84+
if p.BTCHigh == 0 || btc > p.BTCHigh {
85+
p.BTCHigh = btc
86+
}
87+
if p.BTCLow == 0 || btc < p.BTCLow {
88+
p.BTCLow = btc
89+
}
90+
}
91+
92+
if eth > 0 {
93+
p.ETHLatest = eth
94+
if p.ETHHigh == 0 || eth > p.ETHHigh {
95+
p.ETHHigh = eth
96+
}
97+
if p.ETHLow == 0 || eth < p.ETHLow {
98+
p.ETHLow = eth
99+
}
100+
}
101+
102+
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)",
103+
p.TickCount, ageSec, p.ObservationCount,
104+
p.BTCLatest/100, p.BTCLatest%100, p.BTCHigh/100, p.BTCHigh%100, p.BTCLow/100, p.BTCLow%100,
105+
p.ETHLatest/100, p.ETHLatest%100, p.ETHHigh/100, p.ETHHigh%100, p.ETHLow/100, p.ETHLow%100)
106+
} else if p.TickCount%5 == 0 {
107+
// Log a heartbeat every 5 ticks with cached prices.
108+
igor.Logf("[pricewatcher] tick=%d age=%ds obs=%d | BTC=$%d.%02d | ETH=$%d.%02d",
109+
p.TickCount, ageSec, p.ObservationCount,
110+
p.BTCLatest/100, p.BTCLatest%100,
111+
p.ETHLatest/100, p.ETHLatest%100)
112+
}
113+
114+
if p.TickCount%10 == 0 {
115+
igor.Logf("[pricewatcher] MILESTONE: %d observations across %ds — this agent remembers everything",
116+
p.ObservationCount, ageSec)
117+
}
118+
119+
return false
120+
}
121+
122+
func (p *PriceWatcher) Marshal() []byte {
123+
return igor.NewEncoder(stateSize).
124+
Uint64(p.TickCount).
125+
Int64(p.BirthNano).
126+
Int64(p.LastNano).
127+
Uint64(p.BTCLatest).
128+
Uint64(p.BTCHigh).
129+
Uint64(p.BTCLow).
130+
Uint64(p.ETHLatest).
131+
Uint64(p.ETHHigh).
132+
Uint64(p.ETHLow).
133+
Uint32(p.ObservationCount).
134+
Uint32(p.ErrorCount).
135+
Finish()
136+
}
137+
138+
func (p *PriceWatcher) Unmarshal(data []byte) {
139+
d := igor.NewDecoder(data)
140+
p.TickCount = d.Uint64()
141+
p.BirthNano = d.Int64()
142+
p.LastNano = d.Int64()
143+
p.BTCLatest = d.Uint64()
144+
p.BTCHigh = d.Uint64()
145+
p.BTCLow = d.Uint64()
146+
p.ETHLatest = d.Uint64()
147+
p.ETHHigh = d.Uint64()
148+
p.ETHLow = d.Uint64()
149+
p.ObservationCount = d.Uint32()
150+
p.ErrorCount = d.Uint32()
151+
if err := d.Err(); err != nil {
152+
panic("unmarshal checkpoint: " + err.Error())
153+
}
154+
}
155+
156+
// extractPrice parses a CoinGecko simple/price JSON response to find
157+
// the USD price for a given coin. Returns price in cents (USD × 100).
158+
// Uses simple string scanning — no encoding/json needed (TinyGo-safe).
159+
//
160+
// Expected format: {"bitcoin":{"usd":83456.78},"ethereum":{"usd":1923.45}}
161+
func extractPrice(body, coin string) uint64 {
162+
// Find "bitcoin":{"usd": or "ethereum":{"usd":
163+
key := `"` + coin + `":{"usd":`
164+
idx := indexOf(body, key)
165+
if idx < 0 {
166+
return 0
167+
}
168+
169+
// Skip past the key to the number.
170+
start := idx + len(key)
171+
if start >= len(body) {
172+
return 0
173+
}
174+
175+
// Parse the number (integer part and optional decimal).
176+
var whole uint64
177+
var frac uint64
178+
var fracDigits int
179+
inFrac := false
180+
i := start
181+
182+
// Skip whitespace.
183+
for i < len(body) && body[i] == ' ' {
184+
i++
185+
}
186+
187+
for i < len(body) {
188+
c := body[i]
189+
if c >= '0' && c <= '9' {
190+
if inFrac {
191+
if fracDigits < 2 {
192+
frac = frac*10 + uint64(c-'0')
193+
fracDigits++
194+
}
195+
// Skip additional decimal digits.
196+
} else {
197+
whole = whole*10 + uint64(c-'0')
198+
}
199+
} else if c == '.' && !inFrac {
200+
inFrac = true
201+
} else {
202+
break
203+
}
204+
i++
205+
}
206+
207+
// Pad fractional part to 2 digits.
208+
for fracDigits < 2 {
209+
frac *= 10
210+
fracDigits++
211+
}
212+
213+
return whole*100 + frac
214+
}
215+
216+
// indexOf returns the index of the first occurrence of needle in s, or -1.
217+
func indexOf(s, needle string) int {
218+
if len(needle) > len(s) {
219+
return -1
220+
}
221+
for i := 0; i <= len(s)-len(needle); i++ {
222+
match := true
223+
for j := 0; j < len(needle); j++ {
224+
if s[i+j] != needle[j] {
225+
match = false
226+
break
227+
}
228+
}
229+
if match {
230+
return i
231+
}
232+
}
233+
return -1
234+
}
235+
236+
func init() { igor.Run(&PriceWatcher{}) }
237+
func main() {}

internal/agent/instance_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"testing"
1515
"time"
1616

17+
"github.com/simonovic86/igor/internal/config"
1718
"github.com/simonovic86/igor/internal/eventlog"
1819
"github.com/simonovic86/igor/internal/runtime"
1920
"github.com/simonovic86/igor/internal/storage"
@@ -930,8 +931,8 @@ func TestTick_TimeoutEnforcement(t *testing.T) {
930931
}
931932
t.Logf("Tick timed out after %v with error: %v", elapsed, err)
932933

933-
// Should complete within a reasonable bound (timeout is 100ms, allow up to 1s for CI)
934-
if elapsed > 1*time.Second {
935-
t.Fatalf("tick took too long: %v (expected < 1s)", elapsed)
934+
// Should complete within a reasonable bound (allow generous margin for CI)
935+
if elapsed > config.TickTimeout+5*time.Second {
936+
t.Fatalf("tick took too long: %v (expected < %v)", elapsed, config.TickTimeout+5*time.Second)
936937
}
937938
}

internal/config/config.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ import (
1111

1212
// TickTimeout is the maximum duration for a single agent tick.
1313
// Used by agent execution, replay verification, and the simulator.
14-
const TickTimeout = 100 * time.Millisecond
14+
// Set to 15s to accommodate agents making HTTP requests during ticks.
15+
const TickTimeout = 15 * time.Second
1516

1617
// Config holds the runtime configuration for an Igor node.
1718
type Config struct {

0 commit comments

Comments
 (0)