diff --git a/.env.example b/.env.example index 1cf3af4..5e2d105 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,17 @@ DD_API_KEY= DD_AGENT_HOST=localhost DD_AGENT_PORT=8126 +# Agent behaviour +MAX_DAILY_USD=10 +MAX_AGENT_TURNS=10 + +# Notifications — POST purchase events to this URL (optional) +WEBHOOK_URL= + +# Scheduled runs — JSON array of prompts, interval in hours (optional) +SCHEDULED_PROMPTS=[] +SCHEDULE_INTERVAL_HOURS=24 + # Circle Programmable Wallet CIRCLE_API_KEY= CIRCLE_ENTITY_SECRET= diff --git a/README.md b/README.md index 74e8c1f..38316c2 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,21 @@ An autonomous Web3 shopping agent. Give it a prompt, it searches the web, picks ### 💸 Web3 Payments & Safety - **Circle Programmable Wallets**: Uses **Circle's Developer-Controlled Wallets** on ARC-TESTNET. The agent holds its own USDC balance and signs transactions server-side via Circle's API — no private key management required. - **USDC on ARC-TESTNET**: Facilitates real-world value transfer using stablecoins on ARC-TESTNET, with the agent wallet pre-funded with 20 USDC. -- **Spend Guard**: A hard-coded safety mechanism that enforces a **$10/day spending limit**. This prevents the agent from runaway spending in the event of an infinite loop or adversarial prompt. +- **Spend Guard**: A configurable daily spending limit (default $10, set via `MAX_DAILY_USD`). Backed by ClickHouse so it persists across restarts. Prevents runaway spending from infinite loops or adversarial prompts. +- **Wallet Balance Check**: Verifies on-chain USDC balance before submitting a payment. Fails fast with a clear error rather than letting the Circle API reject mid-flight. +- **Max Turns Guard**: The agentic loop is capped at `MAX_AGENT_TURNS` (default 10) iterations. If Claude loops without completing, the run aborts and logs an error instead of burning API credits indefinitely. +- **Dry-Run Mode**: Pass `--dry-run` (or `npm run dry-run`) to simulate a full agent run — search and evaluate without executing any payment or publishing any receipt. Useful for testing prompts and budget planning. - **x402 Micropayment Protocol**: Implements a local "Payment Required" middleware. The agent handles `402` status codes by paying the required fee on-chain and retrying the request with a verifiable payment proof header. ### 📊 Transparency & Observability - **Purchase Audit Log**: Every transaction is recorded in **ClickHouse Cloud**, capturing the original user query, selected product, price, and the immutable blockchain transaction hash. - **Verified Receipts (cited.md)**: Automatically publishes public, markdown-formatted receipts via the **Senso platform**. These receipts are "citeable," making the agent's actions discoverable by search engines and other AI agents. -- **Datadog Instrumentation**: Full observability with Datadog APM. Tracks end-to-end agent run durations, per-tool execution spans, and custom metrics for payment success rates and on-chain confirmation times. +- **Datadog APM + Lapdog**: Full observability with Datadog APM. Tracks end-to-end agent run durations, per-tool execution spans, and custom metrics for payment success rates and on-chain confirmation times. In development, use **lapdog** for a local live dashboard showing every Claude API call with token counts, cost, cache hit rates, and tool traces — no Datadog account required. - **GEO Monitoring**: Integrated AI brand visibility tracking. Monitors how major LLMs (ChatGPT, Claude, Perplexity, Gemini) perceive and represent the "Shop3" brand across the web. +- **Webhook Notifications**: Set `WEBHOOK_URL` to receive a POST on every completed (or dry-run) purchase with product, price, tx hash, and receipt URL. +- **Scheduled Runs**: `npm run schedule` runs a configurable list of prompts on a repeat interval (set via `SCHEDULED_PROMPTS` and `SCHEDULE_INTERVAL_HOURS`). +- **Purchase Memory**: The agent can call `check_purchase_history` before buying to avoid repurchasing the same product across separate runs. +- **Payment Replay Protection**: The x402 search middleware tracks used transaction hashes. A proof that has already unlocked a search result cannot be reused. ## Quick start @@ -63,7 +70,20 @@ node history.js # last 10 purchases node history.js 25 # last N purchases ``` -6. (Optional) Run the local search middleware (x402 payment-gated search): +6. (Optional) Run with **lapdog** for a local LLM observability dashboard: + +```bash +# Install lapdog (one-time) +pip install ddapm-test-agent + +# Run agent with live token/cost/trace dashboard at lapdog.datadoghq.com +npm run lapdog + +# Or run the search server with lapdog +npm run lapdog:server +``` + +7. (Optional) Run the local search middleware (x402 payment-gated search): ```bash npm run start:server diff --git a/agent.js b/agent.js index b9972d9..f7bd688 100644 --- a/agent.js +++ b/agent.js @@ -1,10 +1,12 @@ const Anthropic = require('@anthropic-ai/sdk'); const { searchWeb } = require('./search'); const { mockPaymentFlow, getWalletAddress } = require('./payment'); -const { logPurchase } = require('./memory'); +const { logPurchase, getRecentPurchases } = require('./memory'); const { publishReceipt } = require('./publish'); -const { withSpan, increment, gauge, timing } = require('./telemetry'); +const { notifyPurchase } = require('./notify'); +const { withSpan, withLLMSpan, increment, gauge, timing } = require('./telemetry'); +const MAX_TURNS = parseInt(process.env.MAX_AGENT_TURNS) || 10; const client = new Anthropic(); const tools = [ @@ -22,7 +24,7 @@ const tools = [ }, { name: 'pay_for_purchase', - description: 'Pay for a selected product/service from the agent smart wallet using USDC on Base Sepolia. Enforces $10/day spending limit.', + description: 'Pay for a selected product/service from the agent smart wallet using USDC on ARC-TESTNET. Enforces a daily spending limit.', input_schema: { type: 'object', properties: { @@ -68,9 +70,20 @@ const tools = [ required: ['query', 'selected_result', 'price', 'tx_hash', 'source_url'], }, }, + { + name: 'check_purchase_history', + description: 'Check what Shop3 has already purchased. Use this before buying to avoid duplicates.', + input_schema: { + type: 'object', + properties: { + limit: { type: 'number', description: 'Number of recent purchases to retrieve (default 10)' }, + }, + required: [], + }, + }, ]; -async function executeTool(name, input, context) { +async function executeTool(name, input, context, dryRun) { switch (name) { case 'search_web': { console.log(`\n[agent] Searching: "${input.query}"`); @@ -87,6 +100,15 @@ async function executeTool(name, input, context) { } case 'pay_for_purchase': { + if (dryRun) { + console.log(`\n[agent] DRY RUN — would pay for: ${input.selected_result} (${input.price})`); + context.txHash = '0x0000000000000000000000000000000000000000000000000000000000000000'; + context.selectedResult = input.selected_result; + context.price = input.price; + context.sourceUrl = input.source_url; + increment('payment.dry_run'); + return { success: true, dry_run: true, tx_hash: context.txHash }; + } console.log(`\n[agent] Paying for: ${input.selected_result} (${input.price})`); const start = Date.now(); const txHash = await withSpan('agent.tool.pay_for_purchase', { @@ -116,6 +138,10 @@ async function executeTool(name, input, context) { } case 'publish_receipt': { + if (dryRun) { + console.log(`\n[agent] DRY RUN — skipping receipt publish for: ${input.selected_result}`); + return { success: true, dry_run: true, receipt_url: null }; + } console.log(`\n[agent] Publishing receipt to cited.md`); const url = await withSpan('agent.tool.publish_receipt', {}, () => publishReceipt({ @@ -131,55 +157,79 @@ async function executeTool(name, input, context) { return { success: true, receipt_url: url }; } + case 'check_purchase_history': { + const purchases = await withSpan('agent.tool.check_purchase_history', {}, () => + getRecentPurchases(input.limit ?? 10) + ); + if (purchases.length === 0) { + return { purchases: [], message: 'No purchases yet.' }; + } + return { + purchases: purchases.map((p) => ({ + product: p.selected_result, + price: p.price, + when: p.timestamp, + tx_hash: p.tx_hash, + })), + }; + } + default: throw new Error(`Unknown tool: ${name}`); } } -async function runAgent(userPrompt) { +async function runAgent(userPrompt, { dryRun = false } = {}) { const runStart = Date.now(); increment('agent.run.started'); + if (dryRun) increment('agent.run.dry_run'); const walletAddress = await getWalletAddress(); console.log(`\n[agent] Smart wallet: ${walletAddress}`); + if (dryRun) console.log('[agent] DRY RUN MODE — no payments or receipts will be submitted'); console.log(`[agent] Starting: "${userPrompt}"\n`); const context = {}; - const messages = [ - { - role: 'user', - content: userPrompt, - }, - ]; + const messages = [{ role: 'user', content: userPrompt }]; - const systemPrompt = `You are an autonomous Web3 shopping agent. Your job is to: -1. Search the web to find the best option matching the user's request -2. Evaluate results and select the best one under the user's budget -3. Pay for it autonomously from your smart wallet (USDC on Base Sepolia testnet) -4. Log the purchase to the database -5. Publish a verified receipt + const systemPrompt = `You are Shop3, an autonomous Web3 shopping agent. Your job is to: +1. (Optional) Check purchase history to avoid buying duplicates +2. Search the web to find the best option matching the user's request +3. Evaluate results and select the best one under the user's budget +4. Pay for it autonomously from your smart wallet (USDC on ARC-TESTNET) +5. Log the purchase to the database +6. Publish a verified receipt Your smart wallet address is: ${walletAddress} -Daily spend limit: $10 USD (enforced on-chain) -Network: Base Sepolia (testnet) +Daily spend limit: $${parseFloat(process.env.MAX_DAILY_USD) || 10} USD (enforced on-chain) +Network: ARC-TESTNET Payment token: USDC +${dryRun ? '\nDRY RUN: You are in simulation mode. Payments will not be executed and receipts will not be published.' : ''} When selecting a result to buy, prefer options that are: - Under $10/month or one-time - Have clear pricing - Are reputable API services or products -Always complete all 4 steps: search → pay → log → publish. Do not stop early.`; +Always complete all steps: search → pay → log → publish. Do not stop early.`; + + let turns = 0; - // Agentic loop while (true) { - const response = await client.messages.create({ - model: 'claude-sonnet-4-6', - max_tokens: 4096, - system: systemPrompt, - tools, - messages, - }); + if (turns >= MAX_TURNS) { + throw new Error(`Agent exceeded maximum turn limit (${MAX_TURNS}). Aborting to prevent runaway loop.`); + } + turns++; + + const response = await withLLMSpan('claude-sonnet-4-6', () => + client.messages.create({ + model: 'claude-sonnet-4-6', + max_tokens: 4096, + system: systemPrompt, + tools, + messages, + }) + ); messages.push({ role: 'assistant', content: response.content }); @@ -190,8 +240,18 @@ Always complete all 4 steps: search → pay → log → publish. Do not stop ear .join('\n'); timing('agent.run.duration_ms', Date.now() - runStart); increment('agent.run.completed'); + gauge('agent.run.turns', turns); console.log('\n[agent] Done.\n'); console.log(finalText); + + await notifyPurchase({ + selectedResult: context.selectedResult, + price: context.price, + txHash: context.txHash, + receiptUrl: context.receiptUrl, + dryRun, + }); + return { summary: finalText, ...context }; } @@ -203,9 +263,10 @@ Always complete all 4 steps: search → pay → log → publish. Do not stop ear let result; try { - result = await executeTool(block.name, block.input, context); + result = await executeTool(block.name, block.input, context, dryRun); } catch (err) { console.error(`[agent] Tool error (${block.name}):`, err.message); + increment('agent.tool.error', { tool: block.name }); result = { error: err.message }; } diff --git a/index.js b/index.js index ee72893..3626758 100644 --- a/index.js +++ b/index.js @@ -20,22 +20,22 @@ if (missing.length > 0) { const { runAgent } = require('./agent'); -const prompt = process.argv.slice(2).join(' ') || 'Find me the best web data API subscription under $10 and buy it'; +const args = process.argv.slice(2); +const dryRun = args.includes('--dry-run'); +const promptArgs = args.filter((a) => a !== '--dry-run'); +const prompt = promptArgs.join(' ') || 'Find me the best web data API subscription under $10 and buy it'; console.log('='.repeat(60)); -console.log(' Valution Agent — Autonomous Web3 Shopping'); +console.log(' Shop3 — Autonomous Web3 Shopping Agent'); console.log('='.repeat(60)); +if (dryRun) console.log(' [DRY RUN MODE]'); console.log(`\nPrompt: "${prompt}"\n`); -runAgent(prompt) +runAgent(prompt, { dryRun }) .then((result) => { console.log('\n' + '='.repeat(60)); - if (result.receiptUrl) { - console.log(`\nReceipt: ${result.receiptUrl}`); - } - if (result.txHash) { - console.log(`Tx: ${result.txHash}`); - } + if (result.receiptUrl) console.log(`Receipt: ${result.receiptUrl}`); + if (result.txHash) console.log(`Tx: ${result.txHash}`); console.log('='.repeat(60)); }) .catch((err) => { diff --git a/notify.js b/notify.js new file mode 100644 index 0000000..70ad97d --- /dev/null +++ b/notify.js @@ -0,0 +1,24 @@ +const axios = require('axios'); + +async function notifyPurchase({ selectedResult, price, txHash, receiptUrl, dryRun = false }) { + const url = process.env.WEBHOOK_URL; + if (!url) return; + + const payload = { + event: dryRun ? 'shop3.dry_run' : 'shop3.purchase_complete', + product: selectedResult, + price, + tx_hash: txHash ?? null, + receipt_url: receiptUrl ?? null, + timestamp: new Date().toISOString(), + }; + + try { + await axios.post(url, payload, { timeout: 5000 }); + console.log(`[notify] Webhook delivered to ${url}`); + } catch (err) { + console.warn(`[notify] Webhook failed (non-fatal): ${err.message}`); + } +} + +module.exports = { notifyPurchase }; diff --git a/package.json b/package.json index eb4a735..ed66ff7 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,11 @@ "start:server": "node server.js", "history": "node history.js", "setup:geo": "node scripts/setup-geo.js", - "geo:status": "node scripts/geo-status.js" + "geo:status": "node scripts/geo-status.js", + "lapdog": "lapdog node index.js", + "lapdog:server": "lapdog node server.js", + "schedule": "node scripts/schedule.js", + "dry-run": "node index.js --dry-run" }, "keywords": [], "author": "", @@ -18,13 +22,10 @@ "@anthropic-ai/sdk": "^0.98.0", "@circle-fin/developer-controlled-wallets": "^10.3.1", "@clickhouse/client": "^1.18.5", - "@zerodev/ecdsa-validator": "^5.4.9", - "@zerodev/sdk": "^5.5.10", "axios": "^1.16.1", "dd-trace": "^5.104.0", "dotenv": "^17.4.2", "express": "^5.2.1", - "permissionless": "^0.3.5", "tslib": "^2.8.1", "viem": "^2.50.4" } diff --git a/payment.js b/payment.js index e1139b6..8cd7346 100644 --- a/payment.js +++ b/payment.js @@ -4,7 +4,7 @@ const axios = require('axios'); const { withSpan, increment, gauge, timing } = require('./telemetry'); const { getSpendToday, recordSpend } = require('./memory'); -const MAX_DAILY_USD = 10; +const MAX_DAILY_USD = parseFloat(process.env.MAX_DAILY_USD) || 10; function getCircleClient() { const apiKey = process.env.CIRCLE_API_KEY; @@ -30,6 +30,26 @@ async function checkSpendLimit(amountUSD) { } } +async function checkWalletBalance(client, amountUSD) { + try { + const res = await client.listWalletTokenBalances({ walletId: process.env.CIRCLE_WALLET_ID }); + const balances = res.data?.tokenBalances ?? []; + const usdc = balances.find( + (b) => b.token?.symbol === 'USDC' || + b.token?.tokenAddress?.toLowerCase() === process.env.USDC_TOKEN_ADDRESS?.toLowerCase() + ); + const available = parseFloat(usdc?.amount ?? '0'); + console.log(`[payment] Wallet USDC balance: $${available.toFixed(2)}`); + if (available < amountUSD) { + throw new Error(`Insufficient USDC balance: $${available.toFixed(2)} available, $${amountUSD} required`); + } + } catch (err) { + if (err.message.startsWith('Insufficient')) throw err; + // Balance check is best-effort — don't block payment if API call fails + console.warn(`[payment] Could not verify wallet balance (proceeding): ${err.message}`); + } +} + // Poll Circle until the transaction reaches a terminal state, return blockchain txHash async function waitForCircleTx(client, txId, timeoutMs = 120000) { const start = Date.now(); @@ -60,8 +80,9 @@ async function handle402Payment(paymentInfo) { const amountUSD = parseFloat(amount); await checkSpendLimit(amountUSD); - return withSpan('payment.transaction', { token, chain, amount }, async () => { + return withSpan('payment.transaction', { token, chain, 'payment.amount_usd': amountUSD }, async (span) => { const client = getCircleClient(); + await checkWalletBalance(client, amountUSD); let txId; try { @@ -75,9 +96,12 @@ async function handle402Payment(paymentInfo) { }); txId = res.data?.id; if (!txId) throw new Error('Circle did not return a transaction ID'); + span.setTag('payment.circle_tx_id', txId); increment('payment.tx.submitted', { token, chain }); console.log(`[payment] Circle tx submitted: ${txId}`); } catch (err) { + span.setTag('payment.status', 'submit_failed'); + span.setTag('error', true); increment('payment.tx.error', { token, chain, reason: 'submit_failed' }); throw err; } @@ -90,12 +114,18 @@ async function handle402Payment(paymentInfo) { const confirmMs = Date.now() - confirmStart; await recordSpend(amountUSD); const spentToday = await getSpendToday(); + span.setTag('payment.status', 'confirmed'); + span.setTag('payment.confirmation_ms', confirmMs); + span.setTag('payment.tx_hash', txHash); + span.setTag('payment.daily_spend_usd', spentToday); timing('payment.confirmation_ms', confirmMs, { token, chain }); gauge('payment.amount_usd', amountUSD, { token, chain }); gauge('payment.daily_spend_usd', spentToday); increment('payment.tx.confirmed', { token, chain }); console.log(`[payment] Confirmed: ${txHash}`); } catch (err) { + span.setTag('payment.status', 'confirmation_failed'); + span.setTag('error', true); increment('payment.tx.error', { token, chain, reason: 'confirmation_failed' }); throw err; } diff --git a/scripts/schedule.js b/scripts/schedule.js new file mode 100644 index 0000000..bb6f2e4 --- /dev/null +++ b/scripts/schedule.js @@ -0,0 +1,44 @@ +require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') }); + +const { runAgent } = require('../agent'); + +const PROMPTS = (() => { + try { + return JSON.parse(process.env.SCHEDULED_PROMPTS || '[]'); + } catch { + return []; + } +})(); + +const INTERVAL_HOURS = parseFloat(process.env.SCHEDULE_INTERVAL_HOURS) || 24; +const INTERVAL_MS = INTERVAL_HOURS * 60 * 60 * 1000; + +if (PROMPTS.length === 0) { + console.error('[schedule] No prompts found. Set SCHEDULED_PROMPTS in .env as a JSON array of strings.'); + console.error(' Example: SCHEDULED_PROMPTS=["Find me the best web data API under $10 and buy it"]'); + process.exit(1); +} + +console.log(`[schedule] Running ${PROMPTS.length} prompt(s) every ${INTERVAL_HOURS}h`); +PROMPTS.forEach((p, i) => console.log(` ${i + 1}. ${p}`)); + +async function runAll() { + const ts = new Date().toISOString(); + console.log(`\n[schedule] Firing at ${ts}`); + for (const prompt of PROMPTS) { + try { + console.log(`\n[schedule] → "${prompt}"`); + await runAgent(prompt); + } catch (err) { + console.error(`[schedule] Run failed for prompt "${prompt}": ${err.message}`); + } + } +} + +// Run immediately, then on interval +runAll().then(() => { + setInterval(runAll, INTERVAL_MS); +}).catch((err) => { + console.error('[schedule] Fatal error on first run:', err.message); + process.exit(1); +}); diff --git a/server.js b/server.js index 757c252..b1b27fd 100644 --- a/server.js +++ b/server.js @@ -26,6 +26,9 @@ async function verifyCirclePayment(txHash) { return tx?.state === 'CONFIRMED' || tx?.state === 'COMPLETE'; } +// In-memory replay guard — resets on restart, sufficient for testnet/dev use +const usedTxHashes = new Set(); + app.get('/', (req, res) => { res.json({ message: 'Search middleware running', version: '1.0.0' }); }); @@ -64,6 +67,10 @@ app.get('/search', async (req, res) => { }); } + if (usedTxHashes.has(paymentProof)) { + return res.status(402).json({ error: 'Payment proof already used' }); + } + try { const confirmed = await verifyCirclePayment(paymentProof); if (!confirmed) { @@ -73,6 +80,8 @@ app.get('/search', async (req, res) => { return res.status(402).json({ error: 'Could not verify payment transaction via Circle' }); } + usedTxHashes.add(paymentProof); + try { const numResults = Number(req.query.num_results) || 5; const results = await searchWeb(query, numResults); diff --git a/telemetry.js b/telemetry.js index 4ced718..26eef94 100644 --- a/telemetry.js +++ b/telemetry.js @@ -6,9 +6,27 @@ const tracer = require('dd-trace').init({ logInjection: true, }); -// Wrap an async fn in a named span, attaching arbitrary tags +// Wrap an async fn in a named span. The span is passed as the first arg to fn +// so callers can call span.setTag() to attach dynamic results. async function withSpan(name, tags, fn) { - return tracer.trace(name, { tags }, fn); + return tracer.trace(name, { tags }, (span) => fn(span)); +} + +// Wrap a Claude API call so lapdog captures it as an LLM span with token/cost metadata. +// usage = { input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens } +// model = e.g. 'claude-sonnet-4-6' +async function withLLMSpan(model, fn) { + return tracer.trace('claude.completion', { tags: { 'llm.provider': 'anthropic', 'llm.model': model } }, async (span) => { + const result = await fn(); + const usage = result?.usage; + if (usage && span) { + span.setTag('llm.usage.input_tokens', usage.input_tokens ?? 0); + span.setTag('llm.usage.output_tokens', usage.output_tokens ?? 0); + span.setTag('llm.usage.cache_read_tokens', usage.cache_read_input_tokens ?? 0); + span.setTag('llm.usage.cache_creation_tokens', usage.cache_creation_input_tokens ?? 0); + } + return result; + }); } // Increment a counter metric @@ -30,4 +48,4 @@ function tagsArray(obj) { return Object.entries(obj).map(([k, v]) => `${k}:${v}`); } -module.exports = { tracer, withSpan, increment, gauge, timing }; +module.exports = { tracer, withSpan, withLLMSpan, increment, gauge, timing };