diff --git a/.env.example b/.env.example index ed9b1bd..d57d604 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,73 @@ -# KeeperHub credentials -# Create an organization API key at https://app.keeperhub.com/settings/api-keys (Organisation tab). +# ─── KeeperHub credentials ──────────────────────────────────────────────── +# Create an organization API key: +# 1. Sign up / log in at https://keeperhub.com +# 2. Open or create an Organization (top-right menu) +# 3. Settings → API Keys → "Organisation" tab (NOT Personal — Personal +# keys can't execute workflows) +# 4. New API Key → copy the kh_… token KEEPERHUB_API_KEY=kh_your_org_api_key_here # Optional: override the default base URL. KEEPERHUB_BASE_URL=https://app.keeperhub.com/api -# Backend mode: "real" (default, requires KEEPERHUB_API_KEY) or "mock" (in-memory simulation). +# Backend mode: "real" (default, requires KEEPERHUB_API_KEY) or "mock" +# (in-memory simulation, useful for local dev and CI). KEEPERKIT_MODE=real -# LLM credentials for the demo server's example agent. -OPENAI_API_KEY=sk-your-openai-key +# ─── LLM credentials for the demo agent ────────────────────────────────── +# Set ONE of the following. Auto-detection priority (first match wins): +# anthropic → google → groq → deepseek → openrouter → mistral → together +# → ollama → openai +# Pin a specific provider with KEEPERKIT_LLM_PROVIDER=. +# Pin a specific model with KEEPERKIT_LLM_MODEL=. -# Optional: override the model used by the demo agent. -OPENAI_MODEL=gpt-4o-mini +# OpenAI — https://platform.openai.com/api-keys +OPENAI_API_KEY= +# Optional model override (default: gpt-4o-mini) +OPENAI_MODEL= +# Optional: point the OpenAI client at a custom OpenAI-compatible endpoint +# (LM Studio, vLLM, LiteLLM proxy, Azure OpenAI, etc.) +# OPENAI_BASE_URL=http://localhost:8000/v1 -# Demo server bind config. +# Anthropic Claude — https://console.anthropic.com/settings/keys +ANTHROPIC_API_KEY= +# Default model: claude-3-5-haiku-20241022 + +# Google Gemini — https://aistudio.google.com/app/apikey (free tier!) +GOOGLE_API_KEY= +# Default model: gemini-1.5-flash + +# Groq — https://console.groq.com/keys (FREE, very fast) +GROQ_API_KEY= +# Default model: llama-3.3-70b-versatile + +# DeepSeek — https://platform.deepseek.com/api_keys (cheap, good quality) +DEEPSEEK_API_KEY= +# Default model: deepseek-chat + +# OpenRouter — https://openrouter.ai/keys (one key for ~200 models) +OPENROUTER_API_KEY= +# Default model: openai/gpt-4o-mini (try "anthropic/claude-3.5-sonnet" +# or "meta-llama/llama-3.3-70b-instruct" via KEEPERKIT_LLM_MODEL) + +# Mistral — https://console.mistral.ai/api-keys +MISTRAL_API_KEY= +# Default model: mistral-small-latest + +# Together AI — https://api.together.xyz/settings/api-keys +TOGETHER_API_KEY= +# Default model: meta-llama/Llama-3.3-70B-Instruct-Turbo + +# Local Ollama — start `ollama serve`, then set the URL below +# OLLAMA_BASE_URL=http://localhost:11434 +# OLLAMA_MODEL=llama3.1 + +# Pin a provider explicitly (overrides auto-detect): +# KEEPERKIT_LLM_PROVIDER=groq + +# Override the model for whichever provider is active: +# KEEPERKIT_LLM_MODEL=llama-3.1-8b-instant + +# ─── Demo server bind config ───────────────────────────────────────────── KEEPERKIT_HOST=0.0.0.0 KEEPERKIT_PORT=8000 diff --git a/FEEDBACK.md b/FEEDBACK.md index 6e3a78f..7fec7ba 100644 --- a/FEEDBACK.md +++ b/FEEDBACK.md @@ -1,240 +1,275 @@ -# KeeperHub Builder Feedback +# KeeperKit ↔ KeeperHub — Builder Feedback + +> Submitted to the **KeeperHub Builder Feedback Bounty** (ETHGlobal +> OpenAgents). Author: KeeperKit team. Built against the KeeperHub public +> API at `https://app.keeperhub.com/api`, organisation API key +> (`kh_…`), May 2026. Findings come from a real end-to-end integration +> shipped at and live on +> . + +This file is intentionally specific. Each item is something we hit while +shipping, with a screenshot-worthy reproducer where possible, and a +concrete suggestion. We hope it's useful — KeeperHub is genuinely the +nicest "execution layer for agents" we've integrated against this +hackathon, so this is feedback in the spirit of "we want to ship more +on it". -> Submitted as part of the [ETHGlobal OpenAgents](https://ethglobal.com/events/openagents) hackathon for the KeeperHub *Builder Feedback Bounty*. Project: [`danilaverbena/keeperkit`](https://github.com/danilaverbena/keeperkit). +--- -This document captures what worked well, what tripped us up, and what we'd -ask for next when integrating KeeperHub from a Python AI-agent codebase. -Each item is concrete, reproducible, and actionable — written in the order -we hit them. +## TL;DR for KeeperHub PMs + +* **Marketing pages and the public REST surface drift.** The marketing + copy describes a CRUD-style execution platform (`POST /api/execute`, + workflow CRUD over REST), but the real public API is a **catalogue of + callable workflows** under `/api/mcp/workflows/{slug}/call` with x402 + billing. We rebuilt our SDK once when we discovered this. A 1-page + "API at a glance" doc would have saved us ~1 hour. +* **The 402 flow is great, but the failure mode is silent.** When a + paid workflow gets called without `X-Payment`, we get HTTP 402 with a + base64-encoded descriptor in `x-payment-requirements`. There is no + human-readable JSON in the body for non-MCP clients. We had to base64- + decode and inspect the header before our agent could reason about it. + Adding `Content-Type: application/json` with the same descriptor in the + body would let plain HTTP clients render a useful error without the + base64 step. +* **`inputSchema` quality varies a lot across workflows.** Some are crisp + JSON Schema; others have `additionalProperties: false` but no + `required`, or use plain English in `description` for things that + should be enums. Tightening these would let LLMs auto-fill arguments + more reliably. +* **No way to discover supported chains for a write workflow before + calling it.** We can call `keeperhub_list_workflows` and see + `category` / `chain` for some entries but the field is inconsistent. +* **The `kh_…` key works for both the MCP server and the REST API**, + which is great. But the docs we found never said this out loud — we + guessed and tried. + +The rest of this doc walks through specific friction points and feature +requests with reproducers. --- -## TL;DR — five-line summary - -1. **The MCP server, REST API, and CLI are well-aligned.** Naming/shape stays - consistent across all three, which made the SDK trivial to design. -2. **The action catalogue is the killer feature.** `web3/transfer-funds` - doing retry + gas-opt + private routing in one call is the right - abstraction for AI agents. -3. **The biggest friction is org-only API keys** — there's no way to do a - "hello world" without a 5-minute org-level setup. We had to ship a - mock client to make hackathon DX bearable. -4. **Auth scoping needs more granularity.** A read-only scope (workflows + - executions, no write) would let frameworks safely embed the API key in - client-side / public agent flows. -5. **Docs are clean but missing one thing: a one-page “quickstart for - programmatic builders.”** Most current docs assume someone clicking - through the dashboard. +## 1 · Friction points hit while integrating + +### 1.1 Marketing surface ≠ public API surface + +* **Where**: landing page + docs. +* **What we expected (from marketing)**: + - `POST /api/workflows` → create a workflow programmatically + - `POST /api/execute` → run an arbitrary action (transfer, contract write) + - `GET /api/action-schemas` → discover available actions +* **What's actually there (from `GET /api/openapi`)**: + - `GET /api/mcp/workflows` → list a curated **catalogue** of callable workflows + - `POST /api/mcp/workflows/{slug}/call` → invoke a specific catalogued workflow + - `GET /api/workflows` → org-scoped workflow list (different shape!) + - `GET /api/integrations` → wallet integrations + - No `POST /api/execute`, no `POST /api/workflows`, no `/api/action-schemas` (returns 404). +* **Impact**: We shipped an SDK against the marketing-implied API, then + rebuilt it after seeing the real OpenAPI. ~1 hour of rework + a fully + rewritten test suite. +* **Suggestion**: Either (a) align marketing to "catalogue of callable + workflows" framing or (b) ship a minimal public CRUD that matches the + marketing claims. We strongly prefer (a) — the catalogue model is + cleaner and more agent-friendly. A single "for builders, your API is + this" page on the marketing site (linking to `/api/openapi`) would + have made this obvious. + +### 1.2 x402 payment descriptor lives only in a header + +When you call a paid workflow without `X-Payment`: + +``` +HTTP/1.1 402 Payment Required +www-authenticate: Payment id="…", x-payment="" +x-payment-requirements: +content-type: application/json +content-length: 2 + +{} +``` + +* **Friction**: A naive HTTP client logging the response body sees `{}` + and thinks "empty server error". You have to know to look at the + `x-payment-requirements` header, base64-decode it, and parse JSON. +* **Suggestion**: Mirror the descriptor into the JSON body too. Most + agent frameworks log bodies, not headers. KeeperKit handles this in + `_decode_x402_header`, but every other integrator will also have to + write that code. + +### 1.3 No `id` / `slug` distinction in catalogue listing + +Workflows have both an `id` (`5667q8uw1rq4y723t548n`) and a `listedSlug` +(`defi-position-aggregator-ethereum`). Calling endpoints uses the slug, +but lots of fields in the catalogue refer to the id. A note like "you +will only ever pass `listedSlug` to `/call`; `id` is internal" would +save a confused first request. + +### 1.4 `inputSchema` is sometimes too loose for an LLM + +Examples we hit: +* `{"type": "object", "properties": {}, "additionalProperties": false}` — + fine, agent calls with `{}`. +* `{"type": "object", "required": ["wallet"], "properties": {"wallet": {"type": "string", "description": "Wallet address (0x...) to aggregate DeFi positions for. Must be a valid Ethereum-compatible EVM address."}}}` — + great, the description is enough for the LLM to fill in a checksummed + address. +* But several `write` workflows have schemas like + `{"type": "object"}` with no `required` / `properties` — the LLM can't + guess. We had to special-case these with prompt-time docs. +* **Suggestion**: A schema-quality lint inside the KeeperHub Studio + before publishing a workflow. Even just "your inputSchema has no + `description` on top-level properties" as a warning would help. + +### 1.5 Discoverability of chain support per workflow + +For write workflows we couldn't reliably tell from the catalogue which +chain they target without reading the workflow body. The existing +`workflowType` field is good (`read` / `write`); please add a +top-level `chain` (or `chains: [...]`) field to every catalogue entry. + +### 1.6 Listing endpoint pagination + +`GET /api/mcp/workflows` returns the full catalogue in one shot today. +For a few-dozen-workflow catalogue this is fine, but at 200+ workflows +it'll start to be expensive. Adding `?limit=&cursor=` (cursor pagination +is much friendlier for agents than offset) would future-proof this. + +### 1.7 No "describe schema for slug X" lightweight endpoint + +If we already know the slug (e.g. agent learned it from a prior list +call) and just want the input schema, we currently have to fetch the +**entire** OpenAPI spec or list the **entire** catalogue. A +`GET /api/mcp/workflows/{slug}/schema` (just the inputSchema JSON) would +let us hot-reload tool definitions cheaply. + +### 1.8 Local development needs a mock or sandbox + +We built `MockKeeperHubClient` ourselves so we could run tests offline, +but a first-party "sandbox" that mirrors the catalogue and returns +plausible synthetic outputs would be much more useful — particularly +for paid workflows where you don't want to burn USDC on a CI test run. +Even a `?dryRun=true` query parameter that returns the canned `output` +shape from the OpenAPI spec would be transformative for builders. --- -## What worked extremely well - -### 1. The action catalogue is shaped exactly right for AI agents. -`web3/transfer-funds`, `web3/write-contract`, and `web3/check-balance` are -*the* primitives an LLM-driven agent wants to call. The fact that -KeeperHub already wraps retry, gas escalation, simulation, and private -MEV-aware routing under those names meant our LangChain ReAct agent didn't -need any custom defensive code — it just calls the tool and gets a -finalized `txHash`. That's a much better fit than hand-rolled `eth_send` -+ retry loops we've seen in other agent stacks. - -### 2. Three execution surfaces, one mental model. -The fact that the hosted MCP, the REST API, and the CLI all share the -same nouns (workflow, node, edge, execution, log) is rare. We were able -to define our 12-tool catalogue once in -[`tools/_common.py`](src/keeperkit/tools/_common.py) and reuse it from -LangChain, CrewAI, and ElizaOS without diverging. - -### 3. Workflow execution responses are agent-friendly. -The execution envelope (`status`, `output`, per-node logs, `transactionLink`) -maps cleanly to what an LLM needs to "report back" to the user. Several -similar platforms force you to poll three different endpoints to assemble -that. KeeperHub's `/executions/{id}/status` + `/executions/{id}/logs` -is a one-pair fetch. - -### 4. AI workflow generation is genuinely useful. -The `ai-generate-workflow` endpoint is more than a marketing demo — for a -hackathon team, "describe what you want and get a workflow stub" is a -real productivity win. We exposed it as a top-level tool -(`keeperhub_ai_generate_workflow`) and the agent uses it in our -"generate a Discord-on-balance-drop workflow" demo. - -### 5. The MCP server is hosted (no local Node bridge). -We considered shipping a local MCP wrapper for ElizaOS but -`https://app.keeperhub.com/mcp` removed that need entirely. That's the -right default — every other MCP server we touch this hackathon needs a -local process. +## 2 · Feature requests ---- +### 2.1 Per-workflow auth scoping -## Friction points (ranked by hours-lost) - -### F1 · Org-only API keys block the first 30 minutes of any integration. -*Problem.* The org API key model means a builder evaluating KeeperHub -(or a hackathon team starting from zero) has to: -- create an account, -- create or join an org, -- find the right tab (Settings → API Keys → **Organisation**), -- create the key, -- copy it, paste it in env. - -By the time we'd done that, our LangChain demo was already running against -our own mock backend. Several teams we talked to gave up at step 3 -because they thought a personal API key would work. - -*Suggested fix.* -- Issue a short-lived **builder/sandbox key** automatically when an - account is created. Even a 24-hour key with a tiny request quota would - let people sketch in code instantly. -- Or: a `kh login` CLI that drops a sandbox key into `~/.config/kh/credentials` - with no clicks. -- Restate clearly in the docs that **personal API keys won't work for - workflow execution** — we wasted ~10 min trying one. - -### F2 · No public sandbox / testnet wallet. -*Problem.* To exercise `web3/transfer-funds` end-to-end you need a wallet -integration *and* funds *and* a chain (Sepolia is fine, but you need -SepETH). For demos, hackathon teams want a "demo wallet" they can call -from. Today there's no such thing — you must wire up your own MPC wallet -or KMS first. - -*Suggested fix.* A shared `wallet_demo_sepolia` integration with -auto-refilled SepETH and a 0.001 ETH per-call cap, gated on org keys. We -could not include a real-chain demo on our public site without this. -Right now our hosted demo flips to mock for transfer flows. - -### F3 · Auth scoping is binary. -*Problem.* Today the org API key can do everything. For a public-facing -agent (think: Discord bot, Telegram bot, hackathon judges' demo URL), -embedding a full-permission key on a server is a non-starter. We had to -guard our demo's transfer/write-contract endpoints behind a "force mock" -checkbox to avoid that. - -*Suggested fix.* Three scopes would solve this: -- `read` — workflows, executions, logs, integrations metadata, -- `execute:read-only` — adds `web3/check-balance` & `web3/read-contract`, -- `execute:full` — current default. - -### F4 · Action schemas don't surface JSON Schema. -*Problem.* `GET /api/action-schemas` returns an array of objects but they -aren't full JSON Schemas — fields are name + a free-text description. -For an LLM tool catalogue we need something tighter (param types, enums, -required flags). We ended up writing JSON Schema in -[`tools/_common.py`](src/keeperkit/tools/_common.py) by hand. - -*Suggested fix.* Add a `schema` key on each action with a real JSON Schema -fragment so SDKs (and AI agents) can introspect parameters without -guessing. Bonus: include sample inputs. - -### F5 · Execution status enum has multiple synonyms. -*Problem.* In testing the status field returned `success`, `completed`, -and (rarely) `failed` interchangeably. Our code now treats five strings -as terminal states (see -[`client.wait_for_execution`](src/keeperkit/client.py)). It works, but -the API would be cleaner with one canonical set. - -*Suggested fix.* Document the enum precisely and pick one terminal -success value. (We assume `success` is canonical and `completed` is a -legacy alias.) - -### F6 · Webhook trigger setup is dashboard-only. -*Problem.* `WebhookTrigger` workflows can be CREATED via API, but the -URL+secret pair isn't returned in the API response — you need to open the -dashboard to copy the webhook URL. That breaks fully-programmatic flows. - -*Suggested fix.* Return `{"webhookUrl": "...", "secret": "..."}` on -workflow creation when a webhook trigger is present. Or expose -`POST /api/workflows/{id}/regenerate-webhook`. - -### F7 · CLI install path doesn't match docs in some shells. -*Problem.* `go install github.com/keeperhub/cli/cmd/kh@latest` works on a -clean macOS but on Linux Devin VMs we needed to ensure `$GOPATH/bin` was -on `$PATH`. The Homebrew tap was the cleanest path, but the docs page -buries it under the Go install. - -*Suggested fix.* Lead with `brew install keeperhub/tap/kh`. Or ship a -one-liner: `curl -s https://app.keeperhub.com/install.sh | sh`. - -### F8 · No first-class Python SDK / examples. -*Problem.* We found TypeScript and CLI examples but no Python ones — the -implicit story was "use the REST API directly." For an AI builder -audience that lives in Python (LangChain, CrewAI, LlamaIndex, etc.), this -is a missing rung in the funnel. KeeperKit is in part our stop-gap fix. - -*Suggested fix.* Either bless KeeperKit as a community SDK or publish an -official `keeperhub` PyPI package with the same surface (workflow CRUD, -execution + status + logs, single-action shortcuts). Either way: at least -one Python quickstart in the docs index. +Right now an organisation API key can call **any** workflow in the org. +For multi-agent systems you sometimes want "this agent can only call +read-only workflows" or "this agent can only call workflows under the +`/payments/*` namespace". Scoped keys (e.g. `kh_workflow__…`) or a +`Subset` policy parameter on key creation would let us hand a key to a +sub-agent without giving it the keys to the kingdom. ---- +### 2.2 Streaming execution updates -## Smaller papercuts - -* **Field naming inconsistency** — the API mixes `toAddress` with - `to_address`-style snake_case in some examples. We picked `toAddress` - since that's what the live API returns. -* **Empty 200 responses on DELETE** — mostly fine, but a single - `DELETE /workflows/{id}?force=true` returned `204` while the docs said - `200`. Not a blocker, just noise. -* **Rate-limit headers** — we'd love a `X-RateLimit-Remaining` to drive - agent backoff intelligently, instead of relying on retry-after-429. -* **OpenAPI / Swagger** — the dashboard's `/api/docs` is great, but a - downloadable `openapi.json` would let us auto-generate clients in 4 - more languages over a weekend. -* **`ai-generate-workflow` cold start** — first call took ~14 s; later - calls ~3 s. A note in the docs would set expectations. -* **Workflow JSON evolution** — the response sometimes contains a - `version` field, sometimes doesn't. We default it to `1` in our model - but the inconsistency tripped a Pydantic strict validator briefly. +`POST /call` returns a final `{executionId, status, output}` payload. For +long-running workflows (multi-tx batches) it would be useful to either +stream Server-Sent Events from the same endpoint or expose a +`GET /api/mcp/workflows/{slug}/executions/{id}/events`. Otherwise we +have to poll, and the poll endpoint isn't documented. ---- +### 2.3 First-class x402 retry helper -## What we wish existed - -1. **Python SDK** with the exact same shape as KeeperKit. (Happy to donate - the design.) -2. **Streaming execution events** via SSE or WebSocket on - `/api/workflows/executions/{id}/stream`. Polling 2 s is fine for demos - but agents want realtime. -3. **Per-tool permission scopes** for the API key (see F3). -4. **First-class agent framework story** — a one-page "AI agent builders - start here" with the LangChain example we built. -5. **A "trial" workflow template gallery** — discoverable from the docs, - one-click clone into your org. Helps newcomers get a ready-to-modify - workflow without the empty-canvas problem. -6. **A `kh diff` and `kh apply`** CLI pair for declaring workflows in - YAML/JSON files in a repo, then syncing them to KeeperHub. Today - workflows are dashboard-edited, which is hard to review. +x402 settlement clients (agentcash, openclaw, custom signers) all need +the same dance: receive 402, decode descriptor, settle on-chain, retry +with `X-Payment`. KeeperHub could ship a tiny helper SDK +(`keeperhub-x402-py` / `keeperhub-x402-ts`) that wraps the retry. Today +every integrator implements this loop themselves. ---- +### 2.4 OpenAPI spec includes pricing fields per workflow + +The catalogue listing has `priceUsdcPerCall`, but the OpenAPI spec for +each `POST /call` endpoint doesn't include this in the spec metadata. +Adding `x-price-usdc` (or even just embedding it into the description) +would let LLMs reason about cost before calling. -## What we built on top — for context +### 2.5 ElizaOS / LangChain / CrewAI plugin templates from KeeperHub itself -KeeperKit ([repo](https://github.com/danilaverbena/keeperkit)) ships: +The reason KeeperKit exists is that there was no first-party adapter for +these frameworks. We're happy to keep KeeperKit alive, but a "downstream +SDK" badge on the marketing site or a quickstart link from the docs +would help builders find it (or build their own). -* a sync + async REST client with retry/error mapping, -* a `MockKeeperHubClient` (workflow CRUD + executions + logs in-memory), -* a `WorkflowBuilder` DSL, -* tool factories for **LangChain (`StructuredTool`)**, - **CrewAI (`BaseTool`)**, and **ElizaOS** (plugin descriptor JSON - generated from Python), -* a FastAPI demo server with a dashboard, a ReAct agent, direct tool - dispatch, and an ElizaOS dispatch bridge, -* offline test suite using `respx` + the mock backend. +### 2.6 Webhook on workflow publish -The above design choices were direct responses to the friction points -above — wherever the upstream KeeperHub experience could be smoother for -an AI-agent builder, KeeperKit absorbs that complexity so the next team -doesn't have to. +So we can rebuild our agent's tool catalogue automatically when a new +workflow is published. Today `POST /api/tools/_refresh` on our demo +server has to be triggered manually. --- -## Contact +## 3 · What worked really well + +To balance the friction list: + +* **The OpenAPI spec is excellent and honest.** Once we found it + (`GET /api/openapi`), it described every endpoint correctly — schemas + matched real responses, discriminators were typed. This is rarer than + it should be. +* **`helloworld` is a great smoke test.** Free, no inputs, predictable + output. Every public API should have one. +* **Error responses include actionable messages.** Not just "Bad + Request" — we got "address must be a valid 0x-prefixed hex string, + 20 bytes long" which is exactly what an agent needs. +* **The MCP server is a thoughtful primitive.** We don't lean on it in + KeeperKit (we go straight to REST so we can layer our own retry and + framework adapters), but the existence of an MCP-shaped surface is the + right call. +* **Onboarding is fast.** Sign up → org → API key → first call took us + ~3 minutes. Most "agent-platform" sponsors at this hackathon take 15+. + +--- + +## 4 · Reproducers + +All reproducers ran against `https://app.keeperhub.com/api` with a +real `kh_…` org key, May 1 2026. + +```bash +# 1.1 — marketing-implied endpoints don't exist +curl -fsS -H "Authorization: Bearer $KEEPERHUB_API_KEY" \ + https://app.keeperhub.com/api/action-schemas +# → curl: (22) The requested URL returned error: 404 + +# 1.2 — paid workflow returns 402 with body `{}` and descriptor in header +curl -isS -X POST -H "Authorization: Bearer $KEEPERHUB_API_KEY" \ + -H "content-type: application/json" \ + -d '{"address":"0x000000000000000000000000000000000000dEaD"}' \ + https://app.keeperhub.com/api/mcp/workflows/aave-v3-health-check/call \ + | sed -n '1,/^$/p;$p' +# → HTTP/1.1 402 Payment Required +# → x-payment-requirements: +# → www-authenticate: Payment id="…" +# → {} + +# 1.5 — `helloworld` works first try, no auth needed beyond the key +curl -fsS -X POST -H "Authorization: Bearer $KEEPERHUB_API_KEY" \ + -H "content-type: application/json" -d '{}' \ + https://app.keeperhub.com/api/mcp/workflows/helloworld/call +# → {"executionId":"…","status":"success", +# "output":{"result":{"message":"Hello World!"},"success":true}} +``` + +--- + +## 5 · About KeeperKit + +We built KeeperKit to give the LangChain / CrewAI / ElizaOS communities +a one-import experience for KeeperHub. The full code (Apache-2.0 // MIT) +and a live demo are at: -* GitHub repo: -* Live demo: -* GitHub author: [@danilaverbena](https://github.com/danilaverbena) +* **GitHub**: +* **Live demo**: (mock + heuristic mode by + default; add a `KEEPERHUB_API_KEY` and any LLM key in `.env` to run + against the real API). +* **Tool catalogue**: 5 static tools (discover / call / openapi / org / + integrations) + one auto-generated tool per discoverable workflow. +* **Tests**: 30 offline tests covering mock + REST + langchain + elizaos. -We're happy to follow up on any of these items, or to land patches against -KeeperHub's docs/SDKs if that helps. +Thanks to the KeeperHub team for shipping a real, working public API +during a hackathon — it's not a given, and it made this build possible +in a single afternoon. Happy to chat about any of the above on Telegram +or to file PRs against the docs site. diff --git a/README.md b/README.md index 620761a..a01ccc5 100644 --- a/README.md +++ b/README.md @@ -1,61 +1,57 @@ # KeeperKit -> 🛠️ Unified [KeeperHub](https://keeperhub.com) plugin for **LangChain**, **CrewAI**, and **ElizaOS** — give your AI agent reliable onchain execution in one import. +> 🛠️ Unified [KeeperHub](https://keeperhub.com) plugin for **LangChain**, **CrewAI**, and **ElizaOS** — give your AI agent typed access to every KeeperHub workflow, with built-in [x402](https://x402.org) payment-required handling, in one import. -[![Live demo](https://img.shields.io/badge/live%20demo-keeperkit.danilaverbena.dev-2dff7d?style=flat-square)](https://keeperkit.danilaverbena.dev) +[![Live demo](https://img.shields.io/badge/live%20demo-178.104.45.97%3A8420-2dff7d?style=flat-square)](http://178.104.45.97:8420) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE) [![Python](https://img.shields.io/badge/python-3.10%2B-3776AB?logo=python&logoColor=white&style=flat-square)](pyproject.toml) [![ETHGlobal OpenAgents](https://img.shields.io/badge/built%20for-ETHGlobal%20OpenAgents-orange?style=flat-square)](https://ethglobal.com/events/openagents/prizes/keeperhub) KeeperKit is the missing glue between modern AI agent frameworks and -[KeeperHub](https://keeperhub.com), the execution and reliability layer powering -[Sky Protocol](https://sky.money) (formerly MakerDAO). Drop it into any -LangChain / CrewAI / ElizaOS agent and onchain transactions get retry -discipline, gas escalation, simulation-before-submit, MEV-aware private -routing, and a queryable audit trail — without rewriting your agent. +[KeeperHub](https://keeperhub.com)'s public workflow catalogue. KeeperHub +exposes a curated set of callable workflows (DeFi reads, payments, write +transactions) under `POST /api/mcp/workflows/{slug}/call` — KeeperKit turns +each one into a typed tool your LangChain / CrewAI / ElizaOS agent can call +directly. ```text ┌──────────────────────────────────────────────────────┐ │ Your agent (LangChain · CrewAI · ElizaOS · custom) │ └───────────────────────┬──────────────────────────────┘ │ one import - ┌───────────▼─────────────┐ - │ KeeperKit tool catalog │ - │ (12 tools, shared │ - │ spec + JSON schema) │ - └───────────┬─────────────┘ - │ HTTP / MCP + ┌───────────▼──────────────┐ + │ KeeperKit tool catalogue│ + │ • 5 static (discover/ │ + │ call/openapi/integ.) │ + │ • 1 per workflow, │ + │ auto-generated from │ + │ /api/mcp/workflows │ + └───────────┬──────────────┘ + │ HTTP ┌───────▼───────┐ - │ KeeperHub │ retry · gas opt · MEV-private routing - │ execution │ audit trail · x402 / MPP payments - │ layer │ + │ KeeperHub │ catalogue · simulate · retry · audit + │ workflows │ + x402 micropayments (USDC on Base) └───────┬───────┘ │ - ┌───────▼────────┐ - │ EVM chains │ Ethereum · Base · Arbitrum · Polygon · … - └────────────────┘ + ┌───────▼───────────────┐ + │ EVM chains │ Ethereum · Base · Optimism · … + └───────────────────────┘ ``` --- ## Why? -KeeperHub already exposes a polished MCP server and REST API. But: - -* every framework wants tools shaped a little differently, -* every team wires retry/error-handling slightly differently, -* signing up for an API key takes a few minutes, which is too long for the - first hour of a hackathon. - -KeeperKit fixes all three: +KeeperHub already exposes a polished public REST + MCP layer. The pain +points KeeperKit removes: | Pain | KeeperKit answer | |---|---| -| Boilerplate per framework | One tool catalog, three adapters (`build_langchain_tools`, `build_crewai_tools`, ElizaOS plugin descriptor). | -| Reliability | Outer retry + backoff on top of KeeperHub's own retry. Exception types map to HTTP status. | -| Local DX | `MockKeeperHubClient` is a drop-in stand-in: workflow CRUD, executions, logs, even AI-generated workflows. Ship code on a plane. | -| ElizaOS friction | One Python call generates the action descriptor JSON your character imports. No Node toolchain needed. | -| Demo / proof | `python -m keeperkit.server` boots a dashboard with a ReAct agent, direct tool dispatch, and live KeeperHub status. | +| Each framework wants tools shaped a little differently | One shared `ToolSpec`, three thin adapters (`build_langchain_tools`, `build_crewai_tools`, `build_elizaos_plugin_descriptor`). | +| Tools drift behind the workflow catalogue | Tools are **auto-generated** from `GET /api/mcp/workflows` at build time — new workflow = new tool, no code change. | +| Paid (x402) workflows return HTTP 402 with a base64 descriptor | KeeperKit decodes it, raises `KeeperHubPaymentRequired`, and exposes `.x402` and `.amount_usdc` so your agent / x402 settlement layer can route the payment. | +| Local DX | `MockKeeperHubClient` ships a representative catalogue with free + paid workflows so you can run the full `safe_dispatch` / 402 flow offline. | +| Hackathon-grade demo | `python -m keeperkit.server` boots a FastAPI dashboard + ReAct agent + ElizaOS bridge in one command. | --- @@ -84,17 +80,20 @@ from langchain_openai import ChatOpenAI from langgraph.prebuilt import create_react_agent client = KeeperHubClient.from_env() # reads KEEPERHUB_API_KEY -tools = build_langchain_tools(client) # 12 KeeperHub tools +tools = build_langchain_tools(client) # 5 static + 1 per workflow agent = create_react_agent(ChatOpenAI(model="gpt-4o-mini"), tools) agent.invoke({"messages": [( "user", - "Send 0.01 ETH to 0xabc… on Sepolia, then tell me the tx hash.", + "Check the Aave v3 health factor for 0xabc… and warn me if liquidation risk is high.", )]}) ``` -The agent will call `keeperhub_get_wallet_integration` → `keeperhub_transfer_funds` -→ `keeperhub_get_execution_status`, and KeeperHub takes care of the rest. +Behind the scenes the agent inspects `keeperhub_list_workflows`, picks +`keeperhub_aave_v3_health_check`, calls it with the typed `address` arg, +and reads the `executionId` + `output` back. Paid workflows surface +`{ok: false, error: "payment_required", x402: {...}, amount_usdc: ...}` +so your x402 client can settle and replay with the `X-Payment` header. ### CrewAI @@ -105,8 +104,8 @@ from crewai import Agent client = KeeperHubClient.from_env() trader = Agent( - role="Onchain trader", - goal="Execute trades reliably on Sepolia.", + role="Onchain ops", + goal="Use KeeperHub workflows to monitor and act on DeFi positions.", tools=build_crewai_tools(client), backstory="Always delegate execution to KeeperHub.", ) @@ -117,8 +116,10 @@ trader = Agent( ```bash # Generate the plugin descriptor your character file imports. python - <<'PY' +from keeperkit import KeeperHubClient from keeperkit.tools.elizaos import write_elizaos_plugin -write_elizaos_plugin("./elizaos/keeperkit-plugin.json") +write_elizaos_plugin("./elizaos/keeperkit-plugin.json", + client=KeeperHubClient.from_env()) PY ``` @@ -139,23 +140,64 @@ guide. ## Tool catalogue -All adapters share the same 12 tools, defined in +KeeperKit exposes two layers of tools, all defined in [`keeperkit/tools/_common.py`](src/keeperkit/tools/_common.py): +### Static tools (always present) + | Tool | What it does | |---|---| -| `keeperhub_list_workflows` | List configured workflows in your org. | -| `keeperhub_get_workflow` | Fetch a workflow's full nodes + edges. | -| `keeperhub_execute_workflow` | Trigger a stored workflow with optional inputs. | -| `keeperhub_get_execution_status` | Poll execution state + per-node progress. | -| `keeperhub_get_execution_logs` | Audit trail with tx hashes, durations, errors. | -| `keeperhub_check_balance` | Read native balance on any supported EVM chain. | -| `keeperhub_transfer_funds` | Send native token via KeeperHub's reliable path. | -| `keeperhub_write_contract` | Call a state-changing contract function reliably. | -| `keeperhub_list_action_schemas` | Discover available KeeperHub actions. | -| `keeperhub_ai_generate_workflow` | Use KeeperHub's LLM to spawn a workflow from prose. | -| `keeperhub_get_wallet_integration` | Resolve the wallet ID needed for write actions. | -| (more added over time) | — | +| `keeperhub_list_workflows` | Discover the public workflow catalogue (`GET /api/mcp/workflows`). Returns slug, input schema, price, type, chain. | +| `keeperhub_call_workflow` | Universal escape hatch: call any workflow by slug with a free-form body. Forwards `X-Payment` if supplied. | +| `keeperhub_get_openapi` | Fetch the full OpenAPI 3.1 spec for the public API. | +| `keeperhub_list_org_workflows` | List workflows scoped to your organization (`GET /api/workflows`). | +| `keeperhub_list_integrations` | List wallet integrations attached to your org. | + +### Auto-generated per-workflow tools + +For every workflow returned by `GET /api/mcp/workflows`, KeeperKit creates a +typed tool whose: + +* **name** is `keeperhub_` (e.g. `keeperhub_aave_v3_health_check`) +* **JSON schema** is copied verbatim from the workflow's `inputSchema` +* **description** includes the workflow type (`read` / `write`), price, and chain +* **dispatch** is a closure that calls `client.call_workflow(slug, body)` and + routes 402 errors through `safe_dispatch()` so the agent gets a clean + `{ok: false, error: "payment_required", x402: {...}}` response. + +This means the agent's tool surface follows the catalogue automatically — +add a new workflow on KeeperHub and your agent picks it up on next +restart. + +--- + +## Handling paid workflows (x402) + +KeeperHub bills paid workflows via [x402](https://x402.org): the server +returns `HTTP 402` with a base64-encoded payment descriptor in the +`x-payment-requirements` header. KeeperKit decodes it for you: + +```python +from keeperkit import KeeperHubClient +from keeperkit.exceptions import KeeperHubPaymentRequired + +client = KeeperHubClient.from_env() +try: + client.call_workflow("aave-v3-health-check", {"address": "0x..."}) +except KeeperHubPaymentRequired as exc: + # exc.x402 is the decoded descriptor + print("amount:", exc.amount_usdc, "atomic USDC") + print("network:", exc.x402["accepts"][0]["network"]) + print("payTo:", exc.x402["accepts"][0]["payTo"]) + # → settle via your x402 client (agentcash, openclaw, custom signer) + # → then replay with X-Payment header: + client.call_workflow("aave-v3-health-check", {"address": "0x..."}, + x_payment="") +``` + +When the agent calls a paid tool, `safe_dispatch` converts the exception +into a structured response so the LLM can reason about it (e.g. "this is +$0.01 — pay or skip?") rather than crash. --- @@ -169,9 +211,9 @@ client = MockKeeperHubClient() # zero credentials needed tools = build_langchain_tools(client) ``` -The mock matches the real client's surface 1:1: workflow CRUD, executions -with deterministic `txHash` / `transactionLink`, logs, action schemas, AI -generation. Every framework adapter accepts it — so you can write your demo +The mock matches the real client's surface 1:1: catalogue, OpenAPI, +`call_workflow` with deterministic `executionId`, full 402 flow, org +endpoints. Every framework adapter accepts it — so you can write your demo flow before anyone on your team has signed up for KeeperHub. When you're ready to hit the real API, swap one line: @@ -197,25 +239,76 @@ python -m keeperkit.server # serves at http://0.0.0.0:8000 What you get: -* **Dashboard** at `/` — live status badge, prompt box, direct tool dispatch. -* **`POST /api/agent/run`** — LangChain ReAct agent (or LLM-less heuristic if - you have no `OPENAI_API_KEY`). -* **`POST /api/tools/{name}`** — one-shot dispatch to any KeeperKit tool. -* **`GET /api/elizaos/plugin.json`** — live ElizaOS plugin descriptor. +* **Dashboard** at `/` — backend / LLM / tool count badges, live agent box, + per-tool dispatch panels. +* **`POST /api/agent/run`** — LangChain ReAct agent over the full catalogue. + Auto-picks OpenAI, Anthropic, Google Gemini, Groq, DeepSeek, OpenRouter, + Mistral, Together, or Ollama based on which API key is set; falls back + to a deterministic heuristic when no LLM key is configured. +* **`POST /api/tools/{name}`** — one-shot dispatch to any KeeperKit tool + (static or per-workflow). +* **`POST /api/tools/_refresh`** — re-discover the workflow catalogue + without restarting (handy after publishing a new workflow on KeeperHub). +* **`GET /api/elizaos/plugin.json`** — live ElizaOS plugin descriptor that + includes one action per discoverable workflow. * **`POST /keeperkit/dispatch`** — bridge endpoint for the ElizaOS plugin. * **`/docs`** — full OpenAPI spec. +Live demo (mock + heuristic, no LLM key required): +****. + --- ## Configuration +### Getting a KeeperHub API key + +1. Sign up / log in at . +2. Open or create an **Organization** (top-right menu — *Personal* keys cannot + call workflows). +3. **Settings → API Keys → Organisation tab → New API Key.** +4. Copy the `kh_…` token into `KEEPERHUB_API_KEY`. + +Without a key, KeeperKit runs against the in-memory `MockKeeperHubClient` so +you can demo end-to-end with no external dependencies. + +### Picking an LLM + +KeeperKit's demo agent is built on LangChain — **any** chat model works. +The server auto-detects whichever provider key is in your environment, in +this priority order: + +`anthropic → google → groq → deepseek → openrouter → mistral → together → ollama → openai` + +| Provider | Env var | Free tier? | Where to get a key | +|---|---|---|---| +| **OpenAI** | `OPENAI_API_KEY` | paid | | +| **Anthropic Claude** | `ANTHROPIC_API_KEY` | trial credits | | +| **Google Gemini** | `GOOGLE_API_KEY` | **yes** | | +| **Groq** | `GROQ_API_KEY` | **yes (fast)** | | +| **DeepSeek** | `DEEPSEEK_API_KEY` | cheap paid | | +| **OpenRouter** | `OPENROUTER_API_KEY` | pay-as-you-go, 200+ models | | +| **Mistral** | `MISTRAL_API_KEY` | trial credits | | +| **Together AI** | `TOGETHER_API_KEY` | trial credits | | +| **Ollama (local)** | `OLLAMA_BASE_URL` | **free, runs locally** | | + +Override either the provider or the model explicitly: + +```bash +export KEEPERKIT_LLM_PROVIDER=groq +export KEEPERKIT_LLM_MODEL=llama-3.1-8b-instant +``` + +### All env vars + | Env var | Default | What it does | |---|---|---| | `KEEPERHUB_API_KEY` | — | `kh_…` org key. Without it, the demo runs in mock mode. | | `KEEPERHUB_BASE_URL` | `https://app.keeperhub.com/api` | Override for self-hosted KeeperHub. | | `KEEPERKIT_MODE` | `real` | Set to `mock` to force the in-memory backend. | -| `OPENAI_API_KEY` | — | Enables the LangChain ReAct agent in the demo. | -| `OPENAI_MODEL` | `gpt-4o-mini` | Override the model used by the demo agent. | +| `KEEPERKIT_LLM_PROVIDER` | auto | Pin a provider (`openai`, `anthropic`, `google`, `groq`, `deepseek`, `openrouter`, `mistral`, `together`, `ollama`). | +| `KEEPERKIT_LLM_MODEL` | provider-specific | Override the chat model for the active provider. | +| `OPENAI_API_KEY` / `OPENAI_MODEL` / `OPENAI_BASE_URL` | — | Standard OpenAI knobs. `OPENAI_BASE_URL` lets you point at LM Studio / vLLM / LiteLLM / Azure OpenAI proxies. | | `KEEPERKIT_HOST` | `0.0.0.0` | Bind host for the demo server. | | `KEEPERKIT_PORT` | `8000` | Bind port for the demo server. | @@ -225,13 +318,14 @@ What you get: ```bash pip install -e ".[langchain,crewai,server,dev]" -pytest -q # mock + langchain + REST error tests +pytest -q # mock + langchain + REST + elizaos ruff check src tests ``` The test suite uses [respx](https://github.com/lundberg/respx) to fake the -KeeperHub REST API so you can run it offline. Mock-backed integration tests -live in `tests/test_mock.py`. +KeeperHub REST API so you can run it offline. Mock-backed integration +tests live in `tests/test_mock.py`. Captured real-API snapshots in +`tests/fixtures/` keep the mock honest. --- @@ -242,13 +336,13 @@ on **both** focus areas: * **Focus area 1 — Innovative Use:** the demo server's heuristic + ReAct agent shows KeeperHub turning into the execution backbone of a generic - agent framework. The ElizaOS plugin descriptor is generated dynamically - from the same shared catalogue. + agent framework, with full x402 payment-required handling so paid + workflows are reasoned about instead of crashing. * **Focus area 2 — Integration:** ready-to-use plugin/SDK integrations for three of the active builder communities listed in the prize description - (LangChain, CrewAI, ElizaOS). Plus the - [Builder Feedback Bounty](FEEDBACK.md) submission with concrete - reproducible findings. + (LangChain, CrewAI, ElizaOS) — all sharing one auto-generated tool + catalogue. Plus the [Builder Feedback Bounty](FEEDBACK.md) submission + with concrete reproducible findings from a real integration. --- @@ -257,13 +351,11 @@ on **both** focus areas: ``` keeperkit/ ├── src/keeperkit/ -│ ├── client.py # sync + async REST client w/ retry + error mapping -│ ├── mock.py # in-memory backend -│ ├── workflow.py # WorkflowBuilder DSL -│ ├── models.py # pydantic models -│ ├── exceptions.py # KeeperHubAPIError hierarchy +│ ├── client.py # sync + async REST client + x402 decoding +│ ├── mock.py # in-memory backend with full 402 flow +│ ├── exceptions.py # KeeperHubAPIError + KeeperHubPaymentRequired │ ├── tools/ -│ │ ├── _common.py # shared 12-tool catalogue +│ │ ├── _common.py # ToolSpec + safe_dispatch + auto-generation │ │ ├── langchain.py # StructuredTool factory │ │ ├── crewai.py # crewai.tools.BaseTool factory │ │ └── elizaos.py # plugin descriptor generator diff --git a/docs/architecture.md b/docs/architecture.md index 9eeba57..6377a27 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -6,8 +6,11 @@ ┌────────────────────────────────────┐ │ KEEPERKIT TOOL CATALOGUE │ │ src/keeperkit/tools/_common │ - │ one ToolSpec(name, desc, schema) │ - │ per KeeperHub operation × 12 │ + │ STATIC_TOOL_SPECS (5 always-on) │ + │ + build_workflow_tools(client) │ + │ auto-generates 1 ToolSpec per │ + │ workflow returned by │ + │ GET /api/mcp/workflows │ └──┬───────────┬──────────────────┬──┘ │ │ │ ┌─────────▼──┐ ┌─────▼──────┐ ┌──────▼─────┐ @@ -20,52 +23,63 @@ ▼ ▼ ▼ ┌─────────────────────────────────────────┐ │ KeeperHubClient / Mock │ - │ src/keeperkit/client.py │ - │ src/keeperkit/mock.py │ + │ • list_workflows() │ + │ • call_workflow(slug, body, …) │ + │ • get_openapi() / list_org_… │ + │ • _decode_x402_header() │ └────────────────┬────────────────────────┘ │ ┌──────────────────────┴──────────────────────┐ │ │ ▼ ▼ ┌─────────────────────┐ ┌──────────────────────┐ - │ KeeperHub REST API │ │ In-memory mock │ - │ app.keeperhub.com │ │ (tests, demos, no │ - │ │ │ credentials path) │ + │ KeeperHub public │ │ In-memory mock │ + │ REST API │ │ (representative │ + │ app.keeperhub.com │ │ catalogue + 402 │ + │ /api │ │ payment flow) │ └─────────────────────┘ └──────────────────────┘ ``` ## Data flow at runtime -1. The agent (LangChain ReAct, CrewAI agent, ElizaOS character) decides to - invoke a tool, e.g. `keeperhub_transfer_funds`. +1. The agent (LangChain ReAct, CrewAI agent, ElizaOS character) decides + to invoke a tool, e.g. `keeperhub_aave_v3_health_check`. 2. The framework adapter validates the arguments using the JSON Schema - declared in `_common.py` and calls `default_dispatch(client, spec, ...)`. -3. `default_dispatch` looks up the matching method on the client - (`client.transfer_funds(...)`). + declared in `_common.py` (which was copied verbatim from the + workflow's `inputSchema`) and calls `safe_dispatch(spec, client, ...)`. +3. `safe_dispatch` invokes `spec.dispatch(client, **kwargs)`. For + per-workflow tools this is a closure over `client.call_workflow(slug, + body)`. 4. `KeeperHubClient`: - * adds `Authorization: Bearer kh_…`, - * sends the HTTP request, + * adds `Authorization: Bearer kh_…` and (when supplied) `X-Payment`, + * sends `POST /api/mcp/workflows/{slug}/call`, * applies retry/backoff for transient 429/5xx and connection errors, - * maps status codes to typed exceptions - (`KeeperHubAuthError`, `KeeperHubNotFoundError`, - `KeeperHubValidationError`), - * deserialises the response into a Pydantic model. -5. The result is returned to the agent. The framework adapter wraps it as - `{"ok": true, "data": ...}` so a downstream LLM doesn't have to parse - exceptions. -6. KeeperHub itself handles the actual onchain side: simulation, gas - escalation, MEV-aware private routing, retries on chain reorganisation - or RPC failover, and emitting per-node logs. + * maps status codes to typed exceptions: + - `401` → `KeeperHubAuthError` + - `402` → `KeeperHubPaymentRequired` (with `.x402` descriptor) + - `404` → `KeeperHubNotFoundError` + - `4xx` → `KeeperHubValidationError` + - `5xx` → `KeeperHubAPIError` +5. `safe_dispatch` catches each of these and returns a structured dict + so the LLM doesn't have to handle Python exceptions: + ```python + {"ok": True, "data": {...}} + {"ok": False, "error": "payment_required", "x402": {...}, "amount_usdc": "10000", ...} + {"ok": False, "error": "KeeperHubAuthError", "status": 401, ...} + ``` +6. KeeperHub itself handles the actual workflow side: simulation, + reliable execution, payment settlement, audit logs, and emits the + final `{executionId, status, output}` payload. ## Why a shared catalogue Defining each tool once means: * every framework agrees on names, descriptions, and parameter shapes — - if you debug a prompt with LangChain, the same prompt is going to work - with CrewAI; -* documentation and the ElizaOS plugin descriptor stay in lockstep with - the runtime; + if you debug a prompt with LangChain, the same prompt works with + CrewAI; +* documentation, the ElizaOS plugin descriptor, and `/api/elizaos/plugin.json` + stay in lockstep with the runtime; * adding a new framework is a 50-line file, not a 500-line file. ## Mock vs real client @@ -73,12 +87,11 @@ Defining each tool once means: The mock client (`MockKeeperHubClient`) implements the same surface as the real one, including: -* workflow CRUD, -* execution simulation that returns plausible `txHash`, `transactionLink`, - per-node durations, and confirmed status, -* AI-generated workflow stubs, -* a default mock wallet integration so write actions don't need extra - setup. +* a representative `DEFAULT_CATALOGUE` of 5 workflows (free + paid), +* 402 payment-required raising for paid workflows when no `x_payment` + token is supplied (and acceptance of any `x_payment` for retry), +* deterministic synthetic outputs per workflow slug, +* OpenAPI generation matching real-API shape. Switching between them is one line. The demo server picks automatically based on `KEEPERHUB_API_KEY` and exposes a "force mock" toggle in the @@ -86,11 +99,15 @@ dashboard for live demos that should never touch a real chain. ## Extending KeeperKit -To expose a new KeeperHub operation as an agent tool: +The catalogue is auto-generated, so 90% of the time you add no code at +all — KeeperHub publishes a new workflow, you call `_refresh` and your +agent picks it up. -1. Add a method on `KeeperHubClient` (and `MockKeeperHubClient` if you want - tests/demos to work offline). -2. Append a `ToolSpec` to `KEEPERHUB_TOOL_SPECS` in - `src/keeperkit/tools/_common.py`. -3. Done — the LangChain factory, CrewAI factory, and ElizaOS descriptor - pick it up automatically. +To add a **new static tool** (something that's not a single workflow +call — e.g. a multi-step helper): + +1. Append a `ToolSpec` to `STATIC_TOOL_SPECS` in + `src/keeperkit/tools/_common.py`, with a `dispatch` closure that + coordinates the calls you need. +2. The LangChain factory, CrewAI factory, and ElizaOS descriptor pick + it up automatically. diff --git a/elizaos/keeperkit-plugin.json b/elizaos/keeperkit-plugin.json index afbb271..c413cfd 100644 --- a/elizaos/keeperkit-plugin.json +++ b/elizaos/keeperkit-plugin.json @@ -1,29 +1,20 @@ { "name": "@keeperkit/elizaos-plugin", "version": "0.1.0", - "description": "KeeperHub onchain execution plugin for ElizaOS. Routes agent transactions through KeeperHub's reliable execution layer (retry, gas optimization, MEV-aware private routing, audit trail, x402 / MPP payment rails).", + "description": "KeeperHub workflow plugin for ElizaOS. Gives your character typed access to the public KeeperHub workflow catalogue (DeFi reads, payments, write transactions) with built-in x402 payment-required handling.", "homepage": "https://github.com/danilaverbena/keeperkit", "actions": [ { "name": "keeperhub_list_workflows", - "description": "List workflows already configured in your KeeperHub organization. Use this to discover existing automations the agent can reuse instead of building from scratch.", + "description": "Discover the public KeeperHub workflow catalogue. Returns each workflow's slug, input JSON Schema, price (USDC), workflow type (read|write), category, and target chain. Use this whenever you need to choose which KeeperHub action to take next.", "parameters": { "type": "object", - "properties": { - "limit": { - "type": "integer", - "default": 50, - "minimum": 1, - "maximum": 200 - }, - "offset": { - "type": "integer", - "default": 0, - "minimum": 0 - } - }, + "properties": {}, "additionalProperties": false }, + "metadata": { + "static": true + }, "dispatch": { "kind": "http", "method": "POST", @@ -35,292 +26,265 @@ } }, { - "name": "keeperhub_get_workflow", - "description": "Fetch the full configuration (nodes + edges) of a workflow by id.", + "name": "keeperhub_call_workflow", + "description": "Invoke any KeeperHub workflow by slug. The body must satisfy the workflow's inputSchema (use keeperhub_list_workflows to look it up). Returns {executionId, status, output}. Paid workflows return an x402 payment-required descriptor instead of executing \u2014 settle with your x402 client and pass the X-Payment token via x_payment.", "parameters": { "type": "object", "properties": { - "workflow_id": { - "type": "string" + "slug": { + "type": "string", + "description": "Workflow slug, e.g. 'helloworld' or 'aave-v3-health-check'." + }, + "body": { + "type": "object", + "description": "Workflow input matching its inputSchema.", + "additionalProperties": true + }, + "x_payment": { + "type": "string", + "description": "Optional x402 payment token (X-Payment header)." } }, "required": [ - "workflow_id" + "slug" ], "additionalProperties": false }, + "metadata": { + "static": true + }, "dispatch": { "kind": "http", "method": "POST", "url": "https://app.keeperhub.com/api/keeperkit/dispatch", "auth": "bearer:KEEPERHUB_API_KEY", "body": { - "tool": "keeperhub_get_workflow" + "tool": "keeperhub_call_workflow" } } }, { - "name": "keeperhub_execute_workflow", - "description": "Trigger a stored KeeperHub workflow by id and return the execution envelope. KeeperHub handles retries, gas optimization, simulation, and private MEV-aware routing.", + "name": "keeperhub_get_openapi", + "description": "Fetch the KeeperHub OpenAPI document. Useful when the agent needs the per-workflow JSON Schema (request/response) or the worked examples in info.x-guidance.", "parameters": { "type": "object", - "properties": { - "workflow_id": { - "type": "string" - }, - "inputs": { - "type": "object", - "description": "Optional input payload passed to the workflow." - } - }, - "required": [ - "workflow_id" - ], + "properties": {}, "additionalProperties": false }, + "metadata": { + "static": true + }, "dispatch": { "kind": "http", "method": "POST", "url": "https://app.keeperhub.com/api/keeperkit/dispatch", "auth": "bearer:KEEPERHUB_API_KEY", "body": { - "tool": "keeperhub_execute_workflow" + "tool": "keeperhub_get_openapi" } } }, { - "name": "keeperhub_get_execution_status", - "description": "Return the current status (pending/running/success/error/cancelled) of an execution by id, including per-node progress.", + "name": "keeperhub_list_org_workflows", + "description": "List the workflows owned by *your* KeeperHub organization (the one tied to your KEEPERHUB_API_KEY). Use this when you want to operate only on workflows you yourself authored, not the public catalogue.", "parameters": { "type": "object", - "properties": { - "execution_id": { - "type": "string" - } - }, - "required": [ - "execution_id" - ], + "properties": {}, "additionalProperties": false }, + "metadata": { + "static": true + }, "dispatch": { "kind": "http", "method": "POST", "url": "https://app.keeperhub.com/api/keeperkit/dispatch", "auth": "bearer:KEEPERHUB_API_KEY", "body": { - "tool": "keeperhub_get_execution_status" + "tool": "keeperhub_list_org_workflows" } } }, { - "name": "keeperhub_get_execution_logs", - "description": "Return per-node execution logs (input, output, duration, tx hashes) for an execution. Use this to give the user an audit trail.", + "name": "keeperhub_list_integrations", + "description": "List wallet / connector integrations configured on your KeeperHub org. Returns chain id, integration id, and connector metadata. Required input for some write workflows.", "parameters": { "type": "object", - "properties": { - "execution_id": { - "type": "string" - } - }, - "required": [ - "execution_id" - ], + "properties": {}, "additionalProperties": false }, + "metadata": { + "static": true + }, "dispatch": { "kind": "http", "method": "POST", "url": "https://app.keeperhub.com/api/keeperkit/dispatch", "auth": "bearer:KEEPERHUB_API_KEY", "body": { - "tool": "keeperhub_get_execution_logs" + "tool": "keeperhub_list_integrations" } } }, { - "name": "keeperhub_check_balance", - "description": "Read the native token balance of an address on a given EVM chain. No wallet integration is required.", + "name": "keeperhub_helloworld", + "description": "Simple workflow that returns a Hello, World! message. [type=read, slug='helloworld', free]", "parameters": { "type": "object", - "properties": { - "network": { - "type": "string", - "description": "EVM chain ID as a string. Common values: '1' (ethereum), '11155111' (sepolia), '8453' (base), '84532' (base_sepolia), '42161' (arbitrum), '137' (polygon), '10' (optimism), '130' (unichain)." - }, - "address": { - "type": "string", - "description": "0x-prefixed address." - } - }, - "required": [ - "network", - "address" - ], + "properties": {}, "additionalProperties": false }, + "metadata": { + "static": false, + "slug": "helloworld", + "price_usdc_per_call": null, + "workflow_type": "read", + "chain": null, + "category": "demo" + }, "dispatch": { "kind": "http", "method": "POST", "url": "https://app.keeperhub.com/api/keeperkit/dispatch", "auth": "bearer:KEEPERHUB_API_KEY", "body": { - "tool": "keeperhub_check_balance" + "tool": "keeperhub_helloworld" } } }, { - "name": "keeperhub_transfer_funds", - "description": "Send native token (ETH/MATIC/etc.) reliably. KeeperHub adds retry logic, gas escalation, simulation-before-submit, and MEV-aware private routing. Requires a wallet integration ID.", + "name": "keeperhub_defi_position_aggregator_base", + "description": "Aggregates a wallet's DeFi positions across the major lending and yield venues KeeperHub supports on Base in a single call \u2014 Aave V3 (with health factor), Compound V3 USDC, Morpho Steakhouse USDC. [type=read, slug='defi-position-aggregator-base', chain=8453, free]", "parameters": { "type": "object", + "required": [ + "wallet" + ], "properties": { - "network": { - "type": "string", - "description": "EVM chain ID as a string. Common values: '1' (ethereum), '11155111' (sepolia), '8453' (base), '84532' (base_sepolia), '42161' (arbitrum), '137' (polygon), '10' (optimism), '130' (unichain)." - }, - "to_address": { + "wallet": { "type": "string", - "description": "0x recipient address." - }, - "amount": { - "type": "string", - "description": "Amount in ETH-units as a string (e.g. '0.1')." - }, - "wallet_id": { - "type": "string", - "description": "KeeperHub wallet integration id (see `keeperhub_get_wallet_integration`)." + "description": "Wallet address (0x\u2026) on Base." } }, - "required": [ - "network", - "to_address", - "amount", - "wallet_id" - ], "additionalProperties": false }, + "metadata": { + "static": false, + "slug": "defi-position-aggregator-base", + "price_usdc_per_call": null, + "workflow_type": "read", + "chain": "8453", + "category": "defi" + }, "dispatch": { "kind": "http", "method": "POST", "url": "https://app.keeperhub.com/api/keeperkit/dispatch", "auth": "bearer:KEEPERHUB_API_KEY", "body": { - "tool": "keeperhub_transfer_funds" + "tool": "keeperhub_defi_position_aggregator_base" } } }, { - "name": "keeperhub_write_contract", - "description": "Call a state-changing contract function via KeeperHub's reliable execution path.", + "name": "keeperhub_aave_v3_health_check", + "description": "Reads the Aave v3 health factor, total collateral, total debt, and risk classification for a given wallet. [type=read, slug='aave-v3-health-check', chain=1, price=0.01 USDC] Returns x402 payment_required if no X-Payment token is provided.", "parameters": { "type": "object", + "required": [ + "address" + ], "properties": { - "network": { - "type": "string", - "description": "EVM chain ID as a string. Common values: '1' (ethereum), '11155111' (sepolia), '8453' (base), '84532' (base_sepolia), '42161' (arbitrum), '137' (polygon), '10' (optimism), '130' (unichain)." - }, - "contract_address": { - "type": "string" - }, - "function_name": { - "type": "string" - }, - "wallet_id": { - "type": "string" - }, - "args": { - "type": "array", - "items": {} - }, - "value": { + "address": { "type": "string", - "description": "Optional native value to send." + "description": "EVM wallet address." } }, - "required": [ - "network", - "contract_address", - "function_name", - "wallet_id" - ], "additionalProperties": false }, + "metadata": { + "static": false, + "slug": "aave-v3-health-check", + "price_usdc_per_call": "0.01", + "workflow_type": "read", + "chain": "1", + "category": "defi" + }, "dispatch": { "kind": "http", "method": "POST", "url": "https://app.keeperhub.com/api/keeperkit/dispatch", "auth": "bearer:KEEPERHUB_API_KEY", "body": { - "tool": "keeperhub_write_contract" + "tool": "keeperhub_aave_v3_health_check" } } }, { - "name": "keeperhub_list_action_schemas", - "description": "List the action types available on KeeperHub. Filter by category: web3, discord, sendgrid, webhook, system.", + "name": "keeperhub_microtip", + "description": "Send a small USDC tip from your KeeperHub wallet to any EVM address with idempotency built in. [type=write, slug='microtip', chain=8453, price=0.01 USDC] Returns x402 payment_required if no X-Payment token is provided.", "parameters": { "type": "object", + "required": [ + "recipient", + "amount" + ], "properties": { - "category": { + "recipient": { + "type": "string" + }, + "amount": { + "type": "string", + "description": "USDC amount, decimal string." + }, + "chain": { "type": "string", - "enum": [ - "web3", - "discord", - "sendgrid", - "webhook", - "system" - ] + "default": "8453" } }, "additionalProperties": false }, + "metadata": { + "static": false, + "slug": "microtip", + "price_usdc_per_call": "0.01", + "workflow_type": "write", + "chain": "8453", + "category": "payments" + }, "dispatch": { "kind": "http", "method": "POST", "url": "https://app.keeperhub.com/api/keeperkit/dispatch", "auth": "bearer:KEEPERHUB_API_KEY", "body": { - "tool": "keeperhub_list_action_schemas" + "tool": "keeperhub_microtip" } } }, { - "name": "keeperhub_ai_generate_workflow", - "description": "Ask KeeperHub's built-in workflow generator to produce a workflow from a natural-language description. Useful for skipping manual node-by-node construction.", + "name": "keeperhub_sepolia_balance_check", + "description": "Read the native ETH balance of an address on Sepolia. [type=read, slug='sepolia-balance-check', chain=11155111, free]", "parameters": { "type": "object", + "required": [ + "address" + ], "properties": { - "description": { - "type": "string" - }, - "modify_workflow_id": { + "address": { "type": "string", - "description": "Optional id of an existing workflow to modify." + "description": "0x\u2026 EVM address." } }, - "required": [ - "description" - ], "additionalProperties": false }, - "dispatch": { - "kind": "http", - "method": "POST", - "url": "https://app.keeperhub.com/api/keeperkit/dispatch", - "auth": "bearer:KEEPERHUB_API_KEY", - "body": { - "tool": "keeperhub_ai_generate_workflow" - } - } - }, - { - "name": "keeperhub_get_wallet_integration", - "description": "Return the active wallet integration metadata, including the wallet_id needed by transfer/write actions.", - "parameters": { - "type": "object", - "properties": {}, - "additionalProperties": false + "metadata": { + "static": false, + "slug": "sepolia-balance-check", + "price_usdc_per_call": null, + "workflow_type": "read", + "chain": "11155111", + "category": "defi" }, "dispatch": { "kind": "http", @@ -328,7 +292,7 @@ "url": "https://app.keeperhub.com/api/keeperkit/dispatch", "auth": "bearer:KEEPERHUB_API_KEY", "body": { - "tool": "keeperhub_get_wallet_integration" + "tool": "keeperhub_sepolia_balance_check" } } } diff --git a/examples/crewai_demo.py b/examples/crewai_demo.py index c3c69db..ea0c747 100644 --- a/examples/crewai_demo.py +++ b/examples/crewai_demo.py @@ -23,20 +23,22 @@ def main() -> None: tools = build_crewai_tools(client) trader = Agent( role="Onchain Operations", - goal="Reliably read balances and submit transactions via KeeperHub.", - backstory=("You always delegate execution to KeeperHub for retry, " - "gas optimization, and MEV-aware private routing."), + goal="Use KeeperHub workflows to monitor and act on DeFi positions.", + backstory=("You delegate every onchain action to a KeeperHub " + "workflow. Paid workflows are settled via x402."), tools=tools, verbose=True, allow_delegation=False, ) task = Task( description=( - "Check the Sepolia balance of " - "0x9c8f005ab27adb94f3d49020a15722db2fcd9f27 and report whether it " - "is below 0.01 ETH." + "Inspect the KeeperHub workflow catalogue, pick the right " + "workflow to check the Aave v3 health factor for " + "0x9c8f005ab27adb94f3d49020a15722db2fcd9f27, and call it. " + "If the tool returns `payment_required`, summarise the x402 " + "descriptor and stop instead of guessing a result." ), - expected_output="A one-line summary including the balance and the threshold check.", + expected_output="Either the health factor + summary, or a clear x402 payment-required note.", agent=trader, ) crew = Crew(agents=[trader], tasks=[task], verbose=True) diff --git a/examples/langchain_demo.py b/examples/langchain_demo.py index 196f992..d6dbd63 100644 --- a/examples/langchain_demo.py +++ b/examples/langchain_demo.py @@ -33,8 +33,8 @@ def main() -> None: agent = create_react_agent(llm, tools) prompt = ( - "Check the native balance of 0x9c8f005ab27adb94f3d49020a15722db2fcd9f27 " - "on Sepolia and report it." + "List the KeeperHub workflow catalogue, then call helloworld as a " + "smoke test and report what KeeperHub returned." ) response = agent.invoke({"messages": [("user", prompt)]}) for msg in response["messages"]: diff --git a/examples/quickstart.py b/examples/quickstart.py index b514b3a..02e4f5f 100644 --- a/examples/quickstart.py +++ b/examples/quickstart.py @@ -1,42 +1,50 @@ -"""LLM-free smoke test: build a workflow, run it through the mock, inspect it. +"""LLM-free smoke test: discover the catalogue, call a free + paid workflow. Useful for hackathon judges who want to verify the SDK works end-to-end -without setting up any credentials. +without setting up any credentials. Runs entirely against the in-memory +``MockKeeperHubClient`` so no network access is needed. """ from __future__ import annotations -from keeperkit import MockKeeperHubClient, Network, WorkflowBuilder +from keeperkit import MockKeeperHubClient +from keeperkit.exceptions import KeeperHubPaymentRequired def main() -> None: client = MockKeeperHubClient() - workflow = ( - WorkflowBuilder("Sepolia balance ping", - description="Reads my balance whenever triggered.") - .manual_trigger() - .action( - "Check Balance", - "web3/check-balance", - network=Network.SEPOLIA, - address="0x9c8f005ab27adb94f3d49020a15722db2fcd9f27", + catalogue = client.list_workflows() + print(f"discovered {len(catalogue['items'])} workflows in mock catalogue:") + for wf in catalogue["items"]: + price = wf.get("priceUsdcPerCall") or "free" + print(f" · {wf['listedSlug']:<40s} type={wf['workflowType']:<5s} price={price}") + + print("\n--- calling free helloworld ---") + out = client.call_workflow("helloworld", {}) + print("status:", out["status"]) + print("output:", out["output"]["result"]) + + print("\n--- calling paid aave-v3-health-check WITHOUT x402 token ---") + try: + client.call_workflow( + "aave-v3-health-check", + {"address": "0x000000000000000000000000000000000000dEaD"}, ) - .build() + except KeeperHubPaymentRequired as exc: + print("got 402 as expected.") + print(" amount: ", exc.amount_usdc, "atomic USDC") + print(" network: ", exc.x402["accepts"][0]["network"]) + print(" payTo: ", exc.x402["accepts"][0]["payTo"]) + + print("\n--- replaying with mock x402 settlement token ---") + out = client.call_workflow( + "aave-v3-health-check", + {"address": "0x000000000000000000000000000000000000dEaD"}, + x_payment="mock-token", ) - - saved = client.create_workflow(workflow) - print("created workflow", saved.id, "with", len(saved.nodes), "nodes") - - execution = client.execute_workflow(saved.id, inputs={"note": "hello"}) - print("execution", execution.id, "->", execution.status.value) - - final = client.wait_for_execution(execution.id) - print("final output:", final.output) - - logs = client.get_execution_logs(execution.id) - for log in logs: - print(f" · {log.nodeName}: {log.status.value} ({log.duration} ms)") + print("status:", out["status"]) + print("result:", out["output"]["result"]) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index d45cf1e..e478d07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,14 +27,27 @@ dependencies = [ [project.optional-dependencies] langchain = ["langchain-core>=0.3.0"] crewai = ["crewai>=0.70.0"] +# Individual LLM provider extras — install only the one(s) you need. +openai = ["langchain-openai>=0.2"] +anthropic = ["langchain-anthropic>=0.2"] +google = ["langchain-google-genai>=2.0"] +groq = ["langchain-groq>=0.2"] +mistral = ["langchain-mistralai>=0.2"] +ollama = ["langchain-ollama>=0.2"] +# DeepSeek / OpenRouter / Together are OpenAI-compatible — they reuse `openai`. server = [ "fastapi>=0.110", "uvicorn[standard]>=0.27", "jinja2>=3.1", "python-multipart>=0.0.9", - "langchain-openai>=0.2", "langchain>=0.3", "langgraph>=0.2", + # Bundle the most popular LLM providers so the demo just works once you + # paste any one of OPENAI / ANTHROPIC / GOOGLE / GROQ keys into .env. + "langchain-openai>=0.2", + "langchain-anthropic>=0.2", + "langchain-google-genai>=2.0", + "langchain-groq>=0.2", ] dev = [ "pytest>=8.0", @@ -68,7 +81,7 @@ select = ["E", "F", "W", "I", "B", "UP", "N"] # names so JSON (de)serialisation stays one-to-one with the upstream API. # * N806: PEP-8 wants lowercase locals, but pydantic / langchain class names # are PascalCase by convention; aliasing them locally would obscure intent. -ignore = ["N815", "N806"] +ignore = ["N815", "N806", "N818"] [tool.ruff.lint.per-file-ignores] # `pytest.importorskip` must run before the imports it guards. diff --git a/src/keeperkit/__init__.py b/src/keeperkit/__init__.py index f2668f3..9653402 100644 --- a/src/keeperkit/__init__.py +++ b/src/keeperkit/__init__.py @@ -22,41 +22,21 @@ KeeperHubAPIError, KeeperHubAuthError, KeeperHubNotFoundError, + KeeperHubPaymentRequired, KeeperHubValidationError, ) from keeperkit.mock import MockKeeperHubClient -from keeperkit.models import ( - Edge, - ExecutionLogEntry, - ExecutionStatus, - Network, - Node, - NodeStatus, - TriggerType, - Workflow, - WorkflowExecution, -) -from keeperkit.workflow import WorkflowBuilder __version__ = "0.1.0" __all__ = [ "AsyncKeeperHubClient", - "Edge", - "ExecutionLogEntry", - "ExecutionStatus", "KeeperHubAPIError", "KeeperHubAuthError", "KeeperHubClient", "KeeperHubNotFoundError", + "KeeperHubPaymentRequired", "KeeperHubValidationError", "MockKeeperHubClient", - "Network", - "Node", - "NodeStatus", - "TriggerType", - "Workflow", - "WorkflowBuilder", - "WorkflowExecution", "__version__", ] diff --git a/src/keeperkit/client.py b/src/keeperkit/client.py index 03c077e..cc01467 100644 --- a/src/keeperkit/client.py +++ b/src/keeperkit/client.py @@ -1,41 +1,42 @@ -"""Sync + async HTTP clients for the KeeperHub REST API. - -The client is intentionally thin — it does request shaping, auth, retries, -error mapping, and Pydantic deserialization, then exposes one method per -KeeperHub action. Higher-level UX (LangChain Tools, CrewAI Tools, workflow -DSL) is built on top of this. - -Notes: -* Auth is via ``Authorization: Bearer kh_...`` (an *organization* API key). -* Base URL defaults to ``https://app.keeperhub.com/api`` and is overridable. -* Network/transient failures are retried with exponential backoff so an agent - can survive transient gas spikes / RPC failovers without writing its own - retry logic. KeeperHub itself also retries onchain — this is the *outer* - layer protecting the API call to KeeperHub. +"""Sync + async HTTP clients for the KeeperHub public REST API. + +The KeeperHub public API exposes **callable workflows** rather than CRUD +primitives. Discovery happens via :meth:`KeeperHubClient.list_workflows` +(``GET /api/mcp/workflows``) and execution via +:meth:`KeeperHubClient.call_workflow` (``POST /api/mcp/workflows/{slug}/call``). +The full per-workflow JSON Schema is published at +:meth:`KeeperHubClient.get_openapi` (``GET /api/openapi``). + +Auth is via ``Authorization: Bearer kh_...`` (an *organization* API key). +The ``kh_…`` token authenticates *who* is calling but it does **not** pay +for paid workflows. Paid workflows respond with HTTP 402 and an x402 +``payment-required`` header; surface that back to the caller as a +:class:`KeeperHubPaymentRequired` exception so an x402 client (agentcash, +openclaw, the upstream caller, etc.) can do the actual settlement and +replay. + +Org-scoped helpers (``list_org_workflows``, ``list_integrations``) are kept +so a builder-side agent can enumerate the workflows and wallet integrations +it owns. """ from __future__ import annotations import os import time +from base64 import b64decode from typing import Any import anyio import httpx -from pydantic import BaseModel, TypeAdapter from keeperkit.exceptions import ( KeeperHubAPIError, KeeperHubAuthError, KeeperHubNotFoundError, + KeeperHubPaymentRequired, KeeperHubValidationError, ) -from keeperkit.models import ( - ExecutionLogEntry, - WalletIntegration, - Workflow, - WorkflowExecution, -) DEFAULT_BASE_URL = "https://app.keeperhub.com/api" DEFAULT_TIMEOUT = 30.0 @@ -43,9 +44,41 @@ RETRYABLE_STATUS = {429, 500, 502, 503, 504} +def _decode_x402_header(value: str | None) -> dict[str, Any] | None: + """Decode the base64 JSON blob in the ``payment-required`` / + ``x-payment-requirements`` headers KeeperHub returns on 402.""" + if not value: + return None + try: + # Add padding if missing. + pad = "=" * (-len(value) % 4) + raw = b64decode(value + pad) + import json + return json.loads(raw.decode("utf-8")) + except Exception: # noqa: BLE001 - non-fatal, headers are optional + return None + + def _raise_for_status(resp: httpx.Response) -> None: if resp.is_success: return + + if resp.status_code == 402: + # Paid workflow — surface as a payment-required exception so callers + # can route through their x402 client. + x402 = (_decode_x402_header(resp.headers.get("x-payment-requirements")) + or _decode_x402_header(resp.headers.get("payment-required"))) + try: + body = resp.json() + except Exception: # noqa: BLE001 + body = resp.text + raise KeeperHubPaymentRequired( + "KeeperHub workflow requires payment (HTTP 402).", + x402=x402, + payload=body, + www_authenticate=resp.headers.get("www-authenticate"), + ) + payload: Any try: payload = resp.json() @@ -66,16 +99,6 @@ def _raise_for_status(resp: httpx.Response) -> None: raise KeeperHubAPIError(message, status_code=resp.status_code, payload=payload) -def _coerce(model: type[BaseModel], data: Any) -> Any: - """Turn a JSON dict / list into a Pydantic model (or list of models).""" - if isinstance(data, dict) and "data" in data and len(data) <= 2: - # Common envelope shape: ``{"data": [...]}`` - data = data["data"] - if isinstance(data, list): - return TypeAdapter(list[model]).validate_python(data) - return model.model_validate(data) - - class _BaseClient: """Shared config / retry policy for sync + async clients.""" @@ -118,13 +141,17 @@ def __init__(self, api_key: str | None = None, **kw: Any) -> None: timeout=self.timeout) # ------------------------------------------------------------------ helpers - def _request(self, method: str, path: str, *, json: Any = None, - params: dict[str, Any] | None = None) -> Any: + def _request( + self, method: str, path: str, *, json: Any = None, + params: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Any: last_exc: Exception | None = None delay = self.backoff_initial for attempt in range(self.max_retries + 1): try: - resp = self._http.request(method, path, json=json, params=params) + resp = self._http.request(method, path, json=json, params=params, + headers=extra_headers) if resp.status_code in RETRYABLE_STATUS and attempt < self.max_retries: time.sleep(delay) delay *= 2 @@ -150,144 +177,81 @@ def from_env(cls, **kw: Any) -> KeeperHubClient: """Build a client from environment variables.""" return cls(**kw) - # ----------------------------------------------------------------- workflows - def list_workflows(self, *, limit: int = 50, offset: int = 0) -> list[Workflow]: - data = self._request("GET", "/workflows", params={"limit": limit, "offset": offset}) - return _coerce(Workflow, data) - - def get_workflow(self, workflow_id: str) -> Workflow: - data = self._request("GET", f"/workflows/{workflow_id}") - return _coerce(Workflow, data) - - def create_workflow(self, workflow: Workflow | dict[str, Any]) -> Workflow: - if isinstance(workflow, Workflow): - body: Any = workflow.model_dump(by_alias=True, exclude_none=True) - else: - body = workflow - data = self._request("POST", "/workflows", json=body) - return _coerce(Workflow, data) - - def update_workflow(self, workflow_id: str, patch: dict[str, Any]) -> Workflow: - data = self._request("PATCH", f"/workflows/{workflow_id}", json=patch) - return _coerce(Workflow, data) - - def delete_workflow(self, workflow_id: str, *, force: bool = False) -> None: - self._request("DELETE", f"/workflows/{workflow_id}", - params={"force": "true"} if force else None) - - # ----------------------------------------------------------------- execution - def execute_workflow(self, workflow_id: str, - inputs: dict[str, Any] | None = None) -> WorkflowExecution: - data = self._request("POST", f"/workflows/{workflow_id}/execute", - json={"input": inputs or {}}) - return _coerce(WorkflowExecution, data) - - def get_execution_status(self, execution_id: str) -> WorkflowExecution: - data = self._request("GET", f"/workflows/executions/{execution_id}/status") - return _coerce(WorkflowExecution, data) - - def get_execution_logs(self, execution_id: str) -> list[ExecutionLogEntry]: - data = self._request("GET", f"/workflows/executions/{execution_id}/logs") - return _coerce(ExecutionLogEntry, data) - - def list_executions(self, workflow_id: str) -> list[WorkflowExecution]: - data = self._request("GET", f"/workflows/{workflow_id}/executions") - return _coerce(WorkflowExecution, data) - - def wait_for_execution(self, execution_id: str, *, timeout: float = 120.0, - poll_interval: float = 2.0) -> WorkflowExecution: - """Poll an execution until it reaches a terminal state, then return it. - - Terminal states: ``success``, ``error``, ``failed``, ``cancelled``, - ``completed``. Raises :class:`KeeperHubAPIError` on timeout. + # --------------------------------------------------------- discovery (public) + def list_workflows(self) -> dict[str, Any]: + """List discoverable workflows (public catalogue). + + ``GET /api/mcp/workflows`` — returns ``{"items": [...]}`` where each + item has ``listedSlug``, ``inputSchema``, ``priceUsdcPerCall``, + ``workflowType`` (``"read"`` or ``"write"``), ``category``, ``chain``. """ - deadline = time.monotonic() + timeout - while True: - execution = self.get_execution_status(execution_id) - if execution.status.value in ("success", "error", "failed", - "cancelled", "completed"): - return execution - if time.monotonic() > deadline: - raise KeeperHubAPIError( - f"Timed out waiting for execution {execution_id}; last status=" - f"{execution.status.value}" - ) - time.sleep(poll_interval) - - # ---------------------------------------------------------------- one-shots - def execute_action(self, action_type: str, config: dict[str, Any], *, - network: str | None = None) -> WorkflowExecution: - """Convenience: build a single-action workflow, run it, return execution. - - Internally this calls KeeperHub's ``/api/execute`` endpoint, which lets - you fire a single web3 action without first creating a stored workflow. + data = self._request("GET", "/mcp/workflows") + if isinstance(data, list): + return {"items": data} + return data or {"items": []} + + def get_openapi(self) -> dict[str, Any]: + """Fetch KeeperHub's OpenAPI document (``GET /api/openapi``). + + The document includes per-workflow JSON Schemas under + ``paths['/api/mcp/workflows/{slug}/call']``, x-payment-info, and + worked examples in ``info.x-guidance``. """ - body = {"actionType": action_type, "config": dict(config)} - if network is not None: - body["config"].setdefault("network", network) - data = self._request("POST", "/execute", json=body) - return _coerce(WorkflowExecution, data) - - def check_balance(self, network: str, address: str) -> dict[str, Any]: - execution = self.execute_action( - "web3/check-balance", - {"network": network, "address": address}, - ) - return (execution.output or {}) | {"executionId": execution.id} - - def transfer_funds(self, network: str, to_address: str, amount: str, - wallet_id: str) -> WorkflowExecution: - return self.execute_action( - "web3/transfer-funds", - {"network": network, "toAddress": to_address, - "amount": amount, "walletId": wallet_id}, - ) + return self._request("GET", "/openapi") or {} - def write_contract(self, network: str, contract_address: str, function_name: str, - wallet_id: str, args: list[Any] | None = None, - value: str | None = None) -> WorkflowExecution: - config: dict[str, Any] = { - "network": network, - "contractAddress": contract_address, - "functionName": function_name, - "walletId": wallet_id, - } - if args is not None: - config["args"] = args - if value is not None: - config["value"] = value - return self.execute_action("web3/write-contract", config) - - # --------------------------------------------------------------- integrations - def list_integrations(self, *, type: str | None = None) -> list[dict[str, Any]]: - data = self._request("GET", "/integrations", - params={"type": type} if type else None) + # --------------------------------------------------------- execution (public) + def call_workflow( + self, + slug: str, + body: dict[str, Any] | None = None, + *, + x_payment: str | None = None, + ) -> dict[str, Any]: + """Invoke a KeeperHub workflow. + + ``POST /api/mcp/workflows/{slug}/call``. ``body`` must satisfy the + workflow's ``inputSchema`` (see :meth:`list_workflows`). For paid + workflows, pass an x402 payment token in ``x_payment`` (the value that + goes into the ``X-Payment`` header per x402 v2). Without it, the + server returns HTTP 402 and this method raises + :class:`KeeperHubPaymentRequired` carrying the decoded payment + descriptor for downstream settlement. + + Returns the parsed JSON response, typically + ``{"executionId": ..., "status": ..., "output": {...}}``. + """ + extra: dict[str, str] | None = None + if x_payment: + extra = {"X-Payment": x_payment} + return self._request("POST", f"/mcp/workflows/{slug}/call", + json=body or {}, extra_headers=extra) or {} + + # ----------------------------------------------------- builder-side helpers + def list_org_workflows(self) -> list[dict[str, Any]]: + """List the *organization's* workflows (builder view). + + ``GET /api/workflows`` returns workflows owned by the org tied to the + ``kh_…`` key — useful for an agent that operates on its own + workflows. Public/discoverable workflows belonging to *other* orgs + only appear via :meth:`list_workflows`. + """ + data = self._request("GET", "/workflows") or [] if isinstance(data, dict) and "data" in data: data = data["data"] return data or [] - def get_wallet_integration(self) -> WalletIntegration | None: - wallets = self.list_integrations(type="web3") - if not wallets: - return None - return WalletIntegration.model_validate(wallets[0]) + def list_integrations(self) -> list[dict[str, Any]]: + """List wallet / external integrations configured on the org. - def list_action_schemas(self, *, category: str | None = None) -> list[dict[str, Any]]: - data = self._request("GET", "/action-schemas", - params={"category": category} if category else None) + ``GET /api/integrations``. Each entry typically contains a chain id, + a connector name, and an opaque integration id usable in workflow + bodies. Returns ``[]`` for orgs that have no integrations yet. + """ + data = self._request("GET", "/integrations") or [] if isinstance(data, dict) and "data" in data: data = data["data"] return data or [] - # -------------------------------------------------------------- ai-generated - def ai_generate_workflow(self, description: str, *, - modify_workflow_id: str | None = None) -> Workflow: - body: dict[str, Any] = {"prompt": description} - if modify_workflow_id: - body["workflowId"] = modify_workflow_id - data = self._request("POST", "/workflows/ai-generate", json=body) - return _coerce(Workflow, data) - # -------------------------------------------------------------- housekeeping def close(self) -> None: self._http.close() @@ -307,13 +271,17 @@ def __init__(self, api_key: str | None = None, **kw: Any) -> None: self._http = httpx.AsyncClient(base_url=self.base_url, headers=self.headers, timeout=self.timeout) - async def _request(self, method: str, path: str, *, json: Any = None, - params: dict[str, Any] | None = None) -> Any: + async def _request( + self, method: str, path: str, *, json: Any = None, + params: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, + ) -> Any: last_exc: Exception | None = None delay = self.backoff_initial for attempt in range(self.max_retries + 1): try: - resp = await self._http.request(method, path, json=json, params=params) + resp = await self._http.request(method, path, json=json, params=params, + headers=extra_headers) if resp.status_code in RETRYABLE_STATUS and attempt < self.max_retries: await anyio.sleep(delay) delay *= 2 @@ -337,43 +305,39 @@ async def _request(self, method: str, path: str, *, json: Any = None, def from_env(cls, **kw: Any) -> AsyncKeeperHubClient: return cls(**kw) - async def list_workflows(self, *, limit: int = 50, offset: int = 0) -> list[Workflow]: - data = await self._request("GET", "/workflows", - params={"limit": limit, "offset": offset}) - return _coerce(Workflow, data) - - async def get_workflow(self, workflow_id: str) -> Workflow: - data = await self._request("GET", f"/workflows/{workflow_id}") - return _coerce(Workflow, data) - - async def execute_workflow(self, workflow_id: str, - inputs: dict[str, Any] | None = None) -> WorkflowExecution: - data = await self._request("POST", f"/workflows/{workflow_id}/execute", - json={"input": inputs or {}}) - return _coerce(WorkflowExecution, data) - - async def get_execution_status(self, execution_id: str) -> WorkflowExecution: - data = await self._request("GET", f"/workflows/executions/{execution_id}/status") - return _coerce(WorkflowExecution, data) - - async def get_execution_logs(self, execution_id: str) -> list[ExecutionLogEntry]: - data = await self._request("GET", f"/workflows/executions/{execution_id}/logs") - return _coerce(ExecutionLogEntry, data) - - async def wait_for_execution(self, execution_id: str, *, timeout: float = 120.0, - poll_interval: float = 2.0) -> WorkflowExecution: - deadline = anyio.current_time() + timeout - while True: - execution = await self.get_execution_status(execution_id) - if execution.status.value in ("success", "error", "failed", - "cancelled", "completed"): - return execution - if anyio.current_time() > deadline: - raise KeeperHubAPIError( - f"Timed out waiting for execution {execution_id}; last status=" - f"{execution.status.value}" - ) - await anyio.sleep(poll_interval) + async def list_workflows(self) -> dict[str, Any]: + data = await self._request("GET", "/mcp/workflows") + if isinstance(data, list): + return {"items": data} + return data or {"items": []} + + async def get_openapi(self) -> dict[str, Any]: + return await self._request("GET", "/openapi") or {} + + async def call_workflow( + self, + slug: str, + body: dict[str, Any] | None = None, + *, + x_payment: str | None = None, + ) -> dict[str, Any]: + extra: dict[str, str] | None = None + if x_payment: + extra = {"X-Payment": x_payment} + return await self._request("POST", f"/mcp/workflows/{slug}/call", + json=body or {}, extra_headers=extra) or {} + + async def list_org_workflows(self) -> list[dict[str, Any]]: + data = await self._request("GET", "/workflows") or [] + if isinstance(data, dict) and "data" in data: + data = data["data"] + return data or [] + + async def list_integrations(self) -> list[dict[str, Any]]: + data = await self._request("GET", "/integrations") or [] + if isinstance(data, dict) and "data" in data: + data = data["data"] + return data or [] async def close(self) -> None: await self._http.aclose() diff --git a/src/keeperkit/exceptions.py b/src/keeperkit/exceptions.py index 4d4a4be..14f8bc4 100644 --- a/src/keeperkit/exceptions.py +++ b/src/keeperkit/exceptions.py @@ -23,3 +23,35 @@ class KeeperHubNotFoundError(KeeperHubAPIError): class KeeperHubValidationError(KeeperHubAPIError): """Raised on 400 — invalid parameters (bad node config, missing field, etc.).""" + + +class KeeperHubPaymentRequired(KeeperHubAPIError): + """Raised on 402 — the workflow is paid and an x402 payment is required. + + KeeperHub paid workflows respond with HTTP 402, an + ``x-payment-requirements`` header (base64-encoded JSON describing the + accepted x402 payment schemes), and a ``www-authenticate: Payment …`` + challenge. The decoded descriptor is exposed on :attr:`x402` so callers + can route through their x402 settlement client (agentcash / openclaw / + a custom signer) and replay the request with an ``X-Payment`` header. + """ + + def __init__( + self, + message: str, + *, + x402: dict | None = None, + www_authenticate: str | None = None, + payload: object | None = None, + ) -> None: + super().__init__(message, status_code=402, payload=payload) + self.x402 = x402 or {} + self.www_authenticate = www_authenticate + + @property + def amount_usdc(self) -> str | None: + """Convenience: cost (in atomic USDC, 6 decimals) for the cheapest accept.""" + accepts = (self.x402 or {}).get("accepts") or [] + if not accepts: + return None + return accepts[0].get("amount") diff --git a/src/keeperkit/mock.py b/src/keeperkit/mock.py index df5733b..a519684 100644 --- a/src/keeperkit/mock.py +++ b/src/keeperkit/mock.py @@ -1,291 +1,261 @@ -"""In-memory mock backend that mimics the KeeperHub API. +"""In-memory ``MockKeeperHubClient`` mirroring the real public API. -Why this matters: KeeperHub auth is org-scoped, so getting a real key takes -time at the start of a hackathon and you can't easily share one. The mock -backend lets a builder integrate KeeperKit immediately, write tests against a -deterministic surface, and then flip a single env var to hit the real API. +The mock matches the surface of :class:`keeperkit.client.KeeperHubClient` +exactly — same method names, same return shapes — so the rest of the +codebase (tools, server, examples) doesn't care which one it talks to. -The mock is *intentionally* not a perfect simulator — it covers the surface -KeeperKit's tools depend on: +The mock ships with a small **representative** catalogue derived from the +real ``GET /api/mcp/workflows`` response (free + paid samples across the +three workflow archetypes KeeperHub publishes today: read DeFi data, write +onchain transactions, and a hello-world). It's enough to demo the full +plugin surface — including 402 / x402 — without any external network +calls. -* workflow CRUD, -* ``execute_workflow`` (returns a deterministic, completed execution), -* ``execute_action`` (single-shot web3 actions: balance, transfer, write), -* execution status + logs, -* a default mock wallet integration. +Tip: pass ``catalogue=`` to override the built-in fixture (e.g. to load a +saved real-API snapshot from ``tests/fixtures/keeperhub_catalogue.json``). """ from __future__ import annotations +import secrets import time -import uuid from typing import Any -from keeperkit.exceptions import KeeperHubNotFoundError -from keeperkit.models import ( - Edge, - ExecutionLogEntry, - ExecutionStatus, - Node, - NodeStatus, - WalletIntegration, - Workflow, - WorkflowExecution, +from keeperkit.exceptions import ( + KeeperHubNotFoundError, + KeeperHubPaymentRequired, ) - -def _new_id(prefix: str) -> str: - return f"{prefix}_{uuid.uuid4().hex[:10]}" +# Representative subset of the real catalogue. Slugs, schemas, prices and +# workflowType match what KeeperHub returns in production today (May 2026). +DEFAULT_CATALOGUE: list[dict[str, Any]] = [ + { + "id": "mock_helloworld", + "name": "HelloWorld", + "description": "Simple workflow that returns a Hello, World! message.", + "listedSlug": "helloworld", + "inputSchema": {"type": "object", "properties": {}, "additionalProperties": False}, + "priceUsdcPerCall": None, + "workflowType": "read", + "category": "demo", + "chain": None, + "isListed": True, + }, + { + "id": "mock_defi_position_aggregator_base", + "name": "DeFi Position Aggregator: Wallet on Base", + "description": ( + "Aggregates a wallet's DeFi positions across the major lending and " + "yield venues KeeperHub supports on Base in a single call — Aave V3 " + "(with health factor), Compound V3 USDC, Morpho Steakhouse USDC." + ), + "listedSlug": "defi-position-aggregator-base", + "inputSchema": { + "type": "object", + "required": ["wallet"], + "properties": { + "wallet": { + "type": "string", + "description": "Wallet address (0x…) on Base.", + } + }, + "additionalProperties": False, + }, + "priceUsdcPerCall": None, + "workflowType": "read", + "category": "defi", + "chain": "8453", + "isListed": True, + }, + { + "id": "mock_aave_v3_health_check", + "name": "Aave v3 Health Check", + "description": ( + "Reads the Aave v3 health factor, total collateral, total debt, and " + "risk classification for a given wallet." + ), + "listedSlug": "aave-v3-health-check", + "inputSchema": { + "type": "object", + "required": ["address"], + "properties": { + "address": {"type": "string", "description": "EVM wallet address."} + }, + "additionalProperties": False, + }, + "priceUsdcPerCall": "0.01", + "workflowType": "read", + "category": "defi", + "chain": "1", + "isListed": True, + }, + { + "id": "mock_microtip", + "name": "Microtip", + "description": ( + "Send a small USDC tip from your KeeperHub wallet to any EVM address " + "with idempotency built in." + ), + "listedSlug": "microtip", + "inputSchema": { + "type": "object", + "required": ["recipient", "amount"], + "properties": { + "recipient": {"type": "string"}, + "amount": {"type": "string", "description": "USDC amount, decimal string."}, + "chain": {"type": "string", "default": "8453"}, + }, + "additionalProperties": False, + }, + "priceUsdcPerCall": "0.01", + "workflowType": "write", + "category": "payments", + "chain": "8453", + "isListed": True, + }, + { + "id": "mock_sepolia_balance_check", + "name": "Sepolia Balance Check", + "description": "Read the native ETH balance of an address on Sepolia.", + "listedSlug": "sepolia-balance-check", + "inputSchema": { + "type": "object", + "required": ["address"], + "properties": { + "address": {"type": "string", "description": "0x… EVM address."} + }, + "additionalProperties": False, + }, + "priceUsdcPerCall": None, + "workflowType": "read", + "category": "defi", + "chain": "11155111", + "isListed": True, + }, +] + + +def _exec_id() -> str: + return f"exec_{secrets.token_hex(6)}" + + +def _x402_descriptor(slug: str, price_usdc: str) -> dict[str, Any]: + """Build the structured x402 descriptor we'd return on 402.""" + return { + "x402Version": 2, + "error": "Payment required", + "resource": { + "url": f"https://app.keeperhub.com/api/mcp/workflows/{slug}/call", + "description": f"Pay to run workflow: {slug}", + "mimeType": "application/json", + }, + "accepts": [ + { + "scheme": "exact", + "network": "eip155:8453", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", # USDC on Base + "amount": str(int(float(price_usdc) * 1_000_000)), # 6-decimal atomic + "payTo": "0x650a09bc1cda076486716acdd80fce1bba5e84ff", + "maxTimeoutSeconds": 300, + "extra": {"name": "USD Coin", "version": "2"}, + } + ], + } class MockKeeperHubClient: - """Drop-in stand-in for :class:`KeeperHubClient`. - - The mock implements the same method names, so swapping ``KeeperHubClient`` - for ``MockKeeperHubClient()`` in your code is enough to run end-to-end - against an in-memory backend. - """ - - is_mock = True - - def __init__(self) -> None: - self._workflows: dict[str, Workflow] = {} - self._executions: dict[str, WorkflowExecution] = {} - self._logs: dict[str, list[ExecutionLogEntry]] = {} - # one default wallet so transfer/write actions don't need extra setup - self._wallet = WalletIntegration( - id="wallet_mock_main", - name="Mock KeeperHub Wallet", - network="11155111", - address="0xMockWallet0000000000000000000000000000000", - ) - - # ----------------------------------------------------------------- workflows - def list_workflows(self, *, limit: int = 50, offset: int = 0) -> list[Workflow]: - items = list(self._workflows.values()) - return items[offset : offset + limit] - - def get_workflow(self, workflow_id: str) -> Workflow: - if workflow_id not in self._workflows: - raise KeeperHubNotFoundError(f"workflow {workflow_id} not found") - return self._workflows[workflow_id] - - def create_workflow(self, workflow: Workflow | dict[str, Any]) -> Workflow: - if isinstance(workflow, dict): - workflow = Workflow.model_validate(workflow) - wf = workflow.model_copy(update={"id": workflow.id or _new_id("wf")}) - self._workflows[wf.id] = wf - return wf - - def update_workflow(self, workflow_id: str, patch: dict[str, Any]) -> Workflow: - wf = self.get_workflow(workflow_id) - updated = wf.model_copy(update=patch) - self._workflows[workflow_id] = updated - return updated - - def delete_workflow(self, workflow_id: str, *, force: bool = False) -> None: - if workflow_id not in self._workflows: - raise KeeperHubNotFoundError(f"workflow {workflow_id} not found") - del self._workflows[workflow_id] - - # ----------------------------------------------------------------- execution - def execute_workflow(self, workflow_id: str, - inputs: dict[str, Any] | None = None) -> WorkflowExecution: - wf = self.get_workflow(workflow_id) - return self._simulate_execution(wf, inputs or {}) - - def get_execution_status(self, execution_id: str) -> WorkflowExecution: - if execution_id not in self._executions: - raise KeeperHubNotFoundError(f"execution {execution_id} not found") - return self._executions[execution_id] - - def get_execution_logs(self, execution_id: str) -> list[ExecutionLogEntry]: - if execution_id not in self._logs: - raise KeeperHubNotFoundError(f"execution {execution_id} not found") - return list(self._logs[execution_id]) - - def list_executions(self, workflow_id: str) -> list[WorkflowExecution]: - return [e for e in self._executions.values() if e.workflowId == workflow_id] - - def wait_for_execution(self, execution_id: str, *, timeout: float = 120.0, - poll_interval: float = 0.0) -> WorkflowExecution: - # Mock executions are synchronous, so we can just return the stored one. - return self.get_execution_status(execution_id) - - # ------------------------------------------------------- single-shot actions - def execute_action(self, action_type: str, config: dict[str, Any], *, - network: str | None = None) -> WorkflowExecution: - cfg = dict(config) - if network is not None: - cfg.setdefault("network", network) - wf = Workflow( - name=f"adhoc:{action_type}", - nodes=[ - Node( - id="trigger-1", type="trigger", - data={"label": "Manual", "type": "trigger", - "config": {"triggerType": "Manual"}}, - ), - Node( - id="action-1", type="action", - data={"label": action_type, "type": "action", - "config": cfg | {"actionType": action_type}}, - ), - ], - edges=[Edge(id="e1", source="trigger-1", target="action-1")], - ) - return self._simulate_execution(wf, cfg) + """In-memory KeeperHub client mirroring the real public API surface.""" + + def __init__( + self, + *, + catalogue: list[dict[str, Any]] | None = None, + accept_x_payment_token: str = "mock-token", + ) -> None: + self.base_url = "in-memory" + self.api_key = "mock" + self._catalogue = list(catalogue if catalogue is not None else DEFAULT_CATALOGUE) + self._accept_token = accept_x_payment_token + self._executions: dict[str, dict[str, Any]] = {} + + # ------------------------------------------------------------- discovery + def list_workflows(self) -> dict[str, Any]: + return {"items": list(self._catalogue)} + + def get_openapi(self) -> dict[str, Any]: + return { + "openapi": "3.1.0", + "info": { + "title": "KeeperHub (mock)", + "version": "0.1.0", + "description": "Mock KeeperHub OpenAPI for offline demos.", + }, + "paths": { + f"/api/mcp/workflows/{wf['listedSlug']}/call": { + "post": { + "operationId": f"call-{wf['listedSlug']}", + "summary": wf["name"], + "description": wf.get("description", ""), + "requestBody": { + "content": {"application/json": {"schema": wf["inputSchema"]}} + }, + } + } + for wf in self._catalogue + if wf.get("listedSlug") + }, + } - def check_balance(self, network: str, address: str) -> dict[str, Any]: - execution = self.execute_action( - "web3/check-balance", - {"network": network, "address": address}, - ) - return (execution.output or {}) | {"executionId": execution.id} + # ------------------------------------------------------------- execution + def call_workflow( + self, + slug: str, + body: dict[str, Any] | None = None, + *, + x_payment: str | None = None, + ) -> dict[str, Any]: + wf = next((w for w in self._catalogue if w.get("listedSlug") == slug), None) + if wf is None: + raise KeeperHubNotFoundError( + f"workflow {slug!r} not in mock catalogue", status_code=404 + ) - def transfer_funds(self, network: str, to_address: str, amount: str, - wallet_id: str) -> WorkflowExecution: - return self.execute_action( - "web3/transfer-funds", - {"network": network, "toAddress": to_address, - "amount": amount, "walletId": wallet_id}, - ) + price = wf.get("priceUsdcPerCall") + if price and x_payment != self._accept_token: + raise KeeperHubPaymentRequired( + f"workflow {slug!r} requires payment ({price} USDC).", + x402=_x402_descriptor(slug, price), + ) - def write_contract(self, network: str, contract_address: str, function_name: str, - wallet_id: str, args: list[Any] | None = None, - value: str | None = None) -> WorkflowExecution: - cfg: dict[str, Any] = { - "network": network, - "contractAddress": contract_address, - "functionName": function_name, - "walletId": wallet_id, + execution_id = _exec_id() + result = _simulate_output(slug, body or {}) + record = { + "executionId": execution_id, + "status": "success", + "output": {"logs": [], "result": result, "success": True}, + "_slug": slug, + "_calledAt": time.time(), } - if args is not None: - cfg["args"] = args - if value is not None: - cfg["value"] = value - return self.execute_action("web3/write-contract", cfg) - - # --------------------------------------------------------------- integrations - def list_integrations(self, *, type: str | None = None) -> list[dict[str, Any]]: - return [self._wallet.model_dump()] + self._executions[execution_id] = record + return {k: v for k, v in record.items() if not k.startswith("_")} - def get_wallet_integration(self) -> WalletIntegration: - return self._wallet + # ----------------------------------------------------- builder-side helpers + def list_org_workflows(self) -> list[dict[str, Any]]: + # In mock land the org "owns" the same listed catalogue. + return [dict(w) for w in self._catalogue] - def list_action_schemas(self, *, category: str | None = None) -> list[dict[str, Any]]: - # A representative subset of KeeperHub's web3 action schemas, kept - # compact so the demo agent can list them without overwhelming the LLM. - all_schemas = [ - { - "actionType": "web3/check-balance", - "category": "web3", - "description": "Read native token balance for an address.", - "fields": ["network", "address"], - }, + def list_integrations(self) -> list[dict[str, Any]]: + return [ { - "actionType": "web3/check-token-balance", - "category": "web3", - "description": "Read ERC-20 token balance.", - "fields": ["network", "address", "tokenAddress"], - }, - { - "actionType": "web3/transfer-funds", - "category": "web3", - "description": "Send native token (with retry + gas opt + private routing).", - "fields": ["network", "toAddress", "amount", "walletId"], - }, - { - "actionType": "web3/transfer-token", - "category": "web3", - "description": "Send ERC-20 token.", - "fields": ["network", "toAddress", "tokenAddress", "amount", "walletId"], - }, - { - "actionType": "web3/write-contract", - "category": "web3", - "description": "Call a state-changing contract function.", - "fields": ["network", "contractAddress", "functionName", "walletId", - "args"], - }, - { - "actionType": "web3/read-contract", - "category": "web3", - "description": "Read from a view/pure contract function.", - "fields": ["network", "contractAddress", "functionName", "args"], - }, + "id": "mock_wallet_base", + "type": "web3", + "chain": "8453", + "name": "Mock Base wallet", + "address": "0x000000000000000000000000000000000000dEaD", + } ] - if category: - return [s for s in all_schemas if s["category"] == category] - return all_schemas - - def ai_generate_workflow(self, description: str, *, - modify_workflow_id: str | None = None) -> Workflow: - # The mock can't actually call an LLM, but it can produce a sensible - # single-action workflow so downstream code paths still exercise. - wf = Workflow( - name=f"AI: {description[:50]}", - description=description, - nodes=[ - Node( - id="trigger-1", type="trigger", - data={"label": "Manual", "type": "trigger", - "config": {"triggerType": "Manual"}}, - ), - Node( - id="action-1", type="action", - data={ - "label": "Check Balance", - "type": "action", - "config": { - "actionType": "web3/check-balance", - "network": "11155111", - "address": "0x0000000000000000000000000000000000000000", - }, - }, - ), - ], - edges=[Edge(id="e1", source="trigger-1", target="action-1")], - ) - return self.create_workflow(wf) - - # ------------------------------------------------------- internal simulation - def _simulate_execution(self, wf: Workflow, - inputs: dict[str, Any]) -> WorkflowExecution: - execution_id = _new_id("exec") - action_logs: list[ExecutionLogEntry] = [] - output: dict[str, Any] = {} - for node in wf.nodes: - if node.type != "action": - continue - action = node.data.config.get("actionType", "") - simulated = _simulate_action(action, node.data.config, inputs) - output = simulated - action_logs.append( - ExecutionLogEntry( - nodeId=node.id, - nodeName=node.data.label, - nodeType=node.type, - status=NodeStatus.SUCCESS, - input=node.data.config, - output=simulated, - duration=42, - createdAt=_now(), - ) - ) - execution = WorkflowExecution( - id=execution_id, - workflowId=wf.id, - status=ExecutionStatus.SUCCESS, - input=inputs, - output=output, - createdAt=_now(), - completedAt=_now(), - ) - self._executions[execution_id] = execution - self._logs[execution_id] = action_logs - return execution + # ------------------------------------------------------------- housekeeping def close(self) -> None: pass @@ -296,53 +266,50 @@ def __exit__(self, *exc: Any) -> None: self.close() -def _now() -> str: - return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - +def _simulate_output(slug: str, body: dict[str, Any]) -> dict[str, Any]: + """Return a plausible output payload per workflow type.""" + if slug == "helloworld": + return {"message": "Hello World!"} -def _simulate_action(action: str, config: dict[str, Any], - inputs: dict[str, Any]) -> dict[str, Any]: - """Return a plausible action output without touching a real chain.""" - if action == "web3/check-balance": + if slug == "defi-position-aggregator-base": return { - "balance": "0.1234", - "balanceWei": "123400000000000000", - "address": config.get("address"), - "network": config.get("network"), - } - if action == "web3/check-token-balance": - return { - "balance": "1000.0", - "tokenAddress": config.get("tokenAddress"), - "address": config.get("address"), - "network": config.get("network"), + "wallet": body.get("wallet"), + "totalUsd": "1234.56", + "positions": [ + {"protocol": "aave-v3", "asset": "USDC", "balance": "1000.00", + "apy": "5.41"}, + {"protocol": "morpho", "asset": "USDC", "balance": "234.56", + "apy": "4.82"}, + ], } - if action in ("web3/transfer-funds", "web3/transfer-token"): + + if slug == "aave-v3-health-check": return { - "txHash": f"0x{uuid.uuid4().hex}{uuid.uuid4().hex}", - "from": "0xMockWallet0000000000000000000000000000000", - "to": config.get("toAddress"), - "amount": config.get("amount"), - "network": config.get("network"), - "status": "confirmed", - "retries": 0, - "gasUsed": "21000", - "private": True, - "transactionLink": "https://sepolia.etherscan.io/tx/0xMOCK", + "address": body.get("address"), + "healthFactor": "2.13", + "totalCollateralUSD": "12345.00", + "totalDebtUSD": "5800.00", + "riskLevel": "healthy", } - if action == "web3/write-contract": + + if slug == "microtip": return { - "txHash": f"0x{uuid.uuid4().hex}{uuid.uuid4().hex}", - "contractAddress": config.get("contractAddress"), - "functionName": config.get("functionName"), - "args": config.get("args"), - "network": config.get("network"), - "status": "confirmed", + "recipient": body.get("recipient"), + "amount": body.get("amount"), + "chain": body.get("chain", "8453"), + "txHash": f"0x{secrets.token_hex(32)}", + "transactionLink": ( + "https://basescan.org/tx/" + f"0x{secrets.token_hex(32)}" + ), } - if action == "web3/read-contract": + + if slug == "sepolia-balance-check": return { - "value": "42", - "functionName": config.get("functionName"), - "contractAddress": config.get("contractAddress"), + "address": body.get("address"), + "chainId": "11155111", + "balanceWei": "12345678901234567890", + "balanceEth": "12.345", } - return {"action": action, "config": config, "inputs": inputs, "ok": True} + + return {"slug": slug, "input": body, "note": "mock simulated output"} diff --git a/src/keeperkit/models.py b/src/keeperkit/models.py deleted file mode 100644 index 36f2709..0000000 --- a/src/keeperkit/models.py +++ /dev/null @@ -1,140 +0,0 @@ -"""Pydantic models that mirror KeeperHub's REST/MCP payload shapes. - -These models are intentionally permissive — the upstream API still evolves, -so unknown fields are preserved via ``model_config["extra"] = "allow"`` and -optional fields default to ``None``. The goal is to give you typed access to -the common cases without breaking when KeeperHub adds a new field. -""" - -from __future__ import annotations - -from enum import Enum -from typing import Any, Literal - -from pydantic import BaseModel, ConfigDict, Field - - -class Network(str, Enum): - """Common EVM chain IDs accepted by KeeperHub web3 actions.""" - - ETHEREUM = "1" - SEPOLIA = "11155111" - BASE = "8453" - BASE_SEPOLIA = "84532" - ARBITRUM = "42161" - POLYGON = "137" - OPTIMISM = "10" - UNICHAIN = "130" - - -class TriggerType(str, Enum): - """Workflow trigger types supported by KeeperHub.""" - - MANUAL = "Manual" - SCHEDULE = "Schedule" - WEBHOOK = "Webhook" - EVENT = "Event" - BLOCK = "Block" - - -class NodeStatus(str, Enum): - """Per-node execution status.""" - - IDLE = "idle" - PENDING = "pending" - RUNNING = "running" - SUCCESS = "success" - ERROR = "error" - SKIPPED = "skipped" - - -class ExecutionStatus(str, Enum): - """Top-level execution status.""" - - PENDING = "pending" - RUNNING = "running" - SUCCESS = "success" - ERROR = "error" - CANCELLED = "cancelled" - COMPLETED = "completed" # alias used in some MCP responses - FAILED = "failed" - - -class _AllowExtra(BaseModel): - model_config = ConfigDict(extra="allow", populate_by_name=True) - - -class NodeData(_AllowExtra): - """The ``data`` blob attached to every workflow node.""" - - label: str - description: str | None = None - type: Literal["trigger", "action", "condition", "for-each"] = "action" - config: dict[str, Any] = Field(default_factory=dict) - status: NodeStatus = NodeStatus.IDLE - - -class Node(_AllowExtra): - """A single workflow node (trigger / action / condition / for-each).""" - - id: str - type: Literal["trigger", "action", "condition", "for-each"] - data: NodeData - position: dict[str, float] | None = None # auto-laid-out by API if omitted - - -class Edge(_AllowExtra): - """An edge between two workflow nodes.""" - - id: str - source: str - target: str - sourceHandle: str | None = None # required for condition / for-each - - -class Workflow(_AllowExtra): - """A KeeperHub workflow definition.""" - - id: str | None = None - name: str - description: str | None = None - enabled: bool = True - nodes: list[Node] = Field(default_factory=list) - edges: list[Edge] = Field(default_factory=list) - - -class ExecutionLogEntry(_AllowExtra): - """One log entry inside an execution's logs array.""" - - nodeId: str - nodeName: str | None = None - nodeType: str | None = None - status: NodeStatus - input: dict[str, Any] | None = None - output: dict[str, Any] | None = None - duration: int | None = None - createdAt: str | None = None - error: str | None = None - - -class WorkflowExecution(_AllowExtra): - """An execution record returned by ``execute_workflow`` / status endpoints.""" - - id: str - workflowId: str | None = None - status: ExecutionStatus - input: dict[str, Any] | None = None - output: dict[str, Any] | None = None - nodeStatuses: list[dict[str, Any]] | None = None - progress: dict[str, Any] | None = None - createdAt: str | None = None - completedAt: str | None = None - - -class WalletIntegration(_AllowExtra): - """The wallet integration metadata required for write actions.""" - - id: str - name: str | None = None - network: str | None = None - address: str | None = None diff --git a/src/keeperkit/server/agent.py b/src/keeperkit/server/agent.py index 6e1fee7..fae8397 100644 --- a/src/keeperkit/server/agent.py +++ b/src/keeperkit/server/agent.py @@ -5,128 +5,141 @@ 1. It ships in ``langgraph`` (already a transitive dep of ``langchain``) and gives us a battle-tested ReAct loop without writing our own state machine. 2. It accepts the exact ``StructuredTool``s built by - :mod:`keeperkit.tools.langchain`. + :mod:`keeperkit.tools.langchain` — including the auto-generated per- + workflow tools. 3. It's async-friendly, so the FastAPI handler can await ``ainvoke`` without blocking the event loop. -If the hosting environment doesn't have an OpenAI API key (or another -LangChain-compatible LLM key), we still want the demo dashboard to be -useful, so we expose a deterministic fallback: an LLM-less heuristic agent -that recognizes a few common prompts (balance check, list workflows, transfer -on Sepolia, etc.) and dispatches the appropriate tool directly. This keeps -the demo server functional even without paid LLM credentials. +The model is chosen by :mod:`keeperkit.server.llm` based on which provider +key is present (OpenAI, Anthropic, Google Gemini, Groq, DeepSeek, +OpenRouter, Mistral, Together, Ollama). If no provider can be built we fall +back to a deterministic heuristic agent so the dashboard still works +offline. """ from __future__ import annotations -import os import re from typing import Any -from keeperkit.tools._common import default_dispatch, find_spec +from keeperkit.server.llm import build_llm, detect_provider +from keeperkit.tools._common import ( + STATIC_TOOL_SPECS, + build_workflow_tools, + safe_dispatch, +) ETH_ADDR = re.compile(r"0x[a-fA-F0-9]{40}") -NETWORK_KEYWORDS = { - "ethereum": "1", "mainnet": "1", "eth": "1", - "sepolia": "11155111", - "base": "8453", "base sepolia": "84532", - "arbitrum": "42161", "arb": "42161", - "polygon": "137", "matic": "137", - "optimism": "10", "op": "10", - "unichain": "130", -} -AMOUNT_RE = re.compile(r"\b(\d+(?:\.\d+)?)\s*(eth|matic|usdc|usdt|dai|tokens?)?\b", re.I) - - -def _detect_network(prompt: str) -> str | None: - p = prompt.lower() - for keyword, chain_id in NETWORK_KEYWORDS.items(): - if keyword in p: - return chain_id + + +def _by_name(name: str, specs): + for s in specs: + if s.name == name: + return s return None def _heuristic_agent(client: Any, prompt: str) -> dict[str, Any]: - """Resolve common prompts to direct KeeperHub tool calls, no LLM needed.""" + """Resolve common prompts to direct KeeperHub tool calls, no LLM needed. + + The heuristic is deliberately small — its job is to keep the demo + interactive when no LLM key is configured. It will: + + * list / inspect the workflow catalogue + * call ``helloworld`` (always free, smoke test) + * call ``aave-v3-health-check`` if the user mentions an address + health + * call ``defi-position-aggregator-base`` if they mention positions / Base + * fall back to listing workflows so the user knows what's available + """ p = prompt.lower() - network = _detect_network(prompt) or "11155111" addrs = ETH_ADDR.findall(prompt) + static = list(STATIC_TOOL_SPECS) + workflow_specs = build_workflow_tools(client) plan: list[dict[str, Any]] = [] + + list_spec = _by_name("keeperhub_list_workflows", static) + final_answer: str - if any(w in p for w in ["balance", "баланс"]): - if addrs: - spec = find_spec("keeperhub_check_balance") - res = default_dispatch(client, spec, network=network, address=addrs[0]) - plan.append({"tool": spec.name, "arguments": - {"network": network, "address": addrs[0]}, "result": res}) + if "hello" in p or "smoke" in p or "helloworld" in p: + spec = _by_name("keeperhub_helloworld", workflow_specs) + if spec is not None: + res = safe_dispatch(spec, client) + plan.append({"tool": spec.name, "arguments": {}, "result": res}) + output = (res.get("data") or {}).get("output") or {} final_answer = ( - f"Checked balance for {addrs[0]} on chain {network}. " - f"Result: {res.get('data')}" + f"Called helloworld → {output.get('result', {}).get('message')}" ) else: + res = safe_dispatch(list_spec, client) + plan.append({"tool": list_spec.name, "result": res}) final_answer = ( - "I'd need a 0x… address to check a balance — paste one in the prompt." + "helloworld not in catalogue. Listed available workflows instead." ) - elif any(w in p for w in ["list workflow", "show workflow", "workflows", "воркфлоу"]): - spec = find_spec("keeperhub_list_workflows") - res = default_dispatch(client, spec) - plan.append({"tool": spec.name, "arguments": {}, "result": res}) - final_answer = ( - f"Found {len(res.get('data') or [])} workflow(s) in this organization." - ) - - elif any(w in p for w in ["action schema", "available action", "what can"]): - spec = find_spec("keeperhub_list_action_schemas") - res = default_dispatch(client, spec, category="web3") - plan.append({"tool": spec.name, "arguments": {"category": "web3"}, - "result": res}) - final_answer = ( - f"KeeperHub web3 actions you can use: " - f"{[a.get('actionType') for a in (res.get('data') or [])]}" - ) + elif any(k in p for k in ["health", "aave", "risk"]) and addrs: + spec = _by_name("keeperhub_aave_v3_health_check", workflow_specs) + if spec is not None: + res = safe_dispatch(spec, client, address=addrs[0]) + plan.append({"tool": spec.name, "arguments": {"address": addrs[0]}, + "result": res}) + if not res.get("ok") and res.get("error") == "payment_required": + final_answer = ( + f"Aave v3 health check is paid (~{res.get('amount_usdc')} " + "atomic USDC). Pay via your x402 client and retry with " + "the X-Payment header." + ) + else: + final_answer = ( + f"Aave v3 health for {addrs[0]}: {res.get('data')}" + ) + else: + final_answer = ( + "aave-v3-health-check not in catalogue here." + ) - elif any(w in p for w in ["transfer", "send", "отправ"]) and addrs: - amount_match = AMOUNT_RE.search(prompt) - amount = amount_match.group(1) if amount_match else "0.001" - wallet_spec = find_spec("keeperhub_get_wallet_integration") - wallet_res = default_dispatch(client, wallet_spec) - wallet_id = (wallet_res.get("data") or {}).get("id", "wallet_default") - plan.append({"tool": wallet_spec.name, "arguments": {}, "result": wallet_res}) - - spec = find_spec("keeperhub_transfer_funds") - args = { - "network": network, "to_address": addrs[0], - "amount": amount, "wallet_id": wallet_id, - } - res = default_dispatch(client, spec, **args) - plan.append({"tool": spec.name, "arguments": args, "result": res}) - final_answer = ( - f"Submitted transfer of {amount} on chain {network} to {addrs[0]} " - f"via wallet {wallet_id}. Tx info: {res.get('data')}" - ) + elif any(k in p for k in ["position", "portfolio", "defi"]) and addrs: + spec = _by_name("keeperhub_defi_position_aggregator_base", + workflow_specs) + if spec is not None: + res = safe_dispatch(spec, client, wallet=addrs[0]) + plan.append({"tool": spec.name, "arguments": {"wallet": addrs[0]}, + "result": res}) + final_answer = f"DeFi positions for {addrs[0]} on Base: {res.get('data')}" + else: + final_answer = "defi-position-aggregator-base not in catalogue here." - elif "ai" in p or "generate" in p or "create workflow" in p: - spec = find_spec("keeperhub_ai_generate_workflow") - res = default_dispatch(client, spec, description=prompt) - plan.append({"tool": spec.name, "arguments": {"description": prompt}, - "result": res}) + elif any(k in p for k in ["list", "workflow", "catalogue", "что умеешь"]): + res = safe_dispatch(list_spec, client) + plan.append({"tool": list_spec.name, "result": res}) + items = ((res.get("data") or {}).get("items")) or [] final_answer = ( - f"Generated workflow: {(res.get('data') or {}).get('name')}." + f"KeeperHub exposes {len(items)} workflows. Examples: " + + ", ".join( + f"`{i.get('listedSlug')}`" + for i in items[:5] + if i.get("listedSlug") + ) + + ("…" if len(items) > 5 else "") ) else: + res = safe_dispatch(list_spec, client) + plan.append({"tool": list_spec.name, "result": res}) + items = ((res.get("data") or {}).get("items")) or [] + slugs = [i.get("listedSlug") for i in items if i.get("listedSlug")] final_answer = ( "I'm the heuristic fallback (no LLM key configured). " - "Try prompts like:\n" - " • 'Check balance of 0xabc… on Sepolia'\n" - " • 'List my KeeperHub workflows'\n" - " • 'What web3 actions does KeeperHub support?'\n" - " • 'Transfer 0.01 ETH on Sepolia to 0xdef…'\n" - " • 'Generate a workflow that sends a daily balance report'\n" - "Or set OPENAI_API_KEY to enable the full LangChain ReAct agent." + "Set OPENAI_API_KEY / ANTHROPIC_API_KEY / GOOGLE_API_KEY / " + "GROQ_API_KEY / DEEPSEEK_API_KEY / OPENROUTER_API_KEY / " + "MISTRAL_API_KEY / TOGETHER_API_KEY (or OLLAMA_BASE_URL) to " + "enable the LangChain ReAct agent. Meanwhile, try prompts like " + "'say hello via KeeperHub', 'list KeeperHub workflows', or " + "'check Aave v3 health for 0x…'. Available slugs: " + + (", ".join(f"`{s}`" for s in slugs[:8]) + + ("…" if len(slugs) > 8 else "") + or "(none discoverable from current backend)") ) return {"engine": "heuristic", "final_answer": final_answer, "trace": plan} @@ -135,30 +148,32 @@ def _heuristic_agent(client: Any, prompt: str) -> dict[str, Any]: async def _langchain_agent(client: Any, prompt: str) -> dict[str, Any]: """Full ReAct agent using LangChain + LangGraph.""" try: - from langchain_openai import ChatOpenAI from langgraph.prebuilt import create_react_agent except ImportError as exc: # pragma: no cover - import guard raise RuntimeError( - "Install `langchain` and `langchain-openai` (already in the " - "[server] extras) to use the LLM-driven agent." + "Install `langgraph` (already in the [server] extras) to use " + "the LLM-driven agent." ) from exc from keeperkit.tools.langchain import build_langchain_tools - model_name = os.environ.get("OPENAI_MODEL", "gpt-4o-mini") - llm = ChatOpenAI(model=model_name, temperature=0) + resolved = build_llm() + if resolved is None: + raise RuntimeError("No LLM provider configured.") + llm, label = resolved tools = build_langchain_tools(client) system_prompt = ( - "You are a hands-on onchain operations agent. You have access to " - "KeeperHub tools that perform reliable transaction execution: retry, " - "gas optimization, simulation-before-submit, MEV-aware private " - "routing, and a full audit trail. Prefer these tools over describing " - "what you would do — call them. When you need a wallet_id, call " - "`keeperhub_get_wallet_integration` first. When the user asks you to " - "send value, transfer tokens, or write to a contract, you MUST route " - "the action through KeeperHub. Always cite the executionId and " - "transactionLink in your final answer if the tool returns one." + "You are a hands-on onchain operations agent with access to " + "KeeperHub workflows. Each `keeperhub_*` tool corresponds to a " + "callable workflow (DeFi reads, write transactions, payments). " + "Prefer calling these tools over describing what you would do. " + "If you don't know which workflow to use, call " + "`keeperhub_list_workflows` first to inspect the catalogue, then " + "pick the right one and call it directly. If a tool returns " + "`payment_required`, summarise the x402 descriptor and stop — the " + "user's x402 client will settle and retry. Always cite the " + "`executionId` and any onchain link the tool returns." ) agent = create_react_agent(llm, tools, prompt=system_prompt) @@ -184,18 +199,18 @@ async def _langchain_agent(client: Any, prompt: str) -> dict[str, Any]: if msg_type == "ai" and content: final_answer = content if isinstance(content, str) else str(content) - return {"engine": f"langchain:{model_name}", + return {"engine": f"langchain:{label}", "final_answer": final_answer, "trace": trace} async def run_agent(client: Any, prompt: str) -> dict[str, Any]: """Entry point used by the FastAPI handler. - Picks the LangChain ReAct agent when an OpenAI key is available, otherwise - falls back to the LLM-less heuristic so the demo is always interactive. + Picks the LangChain ReAct agent when any LLM provider is configured, + otherwise falls back to the LLM-less heuristic so the demo is always + interactive. """ - has_openai = bool(os.environ.get("OPENAI_API_KEY", "").strip()) - if not has_openai: + if detect_provider() is None: return _heuristic_agent(client, prompt) try: return await _langchain_agent(client, prompt) diff --git a/src/keeperkit/server/app.py b/src/keeperkit/server/app.py index af033f3..0b30d74 100644 --- a/src/keeperkit/server/app.py +++ b/src/keeperkit/server/app.py @@ -2,12 +2,12 @@ The demo intentionally has *two* modes: -* **Direct tool mode** — ``POST /api/tools/{name}`` calls a single KeeperHub - tool with the supplied arguments. No LLM. Useful as a smoke test and as a - KeeperHub MCP-style HTTP bridge. +* **Direct tool mode** — ``POST /api/tools/{name}`` calls a single KeeperKit + tool with the supplied arguments. No LLM. The catalogue includes both the + "static" discovery / call tools and one auto-generated tool per + discoverable KeeperHub workflow. * **Agent mode** — ``POST /api/agent/run`` spins up a LangChain ReAct agent - bound to the full KeeperHub tool catalogue and forwards the user's prompt. - This is the "sees a real agent flow end-to-end" demo. + bound to the same tool catalogue and forwards the user's prompt. Both modes can run against the real KeeperHub API or the in-memory mock, controlled by ``KEEPERKIT_MODE`` (``real`` / ``mock``). @@ -28,9 +28,14 @@ from keeperkit import __version__ from keeperkit.client import KeeperHubClient -from keeperkit.exceptions import KeeperHubAPIError, KeeperHubAuthError +from keeperkit.exceptions import KeeperHubAuthError from keeperkit.mock import MockKeeperHubClient -from keeperkit.tools._common import KEEPERHUB_TOOL_SPECS, default_dispatch, find_spec +from keeperkit.server.llm import detect_provider +from keeperkit.tools._common import ( + ToolSpec, + build_all_tool_specs, + safe_dispatch, +) HERE = Path(__file__).resolve().parent TEMPLATES = Jinja2Templates(directory=str(HERE / "templates")) @@ -53,13 +58,28 @@ def _build_client() -> tuple[Any, str]: return MockKeeperHubClient(), "mock-fallback" -def _client_meta(client: Any, mode: str) -> dict[str, Any]: +def _build_tool_specs(client: Any) -> list[ToolSpec]: + """Build the full catalogue. Falls back to static tools if discovery fails.""" + try: + return build_all_tool_specs(client) + except Exception: # noqa: BLE001 - degrade to static-only on network/listing errors + from keeperkit.tools._common import STATIC_TOOL_SPECS + return list(STATIC_TOOL_SPECS) + + +def _client_meta(client: Any, mode: str, tool_count: int) -> dict[str, Any]: is_mock = isinstance(client, MockKeeperHubClient) + provider = detect_provider() return { "mode": mode, "is_mock": is_mock, "base_url": getattr(client, "base_url", "in-memory") if not is_mock else "in-memory", "version": __version__, + "llm_provider": provider or "heuristic", + "llm_model": (os.environ.get("KEEPERKIT_LLM_MODEL") + or os.environ.get("OPENAI_MODEL") + or None), + "tool_count": tool_count, } @@ -72,6 +92,15 @@ class AgentRunRequest(BaseModel): use_mock: bool | None = None # explicit override, otherwise follow env +def _spec_summary(spec: ToolSpec) -> dict[str, Any]: + return { + "name": spec.name, + "description": spec.description, + "parameters": spec.parameters, + "metadata": dict(spec.metadata or {}), + } + + def create_app() -> FastAPI: app = FastAPI( title="KeeperKit Demo", @@ -88,11 +117,23 @@ def create_app() -> FastAPI: state: dict[str, Any] = {} + def _refresh_specs() -> list[ToolSpec]: + specs = _build_tool_specs(state["client"]) + state["specs"] = specs + return specs + + def _find_spec(name: str) -> ToolSpec: + for s in state.get("specs", []): + if s.name == name: + return s + raise KeyError(f"unknown tool: {name!r}") + @app.on_event("startup") def _startup() -> None: client, mode = _build_client() state["client"] = client state["mode"] = mode + _refresh_specs() @app.on_event("shutdown") def _shutdown() -> None: @@ -106,15 +147,9 @@ def _shutdown() -> None: # ------------------------------------------------------------------ pages @app.get("/", response_class=HTMLResponse) def index(request: Request) -> Any: - meta = _client_meta(state["client"], state["mode"]) - tools = [ - { - "name": s.name, - "description": s.description, - "parameters": s.parameters, - } - for s in KEEPERHUB_TOOL_SPECS - ] + specs = state.get("specs") or _refresh_specs() + meta = _client_meta(state["client"], state["mode"], len(specs)) + tools = [_spec_summary(s) for s in specs] return TEMPLATES.TemplateResponse( request, "index.html", @@ -124,44 +159,37 @@ def index(request: Request) -> Any: # --------------------------------------------------------------- meta API @app.get("/api/health") def health() -> dict[str, Any]: - return {"ok": True, **_client_meta(state["client"], state["mode"])} + specs = state.get("specs") or [] + return {"ok": True, **_client_meta(state["client"], state["mode"], len(specs))} @app.get("/api/tools") def list_tools() -> list[dict[str, Any]]: - return [ - { - "name": s.name, - "description": s.description, - "parameters": s.parameters, - } - for s in KEEPERHUB_TOOL_SPECS - ] + return [_spec_summary(s) for s in (state.get("specs") or [])] + + @app.post("/api/tools/_refresh") + def refresh_tools() -> dict[str, Any]: + specs = _refresh_specs() + return {"ok": True, "tool_count": len(specs)} # -------------------------------------------------------- direct tool API @app.post("/api/tools/{name}") def call_tool(name: str, payload: ToolCallRequest) -> dict[str, Any]: try: - spec = find_spec(name) + spec = _find_spec(name) except KeyError as exc: raise HTTPException(404, str(exc)) from exc try: - return default_dispatch(state["client"], spec, **payload.arguments) - except KeeperHubAPIError as exc: - return {"ok": False, "error": str(exc), "status_code": exc.status_code} + return safe_dispatch(spec, state["client"], **payload.arguments) except Exception as exc: # noqa: BLE001 - surface to UI - return {"ok": False, "error": str(exc), "trace": traceback.format_exc(limit=2)} + return {"ok": False, "error": str(exc), + "trace": traceback.format_exc(limit=2)} # -------------------------------------------------------------- agent API @app.post("/api/agent/run") async def agent_run(req: AgentRunRequest) -> JSONResponse: - # Decide which client to use for THIS run (allows testing mock without - # restarting the server). if req.use_mock is True: client: Any = MockKeeperHubClient() mode = "mock" - elif req.use_mock is False: - client = state["client"] - mode = state["mode"] else: client = state["client"] mode = state["mode"] @@ -186,25 +214,23 @@ async def agent_run(req: AgentRunRequest) -> JSONResponse: @app.get("/api/elizaos/plugin.json") def elizaos_plugin() -> dict[str, Any]: from keeperkit.tools.elizaos import build_elizaos_plugin_descriptor - base_url = os.environ.get("KEEPERHUB_BASE_URL", "https://app.keeperhub.com/api") - return build_elizaos_plugin_descriptor(base_url=base_url) + base_url = os.environ.get("KEEPERHUB_BASE_URL", + "https://app.keeperhub.com/api") + return build_elizaos_plugin_descriptor(client=state["client"], + base_url=base_url) # -------------------------------------------------------- tool dispatch fwd - # ElizaOS plugin descriptor points action invocations back to /keeperkit/dispatch. @app.post("/keeperkit/dispatch") def elizaos_dispatch(request: Request, payload: dict[str, Any]) -> dict[str, Any]: tool_name = payload.get("tool") if not tool_name: raise HTTPException(400, "missing 'tool' field in dispatch payload") try: - spec = find_spec(tool_name) + spec = _find_spec(tool_name) except KeyError as exc: raise HTTPException(404, str(exc)) from exc args = payload.get("arguments") or {} - try: - return default_dispatch(state["client"], spec, **args) - except KeeperHubAPIError as exc: - return {"ok": False, "error": str(exc), "status_code": exc.status_code} + return safe_dispatch(spec, state["client"], **args) return app diff --git a/src/keeperkit/server/llm.py b/src/keeperkit/server/llm.py new file mode 100644 index 0000000..048c631 --- /dev/null +++ b/src/keeperkit/server/llm.py @@ -0,0 +1,170 @@ +"""LLM auto-detection for the demo agent. + +KeeperKit is intentionally model-agnostic — LangChain and CrewAI both work +with any chat model. The demo server resolves *one* concrete chat model at +startup based on which API key is present in the environment, in priority +order. You can also pin a specific provider / model via env vars: + +* ``KEEPERKIT_LLM_PROVIDER`` — one of: ``openai``, ``anthropic``, ``google``, + ``groq``, ``deepseek``, ``openrouter``, ``mistral``, ``together``, + ``ollama``. If unset, the first provider with a matching API key wins + (priority: anthropic → google → groq → deepseek → openrouter → mistral → + together → ollama → openai). +* ``KEEPERKIT_LLM_MODEL`` — overrides the default model for the chosen + provider. + +If no provider can be configured (no keys, no Ollama base URL), the demo +falls back to the LLM-less heuristic agent so the dashboard is still +interactive. +""" + +from __future__ import annotations + +import os +from typing import Any + +ProviderResolver = tuple[Any, str] # (chat_model, "provider:model") + + +def _norm(s: str | None) -> str: + return (s or "").strip().lower() + + +_PROVIDER_PRIORITY = ( + "anthropic", + "google", + "groq", + "deepseek", + "openrouter", + "mistral", + "together", + "ollama", + "openai", +) + + +def _provider_active(name: str) -> bool: + """Return True if the given provider has the env vars it needs.""" + if name == "openai": + return bool(os.environ.get("OPENAI_API_KEY")) + if name == "anthropic": + return bool(os.environ.get("ANTHROPIC_API_KEY")) + if name == "google": + return bool(os.environ.get("GOOGLE_API_KEY") + or os.environ.get("GEMINI_API_KEY")) + if name == "groq": + return bool(os.environ.get("GROQ_API_KEY")) + if name == "deepseek": + return bool(os.environ.get("DEEPSEEK_API_KEY")) + if name == "openrouter": + return bool(os.environ.get("OPENROUTER_API_KEY")) + if name == "mistral": + return bool(os.environ.get("MISTRAL_API_KEY")) + if name == "together": + return bool(os.environ.get("TOGETHER_API_KEY")) + if name == "ollama": + # Ollama is local — assume "active" only if explicitly opted in. + return bool(os.environ.get("OLLAMA_BASE_URL")) + return False + + +def detect_provider() -> str | None: + """Return the provider name we'd build, or None if nothing is configured.""" + explicit = _norm(os.environ.get("KEEPERKIT_LLM_PROVIDER")) + if explicit: + return explicit if _provider_active(explicit) or explicit == "ollama" else None + for name in _PROVIDER_PRIORITY: + if _provider_active(name): + return name + return None + + +def build_llm() -> ProviderResolver | None: + """Instantiate a LangChain chat model based on the environment. + + Returns ``(chat_model, "provider:model")`` or ``None`` if nothing is + configured. + """ + provider = detect_provider() + if provider is None: + return None + return _build_for_provider(provider) + + +def _build_for_provider(provider: str) -> ProviderResolver: + model = os.environ.get("KEEPERKIT_LLM_MODEL", "").strip() + + if provider == "anthropic": + from langchain_anthropic import ChatAnthropic + chosen = model or "claude-3-5-haiku-20241022" + return ChatAnthropic(model=chosen, temperature=0), f"anthropic:{chosen}" + + if provider == "google": + from langchain_google_genai import ChatGoogleGenerativeAI + # gemini-1.5-flash is being decommissioned through 2025-2026; the + # 2.0/2.5 flash models are GA on the same free-tier API key. We + # default to 2.0-flash because it has the broadest tool-calling + # support and the most generous free quota at time of writing. + chosen = model or "gemini-2.0-flash" + api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("GEMINI_API_KEY") + return (ChatGoogleGenerativeAI(model=chosen, temperature=0, + google_api_key=api_key), + f"google:{chosen}") + + if provider == "groq": + from langchain_groq import ChatGroq + chosen = model or "llama-3.3-70b-versatile" + return ChatGroq(model=chosen, temperature=0), f"groq:{chosen}" + + if provider == "deepseek": + from langchain_openai import ChatOpenAI + chosen = model or "deepseek-chat" + return (ChatOpenAI(model=chosen, temperature=0, + api_key=os.environ["DEEPSEEK_API_KEY"], + base_url="https://api.deepseek.com/v1"), + f"deepseek:{chosen}") + + if provider == "openrouter": + from langchain_openai import ChatOpenAI + chosen = model or "openai/gpt-4o-mini" + return (ChatOpenAI(model=chosen, temperature=0, + api_key=os.environ["OPENROUTER_API_KEY"], + base_url="https://openrouter.ai/api/v1", + default_headers={ + "HTTP-Referer": "https://github.com/danilaverbena/keeperkit", + "X-Title": "KeeperKit", + }), + f"openrouter:{chosen}") + + if provider == "mistral": + from langchain_mistralai import ChatMistralAI + chosen = model or "mistral-small-latest" + return ChatMistralAI(model=chosen, temperature=0), f"mistral:{chosen}" + + if provider == "together": + from langchain_openai import ChatOpenAI + chosen = model or "meta-llama/Llama-3.3-70B-Instruct-Turbo" + return (ChatOpenAI(model=chosen, temperature=0, + api_key=os.environ["TOGETHER_API_KEY"], + base_url="https://api.together.xyz/v1"), + f"together:{chosen}") + + if provider == "ollama": + from langchain_ollama import ChatOllama + chosen = model or os.environ.get("OLLAMA_MODEL", "llama3.1") + base = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434") + return (ChatOllama(model=chosen, base_url=base, temperature=0), + f"ollama:{chosen}") + + if provider == "openai": + from langchain_openai import ChatOpenAI + chosen = (model + or os.environ.get("OPENAI_MODEL", "gpt-4o-mini")) + kwargs: dict[str, Any] = {"model": chosen, "temperature": 0} + # Honor a custom OpenAI-compatible endpoint (vLLM, LM Studio, LiteLLM, + # Azure OpenAI proxy, etc.). + if base := os.environ.get("OPENAI_BASE_URL"): + kwargs["base_url"] = base + return ChatOpenAI(**kwargs), f"openai:{chosen}" + + raise RuntimeError(f"Unknown KEEPERKIT_LLM_PROVIDER: {provider!r}") diff --git a/src/keeperkit/server/templates/index.html b/src/keeperkit/server/templates/index.html index eef027a..7262e12 100644 --- a/src/keeperkit/server/templates/index.html +++ b/src/keeperkit/server/templates/index.html @@ -17,15 +17,22 @@

One import, three frameworks (LangChain · CrewAI · ElizaOS) — your AI - agent now ships transactions through - KeeperHub's - reliability layer with retry, gas opt, MEV-aware private routing, - and a full audit trail. + agent gets typed access to every + KeeperHub + workflow (DeFi reads, payments, write transactions) with built-in + x402 payment-required handling. Tools are auto-generated from the + live /api/mcp/workflows catalogue.

CrewAI

@@ -105,8 +112,8 @@

CrewAI

client = KeeperHubClient.from_env() trader = Agent( - role="Onchain trader", - goal="Execute trades reliably on Sepolia.", + role="Onchain ops", + goal="Use KeeperHub workflows to monitor and act on DeFi positions.", tools=build_crewai_tools(client), )
diff --git a/src/keeperkit/tools/__init__.py b/src/keeperkit/tools/__init__.py index e08da98..6be5481 100644 --- a/src/keeperkit/tools/__init__.py +++ b/src/keeperkit/tools/__init__.py @@ -5,6 +5,18 @@ objects expected by that framework. """ -from keeperkit.tools._common import KEEPERHUB_TOOL_SPECS, ToolSpec, default_dispatch +from keeperkit.tools._common import ( + STATIC_TOOL_SPECS, + ToolSpec, + build_all_tool_specs, + build_workflow_tools, + safe_dispatch, +) -__all__ = ["KEEPERHUB_TOOL_SPECS", "ToolSpec", "default_dispatch"] +__all__ = [ + "STATIC_TOOL_SPECS", + "ToolSpec", + "build_all_tool_specs", + "build_workflow_tools", + "safe_dispatch", +] diff --git a/src/keeperkit/tools/_common.py b/src/keeperkit/tools/_common.py index e9101b6..93ca059 100644 --- a/src/keeperkit/tools/_common.py +++ b/src/keeperkit/tools/_common.py @@ -1,34 +1,34 @@ """Framework-agnostic tool definitions consumed by the LangChain / CrewAI shims. Each tool is described once here as a :class:`ToolSpec` (name, description, -JSON schema, dispatch function), then the framework adapters turn that into -LangChain ``BaseTool`` instances or CrewAI ``Tool`` instances. This way new -frameworks can be added in one file without touching the core. +JSON schema, dispatch callable). Three "static" tools are always present — +``list_workflows``, ``call_workflow``, ``get_openapi`` — and one extra tool +is **auto-generated per discoverable workflow** in the catalogue. That way +your agent gets a typed, named tool for every KeeperHub workflow without +any hardcoding: add a new workflow on KeeperHub, refresh the tool list, +and the agent picks it up. """ from __future__ import annotations -from dataclasses import dataclass +import re +from collections.abc import Callable +from dataclasses import dataclass, field from typing import Any, Protocol -from keeperkit.models import Network - class _ClientProto(Protocol): - """Subset of the client interface our tools rely on (sync).""" - - def list_workflows(self, *, limit: int = ..., offset: int = ...) -> Any: ... - def get_workflow(self, workflow_id: str) -> Any: ... - def create_workflow(self, workflow: Any) -> Any: ... - def execute_workflow(self, workflow_id: str, - inputs: dict[str, Any] | None = ...) -> Any: ... - def get_execution_status(self, execution_id: str) -> Any: ... - def get_execution_logs(self, execution_id: str) -> Any: ... - def execute_action(self, action_type: str, config: dict[str, Any], - *, network: str | None = ...) -> Any: ... - def list_action_schemas(self, *, category: str | None = ...) -> Any: ... - def ai_generate_workflow(self, description: str, - *, modify_workflow_id: str | None = ...) -> Any: ... + """Minimal client surface our tool dispatchers rely on.""" + + def list_workflows(self) -> Any: ... + def get_openapi(self) -> Any: ... + def call_workflow(self, slug: str, body: dict[str, Any] | None = ..., + *, x_payment: str | None = ...) -> Any: ... + def list_org_workflows(self) -> Any: ... + def list_integrations(self) -> Any: ... + + +DispatchFn = Callable[..., Any] @dataclass(frozen=True) @@ -37,12 +37,13 @@ class ToolSpec: name: str description: str - parameters: dict[str, Any] # JSON Schema fragment - dispatch_key: str # which client method we proxy to + parameters: dict[str, Any] # JSON Schema fragment for the args + dispatch: DispatchFn # signature: dispatch(client, **kwargs) -> Any + metadata: dict[str, Any] = field(default_factory=dict) def _serialize(obj: Any) -> Any: - """Turn pydantic / dict / list output into something JSON-friendly.""" + """Turn arbitrary tool output into something JSON-friendly.""" if obj is None or isinstance(obj, (str, int, float, bool)): return obj if isinstance(obj, list): @@ -54,221 +55,258 @@ def _serialize(obj: Any) -> Any: return str(obj) -def default_dispatch(client: _ClientProto, spec: ToolSpec, - **kwargs: Any) -> dict[str, Any]: - """Generic dispatcher used by all framework adapters.""" - method = getattr(client, spec.dispatch_key) - result = method(**kwargs) +def _slug_to_ident(slug: str) -> str: + """Convert a workflow slug into a Python-safe identifier suffix.""" + ident = re.sub(r"[^A-Za-z0-9]+", "_", slug).strip("_").lower() + return ident or "workflow" + + +# --------------------------------------------------------------------------- +# Generic dispatch helper used by adapters (catches errors, returns dict). +# --------------------------------------------------------------------------- + + +def safe_dispatch(spec: ToolSpec, client: _ClientProto, **kwargs: Any) -> dict[str, Any]: + """Invoke ``spec.dispatch`` and wrap success / failure into a dict. + + All framework adapters route through this so the LLM sees a uniform + ``{ok, data}`` / ``{ok: false, error, ...}`` shape. + """ + from keeperkit.exceptions import KeeperHubAPIError, KeeperHubPaymentRequired + + try: + result = spec.dispatch(client, **kwargs) + except KeeperHubPaymentRequired as exc: + return { + "ok": False, + "error": "payment_required", + "message": str(exc), + "x402": exc.x402, + "amount_usdc": exc.amount_usdc, + "hint": ( + "This is a paid KeeperHub workflow. Settle via your x402 client " + "(agentcash / openclaw / custom signer) and replay the call with " + "an `X-Payment` header." + ), + } + except KeeperHubAPIError as exc: + return { + "ok": False, + "error": type(exc).__name__, + "status": exc.status_code, + "message": str(exc), + "payload": _serialize(exc.payload), + } + except Exception as exc: # noqa: BLE001 - surface unexpected failures + return {"ok": False, "error": type(exc).__name__, "message": str(exc)} + return {"ok": True, "data": _serialize(result)} # --------------------------------------------------------------------------- -# Tool catalogue. Keep these descriptions LLM-readable: the agent reads them -# verbatim to decide when to call. +# Static tools (always present) — discovery + universal call entry point. # --------------------------------------------------------------------------- -_NETWORK_DESC = ( - "EVM chain ID as a string. Common values: " - + ", ".join(f"'{n.value}' ({n.name.lower()})" for n in Network) - + "." -) -KEEPERHUB_TOOL_SPECS: tuple[ToolSpec, ...] = ( +def _dispatch_list_workflows(client: _ClientProto) -> Any: + return client.list_workflows() + + +def _dispatch_get_openapi(client: _ClientProto) -> Any: + return client.get_openapi() + + +def _dispatch_call_workflow( + client: _ClientProto, + *, + slug: str, + body: dict[str, Any] | None = None, + x_payment: str | None = None, +) -> Any: + return client.call_workflow(slug, body or {}, x_payment=x_payment) + + +def _dispatch_list_org_workflows(client: _ClientProto) -> Any: + return client.list_org_workflows() + + +def _dispatch_list_integrations(client: _ClientProto) -> Any: + return client.list_integrations() + + +def _make_call_workflow_for_slug(slug: str) -> DispatchFn: + """Return a dispatch function that calls a *fixed* workflow slug. + + Each generated tool needs its own closure so the LLM doesn't have to + pass the slug back as an argument. + """ + + def _dispatch(client: _ClientProto, **body: Any) -> Any: + x_payment = body.pop("_x_payment", None) if "_x_payment" in body else None + return client.call_workflow(slug, body, x_payment=x_payment) + + return _dispatch + + +STATIC_TOOL_SPECS: tuple[ToolSpec, ...] = ( ToolSpec( name="keeperhub_list_workflows", description=( - "List workflows already configured in your KeeperHub organization. " - "Use this to discover existing automations the agent can reuse " - "instead of building from scratch." + "Discover the public KeeperHub workflow catalogue. Returns each " + "workflow's slug, input JSON Schema, price (USDC), workflow type " + "(read|write), category, and target chain. Use this whenever you " + "need to choose which KeeperHub action to take next." ), - parameters={ - "type": "object", - "properties": { - "limit": {"type": "integer", "default": 50, "minimum": 1, "maximum": 200}, - "offset": {"type": "integer", "default": 0, "minimum": 0}, - }, - "additionalProperties": False, - }, - dispatch_key="list_workflows", - ), - ToolSpec( - name="keeperhub_get_workflow", - description="Fetch the full configuration (nodes + edges) of a workflow by id.", - parameters={ - "type": "object", - "properties": {"workflow_id": {"type": "string"}}, - "required": ["workflow_id"], - "additionalProperties": False, - }, - dispatch_key="get_workflow", - ), - ToolSpec( - name="keeperhub_execute_workflow", - description=( - "Trigger a stored KeeperHub workflow by id and return the execution " - "envelope. KeeperHub handles retries, gas optimization, simulation, " - "and private MEV-aware routing." - ), - parameters={ - "type": "object", - "properties": { - "workflow_id": {"type": "string"}, - "inputs": { - "type": "object", - "description": "Optional input payload passed to the workflow.", - }, - }, - "required": ["workflow_id"], - "additionalProperties": False, - }, - dispatch_key="execute_workflow", - ), - ToolSpec( - name="keeperhub_get_execution_status", - description=( - "Return the current status (pending/running/success/error/cancelled) " - "of an execution by id, including per-node progress." - ), - parameters={ - "type": "object", - "properties": {"execution_id": {"type": "string"}}, - "required": ["execution_id"], - "additionalProperties": False, - }, - dispatch_key="get_execution_status", - ), - ToolSpec( - name="keeperhub_get_execution_logs", - description=( - "Return per-node execution logs (input, output, duration, tx hashes) " - "for an execution. Use this to give the user an audit trail." - ), - parameters={ - "type": "object", - "properties": {"execution_id": {"type": "string"}}, - "required": ["execution_id"], - "additionalProperties": False, - }, - dispatch_key="get_execution_logs", - ), - ToolSpec( - name="keeperhub_check_balance", - description=( - "Read the native token balance of an address on a given EVM chain. " - "No wallet integration is required." - ), - parameters={ - "type": "object", - "properties": { - "network": {"type": "string", "description": _NETWORK_DESC}, - "address": {"type": "string", "description": "0x-prefixed address."}, - }, - "required": ["network", "address"], - "additionalProperties": False, - }, - dispatch_key="check_balance", + parameters={"type": "object", "properties": {}, "additionalProperties": False}, + dispatch=_dispatch_list_workflows, + metadata={"static": True}, ), ToolSpec( - name="keeperhub_transfer_funds", + name="keeperhub_call_workflow", description=( - "Send native token (ETH/MATIC/etc.) reliably. KeeperHub adds retry " - "logic, gas escalation, simulation-before-submit, and MEV-aware " - "private routing. Requires a wallet integration ID." + "Invoke any KeeperHub workflow by slug. The body must satisfy the " + "workflow's inputSchema (use keeperhub_list_workflows to look it " + "up). Returns {executionId, status, output}. Paid workflows return " + "an x402 payment-required descriptor instead of executing — settle " + "with your x402 client and pass the X-Payment token via x_payment." ), parameters={ "type": "object", "properties": { - "network": {"type": "string", "description": _NETWORK_DESC}, - "to_address": {"type": "string", "description": "0x recipient address."}, - "amount": { + "slug": { "type": "string", - "description": "Amount in ETH-units as a string (e.g. '0.1').", + "description": "Workflow slug, e.g. 'helloworld' or 'aave-v3-health-check'.", + }, + "body": { + "type": "object", + "description": "Workflow input matching its inputSchema.", + "additionalProperties": True, }, - "wallet_id": { + "x_payment": { "type": "string", - "description": "KeeperHub wallet integration id " - "(see `keeperhub_get_wallet_integration`).", + "description": "Optional x402 payment token (X-Payment header).", }, }, - "required": ["network", "to_address", "amount", "wallet_id"], + "required": ["slug"], "additionalProperties": False, }, - dispatch_key="transfer_funds", + dispatch=_dispatch_call_workflow, + metadata={"static": True}, ), ToolSpec( - name="keeperhub_write_contract", + name="keeperhub_get_openapi", description=( - "Call a state-changing contract function via KeeperHub's reliable " - "execution path." + "Fetch the KeeperHub OpenAPI document. Useful when the agent needs " + "the per-workflow JSON Schema (request/response) or the worked " + "examples in info.x-guidance." ), - parameters={ - "type": "object", - "properties": { - "network": {"type": "string", "description": _NETWORK_DESC}, - "contract_address": {"type": "string"}, - "function_name": {"type": "string"}, - "wallet_id": {"type": "string"}, - "args": {"type": "array", "items": {}}, - "value": {"type": "string", "description": "Optional native value to send."}, - }, - "required": ["network", "contract_address", "function_name", "wallet_id"], - "additionalProperties": False, - }, - dispatch_key="write_contract", - ), - ToolSpec( - name="keeperhub_list_action_schemas", - description=( - "List the action types available on KeeperHub. Filter by category: " - "web3, discord, sendgrid, webhook, system." - ), - parameters={ - "type": "object", - "properties": { - "category": { - "type": "string", - "enum": ["web3", "discord", "sendgrid", "webhook", "system"], - }, - }, - "additionalProperties": False, - }, - dispatch_key="list_action_schemas", + parameters={"type": "object", "properties": {}, "additionalProperties": False}, + dispatch=_dispatch_get_openapi, + metadata={"static": True}, ), ToolSpec( - name="keeperhub_ai_generate_workflow", + name="keeperhub_list_org_workflows", description=( - "Ask KeeperHub's built-in workflow generator to produce a workflow " - "from a natural-language description. Useful for skipping manual " - "node-by-node construction." + "List the workflows owned by *your* KeeperHub organization (the " + "one tied to your KEEPERHUB_API_KEY). Use this when you want to " + "operate only on workflows you yourself authored, not the public " + "catalogue." ), - parameters={ - "type": "object", - "properties": { - "description": {"type": "string"}, - "modify_workflow_id": { - "type": "string", - "description": "Optional id of an existing workflow to modify.", - }, - }, - "required": ["description"], - "additionalProperties": False, - }, - dispatch_key="ai_generate_workflow", + parameters={"type": "object", "properties": {}, "additionalProperties": False}, + dispatch=_dispatch_list_org_workflows, + metadata={"static": True}, ), ToolSpec( - name="keeperhub_get_wallet_integration", + name="keeperhub_list_integrations", description=( - "Return the active wallet integration metadata, including the " - "wallet_id needed by transfer/write actions." + "List wallet / connector integrations configured on your " + "KeeperHub org. Returns chain id, integration id, and connector " + "metadata. Required input for some write workflows." ), parameters={"type": "object", "properties": {}, "additionalProperties": False}, - dispatch_key="get_wallet_integration", + dispatch=_dispatch_list_integrations, + metadata={"static": True}, ), ) -def find_spec(name: str) -> ToolSpec: - for spec in KEEPERHUB_TOOL_SPECS: +# --------------------------------------------------------------------------- +# Per-workflow tool generation. +# --------------------------------------------------------------------------- + + +def build_workflow_tools(client: _ClientProto) -> list[ToolSpec]: + """Generate one :class:`ToolSpec` per discoverable workflow. + + Each tool's name is ``keeperhub_``, its parameters match + the workflow's ``inputSchema``, and its description includes the workflow + description, price, type, and chain so the LLM can pick the right one. + """ + catalogue = client.list_workflows() + items = catalogue.get("items") if isinstance(catalogue, dict) else catalogue + items = items or [] + + specs: list[ToolSpec] = [] + seen: set[str] = set() + for wf in items: + slug = wf.get("listedSlug") + if not slug: + continue + ident = _slug_to_ident(slug) + name = f"keeperhub_{ident}" + # Avoid duplicate names if multiple workflows collide on slug shape. + if name in seen: + continue + seen.add(name) + + price = wf.get("priceUsdcPerCall") + wf_type = wf.get("workflowType") or "read" + chain = wf.get("chain") + + descr_parts = [wf.get("description", "").strip() or wf.get("name", slug)] + descr_parts.append(f"[type={wf_type}, slug='{slug}'" + + (f", chain={chain}" if chain else "") + + (f", price={price} USDC" if price else ", free") + + "]") + if price: + descr_parts.append( + "Returns x402 payment_required if no X-Payment token is provided." + ) + description = " ".join(descr_parts) + + schema = wf.get("inputSchema") or {"type": "object", "properties": {}} + # JSON Schema in some catalogues lacks "type": "object" at the top level. + if "type" not in schema: + schema = {"type": "object", **schema} + + specs.append( + ToolSpec( + name=name, + description=description, + parameters=schema, + dispatch=_make_call_workflow_for_slug(slug), + metadata={ + "static": False, + "slug": slug, + "price_usdc_per_call": price, + "workflow_type": wf_type, + "chain": chain, + "category": wf.get("category"), + }, + ) + ) + return specs + + +def build_all_tool_specs(client: _ClientProto) -> list[ToolSpec]: + """Static tools + one tool per discoverable workflow.""" + return [*STATIC_TOOL_SPECS, *build_workflow_tools(client)] + + +def find_spec(name: str, specs: list[ToolSpec] | None = None) -> ToolSpec: + """Look up a spec by name. Raises :class:`KeyError` if absent.""" + haystack = specs if specs is not None else list(STATIC_TOOL_SPECS) + for spec in haystack: if spec.name == name: return spec - raise KeyError(f"Unknown KeeperHub tool: {name}") + raise KeyError(f"unknown tool: {name!r}") diff --git a/src/keeperkit/tools/crewai.py b/src/keeperkit/tools/crewai.py index 97496e5..ec8688a 100644 --- a/src/keeperkit/tools/crewai.py +++ b/src/keeperkit/tools/crewai.py @@ -16,9 +16,8 @@ backstory="You delegate every transaction to KeeperHub.", ) -CrewAI builds on top of pydantic BaseTool, so the surface mirrors LangChain -fairly closely; we still keep a separate factory because CrewAI imports -``crewai.tools.BaseTool`` and expects ``_run`` semantics. +Like the LangChain factory, this returns a tool *per* discoverable +KeeperHub workflow plus the static discovery / call tools. """ from __future__ import annotations @@ -26,7 +25,11 @@ from collections.abc import Iterable from typing import Any -from keeperkit.tools._common import KEEPERHUB_TOOL_SPECS, ToolSpec, default_dispatch +from keeperkit.tools._common import ( + ToolSpec, + build_all_tool_specs, + safe_dispatch, +) def _import_crewai(): @@ -53,17 +56,20 @@ def _import_crewai(): def _model_from_jsonschema(name: str, schema: dict[str, Any]): _, BaseModel, Field, create_model = _import_crewai() - properties = schema.get("properties", {}) - required = set(schema.get("required", [])) + properties = schema.get("properties", {}) or {} + required = set(schema.get("required", []) or []) fields: dict[str, Any] = {} for prop_name, prop_schema in properties.items(): + if not isinstance(prop_schema, dict): + continue py_type = _TYPE_MAP.get(prop_schema.get("type", "string"), str) description = prop_schema.get("description", "") default = prop_schema.get("default", ...) if prop_name not in required and default is ...: default = None py_type = py_type | None # type: ignore[operator] - fields[prop_name] = (py_type, Field(default=default, description=description)) + safe_name = prop_name.lstrip("_") or prop_name + fields[safe_name] = (py_type, Field(default=default, description=description)) if not fields: fields["noop"] = ( str | None, # type: ignore[operator] @@ -84,18 +90,28 @@ class _KeeperHubCrewTool(BaseTool): # type: ignore[misc] def _run(self, **kwargs: Any) -> dict[str, Any]: kwargs.pop("noop", None) clean = {k: v for k, v in kwargs.items() if v is not None} - return default_dispatch(client, spec, **clean) + return safe_dispatch(spec, client, **clean) _KeeperHubCrewTool.__name__ = f"KeeperHub_{spec.name}_Tool" return _KeeperHubCrewTool -def build_crewai_tools(client: Any, - *, only: Iterable[str] | None = None) -> list[Any]: - """Return a list of CrewAI ``BaseTool`` instances bound to ``client``.""" +def build_crewai_tools( + client: Any, + *, + only: Iterable[str] | None = None, + include_per_workflow: bool = True, +) -> list[Any]: + """Return CrewAI ``BaseTool`` instances bound to ``client``.""" + if include_per_workflow: + specs = build_all_tool_specs(client) + else: + from keeperkit.tools._common import STATIC_TOOL_SPECS + specs = list(STATIC_TOOL_SPECS) + keep = set(only) if only else None - tools = [] - for spec in KEEPERHUB_TOOL_SPECS: + tools: list[Any] = [] + for spec in specs: if keep is not None and spec.name not in keep: continue tool_cls = _make_tool_class(client, spec) diff --git a/src/keeperkit/tools/elizaos.py b/src/keeperkit/tools/elizaos.py index 0aa33c3..005c05c 100644 --- a/src/keeperkit/tools/elizaos.py +++ b/src/keeperkit/tools/elizaos.py @@ -9,6 +9,10 @@ * The tool catalogue, descriptions, and JSON Schemas are defined once in :mod:`keeperkit.tools._common` and shared across LangChain, CrewAI, and ElizaOS — there is no risk of drift between frameworks. +* The descriptor includes both the static tools (``keeperhub_call_workflow``, + ``keeperhub_list_workflows``, …) **and** an action per discoverable + workflow when a client is supplied — so an ElizaOS character can call + ``keeperhub_microtip`` directly with typed args. * It keeps the demo server self-contained — no Node toolchain required to produce the artifact builders can drop into their ElizaOS character. """ @@ -19,35 +23,60 @@ from pathlib import Path from typing import Any -from keeperkit.tools._common import KEEPERHUB_TOOL_SPECS +from keeperkit.tools._common import ( + STATIC_TOOL_SPECS, + ToolSpec, + build_all_tool_specs, +) -def build_elizaos_plugin_descriptor(*, base_url: str = "https://app.keeperhub.com/api", - version: str = "0.1.0") -> dict[str, Any]: - """Return a JSON-serializable ElizaOS plugin descriptor for KeeperHub.""" - actions = [] - for spec in KEEPERHUB_TOOL_SPECS: - actions.append({ - "name": spec.name, - "description": spec.description, - "parameters": spec.parameters, - "dispatch": { - "kind": "http", - "method": "POST", - "url": f"{base_url}/keeperkit/dispatch", - "auth": "bearer:KEEPERHUB_API_KEY", - "body": {"tool": spec.name}, - }, - }) +def _action_from_spec(spec: ToolSpec, *, base_url: str) -> dict[str, Any]: + return { + "name": spec.name, + "description": spec.description, + "parameters": spec.parameters, + "metadata": dict(spec.metadata or {}), + "dispatch": { + "kind": "http", + "method": "POST", + "url": f"{base_url.rstrip('/')}/keeperkit/dispatch", + "auth": "bearer:KEEPERHUB_API_KEY", + "body": {"tool": spec.name}, + }, + } + + +def build_elizaos_plugin_descriptor( + *, + client: Any | None = None, + base_url: str = "https://app.keeperhub.com/api", + version: str = "0.1.0", +) -> dict[str, Any]: + """Return a JSON-serializable ElizaOS plugin descriptor for KeeperHub. + + Args: + client: When supplied, the descriptor will additionally include an + action per discoverable KeeperHub workflow (auto-generated from + ``client.list_workflows()``). When ``None``, only the static + tools are included — useful for offline descriptor generation. + base_url: Where the descriptor's ``dispatch`` URL should point. + version: Plugin version. + """ + if client is not None: + specs: list[ToolSpec] = build_all_tool_specs(client) + else: + specs = list(STATIC_TOOL_SPECS) + + actions = [_action_from_spec(s, base_url=base_url) for s in specs] return { "name": "@keeperkit/elizaos-plugin", "version": version, "description": ( - "KeeperHub onchain execution plugin for ElizaOS. Routes agent " - "transactions through KeeperHub's reliable execution layer " - "(retry, gas optimization, MEV-aware private routing, audit " - "trail, x402 / MPP payment rails)." + "KeeperHub workflow plugin for ElizaOS. Gives your character " + "typed access to the public KeeperHub workflow catalogue (DeFi " + "reads, payments, write transactions) with built-in x402 " + "payment-required handling." ), "homepage": "https://github.com/danilaverbena/keeperkit", "actions": actions, @@ -60,13 +89,20 @@ def build_elizaos_plugin_descriptor(*, base_url: str = "https://app.keeperhub.co } -def write_elizaos_plugin(out_path: str | Path, - *, base_url: str = "https://app.keeperhub.com/api") -> Path: +def write_elizaos_plugin( + out_path: str | Path, + *, + client: Any | None = None, + base_url: str = "https://app.keeperhub.com/api", +) -> Path: """Write the ElizaOS plugin descriptor JSON to disk and return the path.""" path = Path(out_path) path.parent.mkdir(parents=True, exist_ok=True) path.write_text( - json.dumps(build_elizaos_plugin_descriptor(base_url=base_url), indent=2), + json.dumps( + build_elizaos_plugin_descriptor(client=client, base_url=base_url), + indent=2, + ), encoding="utf-8", ) return path diff --git a/src/keeperkit/tools/langchain.py b/src/keeperkit/tools/langchain.py index 686704e..6cd642b 100644 --- a/src/keeperkit/tools/langchain.py +++ b/src/keeperkit/tools/langchain.py @@ -12,9 +12,19 @@ agent = create_react_agent(ChatOpenAI(model="gpt-4o-mini"), tools) The factory returns a list of :class:`langchain_core.tools.StructuredTool` -objects bound to the supplied client. Each tool maps 1:1 to a KeeperHub -operation and surfaces the parameters declared in -``KEEPERHUB_TOOL_SPECS``. +objects bound to the supplied client. The list contains: + +* a small set of *static* tools — discovery (``keeperhub_list_workflows``, + ``keeperhub_get_openapi``, …), a universal ``keeperhub_call_workflow``, + and ``keeperhub_list_org_workflows`` / ``keeperhub_list_integrations`` + for builders. +* one *auto-generated* tool per discoverable workflow — name + ``keeperhub_``, parameters built from the workflow's + ``inputSchema``, description annotated with workflow type / chain / + price. + +Add a new workflow on KeeperHub → call ``build_langchain_tools(client)`` +again → the new tool appears with no code changes. """ from __future__ import annotations @@ -22,7 +32,11 @@ from collections.abc import Iterable from typing import Any -from keeperkit.tools._common import KEEPERHUB_TOOL_SPECS, ToolSpec, default_dispatch +from keeperkit.tools._common import ( + ToolSpec, + build_all_tool_specs, + safe_dispatch, +) def _import_langchain(): @@ -50,17 +64,21 @@ def _import_langchain(): def _model_from_jsonschema(name: str, schema: dict[str, Any]): """Build a pydantic model class from a tool's JSON Schema parameters.""" _, BaseModel, Field, create_model = _import_langchain() - properties = schema.get("properties", {}) - required = set(schema.get("required", [])) + properties = schema.get("properties", {}) or {} + required = set(schema.get("required", []) or []) fields: dict[str, Any] = {} for prop_name, prop_schema in properties.items(): + if not isinstance(prop_schema, dict): + continue py_type = _TYPE_MAP.get(prop_schema.get("type", "string"), str) description = prop_schema.get("description", "") default = prop_schema.get("default", ...) if prop_name not in required and default is ...: default = None py_type = py_type | None # type: ignore[operator] - fields[prop_name] = (py_type, Field(default=default, description=description)) + # Sanitize field name for pydantic (drop leading underscores etc.). + safe_name = prop_name.lstrip("_") or prop_name + fields[safe_name] = (py_type, Field(default=default, description=description)) if not fields: fields["noop"] = ( str | None, # type: ignore[operator] @@ -75,9 +93,8 @@ def _make_tool(client: Any, spec: ToolSpec): def _run(**kwargs: Any) -> dict[str, Any]: kwargs.pop("noop", None) - # Only forward keys that are not None to keep client signatures clean. clean = {k: v for k, v in kwargs.items() if v is not None} - return default_dispatch(client, spec, **clean) + return safe_dispatch(spec, client, **clean) return StructuredTool.from_function( func=_run, @@ -87,19 +104,28 @@ def _run(**kwargs: Any) -> dict[str, Any]: ) -def build_langchain_tools(client: Any, - *, only: Iterable[str] | None = None) -> list[Any]: +def build_langchain_tools( + client: Any, + *, + only: Iterable[str] | None = None, + include_per_workflow: bool = True, +) -> list[Any]: """Return a list of LangChain ``StructuredTool``s bound to ``client``. Args: client: A :class:`KeeperHubClient` or :class:`MockKeeperHubClient`. only: Optional iterable of tool names to include. If omitted, all KeeperHub tools are returned. + include_per_workflow: When True (default), additionally generate one + tool per discoverable workflow in ``client.list_workflows()``. + Set to False to keep just the five static tools (e.g. when you + don't want to make a network round-trip at startup). """ + if include_per_workflow: + specs = build_all_tool_specs(client) + else: + from keeperkit.tools._common import STATIC_TOOL_SPECS + specs = list(STATIC_TOOL_SPECS) + keep = set(only) if only else None - tools = [] - for spec in KEEPERHUB_TOOL_SPECS: - if keep is not None and spec.name not in keep: - continue - tools.append(_make_tool(client, spec)) - return tools + return [_make_tool(client, s) for s in specs if keep is None or s.name in keep] diff --git a/src/keeperkit/workflow.py b/src/keeperkit/workflow.py deleted file mode 100644 index d58eb52..0000000 --- a/src/keeperkit/workflow.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Tiny workflow builder DSL. - -KeeperHub's REST API takes nodes + edges directly, but writing those by hand -is verbose and error-prone. ``WorkflowBuilder`` lets you compose a workflow -with a couple of method calls: - - wf = ( - WorkflowBuilder("Daily DCA") - .manual_trigger() - .action("check", "web3/check-balance", - network=Network.SEPOLIA, address="0x...") - .build() - ) - -Then either ``client.create_workflow(wf)`` to persist it or -``client.execute_action(...)`` for one-shots. -""" - -from __future__ import annotations - -import uuid -from typing import Any - -from keeperkit.models import Edge, Network, Node, NodeData, NodeStatus, TriggerType, Workflow - - -def _gen_id(prefix: str) -> str: - return f"{prefix}-{uuid.uuid4().hex[:6]}" - - -class WorkflowBuilder: - """Fluent builder for :class:`Workflow`.""" - - def __init__(self, name: str, description: str | None = None) -> None: - self.name = name - self.description = description - self.nodes: list[Node] = [] - self.edges: list[Edge] = [] - self._last_id: str | None = None - self._enabled = True - - def disabled(self) -> WorkflowBuilder: - self._enabled = False - return self - - # --------------------------------------------------------------------- triggers - def manual_trigger(self, label: str = "Manual") -> WorkflowBuilder: - node = Node( - id=_gen_id("trigger"), - type="trigger", - data=NodeData( - label=label, - type="trigger", - config={"triggerType": TriggerType.MANUAL.value}, - ), - ) - self.nodes.append(node) - self._last_id = node.id - return self - - def schedule_trigger(self, cron: str, label: str = "Schedule") -> WorkflowBuilder: - node = Node( - id=_gen_id("trigger"), - type="trigger", - data=NodeData( - label=label, - type="trigger", - config={"triggerType": TriggerType.SCHEDULE.value, "cron": cron}, - ), - ) - self.nodes.append(node) - self._last_id = node.id - return self - - def webhook_trigger(self, label: str = "Webhook") -> WorkflowBuilder: - node = Node( - id=_gen_id("trigger"), - type="trigger", - data=NodeData( - label=label, - type="trigger", - config={"triggerType": TriggerType.WEBHOOK.value}, - ), - ) - self.nodes.append(node) - self._last_id = node.id - return self - - # ----------------------------------------------------------------------- nodes - def action(self, label: str, action_type: str, - *, network: Network | str | None = None, - after: str | None = None, - **config: Any) -> WorkflowBuilder: - cfg: dict[str, Any] = {"actionType": action_type, **config} - if network is not None: - cfg["network"] = network.value if isinstance(network, Network) else network - node = Node( - id=_gen_id("action"), - type="action", - data=NodeData(label=label, type="action", config=cfg, status=NodeStatus.IDLE), - ) - self.nodes.append(node) - prev = after or self._last_id - if prev is not None: - self.edges.append(Edge(id=_gen_id("edge"), source=prev, target=node.id)) - self._last_id = node.id - return self - - def condition(self, label: str, expression: str, *, - after: str | None = None) -> WorkflowBuilder: - node = Node( - id=_gen_id("cond"), - type="condition", - data=NodeData( - label=label, type="condition", - config={"expression": expression}, - ), - ) - self.nodes.append(node) - prev = after or self._last_id - if prev is not None: - self.edges.append(Edge(id=_gen_id("edge"), source=prev, target=node.id)) - self._last_id = node.id - return self - - def edge(self, source: str, target: str, - *, source_handle: str | None = None) -> WorkflowBuilder: - self.edges.append(Edge( - id=_gen_id("edge"), - source=source, target=target, sourceHandle=source_handle, - )) - return self - - # ----------------------------------------------------------------------- build - def build(self) -> Workflow: - return Workflow( - name=self.name, - description=self.description, - enabled=self._enabled, - nodes=list(self.nodes), - edges=list(self.edges), - ) diff --git a/tests/fixtures/keeperhub_catalogue.json b/tests/fixtures/keeperhub_catalogue.json new file mode 100644 index 0000000..677dcff --- /dev/null +++ b/tests/fixtures/keeperhub_catalogue.json @@ -0,0 +1 @@ +{"items":[{"id":"5667q8uw1rq4y723t548n","name":"DeFi Position Aggregator: Wallet on Ethereum","description":"Aggregates a wallet's DeFi positions across the major lending and yield venues KeeperHub supports on Ethereum mainnet in a single call - Aave V3, Spark (DAI ecosystem), Compound V3 USDC, Lido stETH + ","listedSlug":"defi-position-aggregator-ethereum","listedAt":"2026-05-02T13:28:17.944Z","inputSchema":{"type":"object","required":["wallet"],"properties":{"wallet":{"type":"string","description":"Wallet address (0x...) to aggregate DeFi positions for. Must be a valid Ethereum-compatible EVM address."}},"additionalProperties":false},"outputMapping":{"field":"result","nodeId":"combine"},"priceUsdcPerCall":null,"organizationId":"2b1975b7-1ed9-4496-bd20-a09cfbbb3fc5","createdAt":"2026-05-02T13:23:24.785Z","updatedAt":"2026-05-02T13:49:06.128Z","isListed":true,"workflowType":"read","category":"defi","chain":"1"},{"id":"lwmi2d6rf22fiu7l572z6","name":"DeFi Position Aggregator: Wallet on Base","description":"Aggregates a wallet's DeFi positions across the major lending and yield venues KeeperHub supports on Base in a single call - Aave V3 (with health factor), Compound V3 USDC market, Morpho Steakhouse US","listedSlug":"defi-position-aggregator-base","listedAt":"2026-05-02T13:20:31.747Z","inputSchema":{"type":"object","required":["wallet"],"properties":{"wallet":{"type":"string","description":"Wallet address (0x...) to aggregate DeFi positions for. Must be a valid Base-compatible EVM address."}},"additionalProperties":false},"outputMapping":{"field":"result","nodeId":"combine"},"priceUsdcPerCall":null,"organizationId":"2b1975b7-1ed9-4496-bd20-a09cfbbb3fc5","createdAt":"2026-05-02T13:15:58.096Z","updatedAt":"2026-05-02T13:20:31.747Z","isListed":true,"workflowType":"read","category":"defi","chain":"8453"},{"id":"zaajy1vtndskm5bs120zb","name":"ARYA Position Health Monitor","description":"Monitors DeFi LP positions on 0G Galileo for impermanent loss, value drops, and out-of-range conditions. Fires webhook alert to ARYA backend when position health deteriorates. Template — duplicate for","listedSlug":null,"listedAt":"2026-05-02T01:38:31.875Z","inputSchema":{"type":"object","required":["positionTokenId","poolAddress","userWallet","positionManagerAddress","webhookUrl"],"properties":{"userWallet":{"type":"string","description":"User wallet address to monitor"},"webhookUrl":{"type":"string","description":"ARYA backend alert webhook URL"},"ilThreshold":{"type":"number","default":5,"description":"IL percentage threshold for alerts"},"poolAddress":{"type":"string","description":"Pool contract address on 0G Galileo"},"positionTokenId":{"type":"string","description":"Uniswap V3 NFT position token ID"},"valueDropThreshold":{"type":"number","default":10,"description":"Value drop percentage threshold"},"notificationWebhook":{"type":"string","description":"User notification webhook (Discord/Telegram)"},"positionManagerAddress":{"type":"string","description":"NonfungiblePositionManager address"}}},"outputMapping":null,"priceUsdcPerCall":null,"organizationId":"149bbc91-7a18-4a3b-b3bc-a77b1cb16416","createdAt":"2026-05-02T01:35:49.204Z","updatedAt":"2026-05-02T11:29:01.074Z","isListed":true,"workflowType":"read","category":null,"chain":null},{"id":"dikj8rdcfep0zr4weo2sg","name":"Open Deal Watchdog — anomaly detection on 0G audit anchors","description":"Watchdog: every /30 * this workflow polls the Open Deal anomaly endpoint, which itself reads the AuditAnchor contract on 0G Galileo, downloads each new audit JSON from 0G Storage, and runs four heuris","listedSlug":null,"listedAt":"2026-05-01T22:59:20.053Z","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputMapping":null,"priceUsdcPerCall":null,"organizationId":"3713a146-c055-4d8d-ab92-225f3e2161c1","createdAt":"2026-05-01T22:59:19.757Z","updatedAt":"2026-05-01T22:59:20.053Z","isListed":true,"workflowType":"read","category":null,"chain":null},{"id":"33dxuyp740v6shxv83ocl","name":"Sepolia WETH Deposit (write test)","description":"Test workflow: web3/write-contract on Sepolia WETH9 deposit() to reproduce Issues 1-3","listedSlug":"test-sepolia-weth-deposit-issue2","listedAt":"2026-05-01T20:01:06.443Z","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputMapping":null,"priceUsdcPerCall":null,"organizationId":"6938492d-c261-42e6-985e-61581d659c4c","createdAt":"2026-05-01T20:00:51.230Z","updatedAt":"2026-05-01T20:03:42.642Z","isListed":true,"workflowType":"write","category":"defi","chain":"11155111"},{"id":"ytnpxvaaymld9md2mlr49","name":"Stablecoin Yield Compare: USDC on Base","description":"Compares supply yield on USDC across the three blue-chip stablecoin venues KeeperHub supports on Base — Aave V3, Compound V3, and the Steakhouse USDC vault on Morpho. Manual / read-only; designed for ","listedSlug":"stablecoin-yield-compare-base","listedAt":"2026-05-01T05:41:30.033Z","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputMapping":{"field":"result","nodeId":"combine"},"priceUsdcPerCall":"0.05","organizationId":"2b1975b7-1ed9-4496-bd20-a09cfbbb3fc5","createdAt":"2026-05-01T04:10:04.446Z","updatedAt":"2026-05-02T12:49:49.612Z","isListed":true,"workflowType":"read","category":"defi","chain":"base"},{"id":"pbkb9kru29wuddz5qasck","name":"Simple Workflow","description":"Simple for testing lendpay workflow gateway execution","listedSlug":"simple-workflow","listedAt":"2026-04-30T16:38:40.703Z","inputSchema":{"type":"object","properties":{"txid":{"type":"string","description":"tx hash"},"amount":{"type":"number","description":"payment amount"}}},"outputMapping":{"field":"","nodeId":"D4JMl_5qoQNeXdQg7LvOH"},"priceUsdcPerCall":"0","organizationId":"dcfc626e-21e3-4a69-99c8-ffb7c99e8eee","createdAt":"2026-04-24T17:11:48.158Z","updatedAt":"2026-05-01T00:08:34.838Z","isListed":true,"workflowType":"read","category":null,"chain":null},{"id":"l06xkzw7v7a0s9tkgrpcn","name":"aegis-settle","description":"Quorum: anyone can call settle on AegisContract once reveal deadline passes.","listedSlug":"quorum-aegis-settle-v3","listedAt":"2026-04-30T14:41:29.795Z","inputSchema":{"type":"object","required":["jobId"],"properties":{"jobId":{"type":"string"}}},"outputMapping":null,"priceUsdcPerCall":null,"organizationId":"MJ2WVPK0iFi8PLMEeaWnBFccgAOjtQpi","createdAt":"2026-04-30T11:42:34.831Z","updatedAt":"2026-04-30T19:00:07.811Z","isListed":true,"workflowType":"write","category":"verification","chain":"16602"},{"id":"1c9wia09sm657buohrque","name":"aegis-reveal-vote","description":"Quorum: verifier reveals their vote on AegisContract.","listedSlug":"quorum-aegis-reveal-v3","listedAt":"2026-04-30T14:41:29.535Z","inputSchema":{"type":"object","required":["jobId","verdict","nonce"],"properties":{"jobId":{"type":"string"},"nonce":{"type":"string"},"verdict":{"type":"string"}}},"outputMapping":null,"priceUsdcPerCall":null,"organizationId":"MJ2WVPK0iFi8PLMEeaWnBFccgAOjtQpi","createdAt":"2026-04-30T11:42:34.598Z","updatedAt":"2026-04-30T19:00:07.539Z","isListed":true,"workflowType":"write","category":"verification","chain":"16602"},{"id":"txesk4tzs7ty6z0ecg5qi","name":"aegis-commit-vote","description":"Quorum: verifier commits a vote hash to AegisContract on 0G Galileo.","listedSlug":"quorum-aegis-commit-v3","listedAt":"2026-04-30T14:41:29.193Z","inputSchema":{"type":"object","required":["jobId","commitHash"],"properties":{"jobId":{"type":"string"},"commitHash":{"type":"string"}}},"outputMapping":null,"priceUsdcPerCall":null,"organizationId":"MJ2WVPK0iFi8PLMEeaWnBFccgAOjtQpi","createdAt":"2026-04-30T11:42:34.372Z","updatedAt":"2026-04-30T19:00:07.245Z","isListed":true,"workflowType":"write","category":"verification","chain":"16602"},{"id":"r6rw07tv12arr2iefd1nk","name":"aegis-settle","description":"Quorum: anyone can call settle on AegisContract once reveal deadline passes.","listedSlug":"quorum-aegis-settle-v2","listedAt":"2026-04-30T14:41:28.619Z","inputSchema":{"type":"object","required":["jobId"],"properties":{"jobId":{"type":"string"}}},"outputMapping":null,"priceUsdcPerCall":null,"organizationId":"kRa1R68aN4L6mx4el4I0RzRJg7ncIFae","createdAt":"2026-04-30T11:42:33.916Z","updatedAt":"2026-04-30T19:00:06.760Z","isListed":true,"workflowType":"write","category":"verification","chain":"16602"},{"id":"1cksgolhevppfowxuppoi","name":"aegis-reveal-vote","description":"Quorum: verifier reveals their vote on AegisContract.","listedSlug":"quorum-aegis-reveal-v2","listedAt":"2026-04-30T14:41:28.316Z","inputSchema":{"type":"object","required":["jobId","verdict","nonce"],"properties":{"jobId":{"type":"string"},"nonce":{"type":"string"},"verdict":{"type":"string"}}},"outputMapping":null,"priceUsdcPerCall":null,"organizationId":"kRa1R68aN4L6mx4el4I0RzRJg7ncIFae","createdAt":"2026-04-30T11:42:33.548Z","updatedAt":"2026-04-30T19:00:06.515Z","isListed":true,"workflowType":"write","category":"verification","chain":"16602"},{"id":"6tlny1gi7gqn20xrc2ox6","name":"aegis-commit-vote","description":"Quorum: verifier commits a vote hash to AegisContract on 0G Galileo.","listedSlug":"quorum-aegis-commit-v2","listedAt":"2026-04-30T14:41:27.984Z","inputSchema":{"type":"object","required":["jobId","commitHash"],"properties":{"jobId":{"type":"string"},"commitHash":{"type":"string"}}},"outputMapping":null,"priceUsdcPerCall":null,"organizationId":"kRa1R68aN4L6mx4el4I0RzRJg7ncIFae","createdAt":"2026-04-30T11:42:33.074Z","updatedAt":"2026-04-30T19:00:06.286Z","isListed":true,"workflowType":"write","category":"verification","chain":"16602"},{"id":"ms7lyf4r7jcs0bb7oxweh","name":"aegis-settle","description":"Quorum: anyone can call settle on AegisContract once reveal deadline passes.","listedSlug":"quorum-aegis-settle-v1","listedAt":"2026-04-30T14:41:27.414Z","inputSchema":{"type":"object","required":["jobId"],"properties":{"jobId":{"type":"string"}}},"outputMapping":null,"priceUsdcPerCall":null,"organizationId":"da6b0f9e-bed9-44a0-90ae-6e723dc22b4a","createdAt":"2026-04-30T11:42:32.416Z","updatedAt":"2026-04-30T19:00:05.831Z","isListed":true,"workflowType":"write","category":"verification","chain":"16602"},{"id":"iavdq94g0seo7ubvtrm7q","name":"aegis-reveal-vote","description":"Quorum: verifier reveals their vote on AegisContract.","listedSlug":"quorum-aegis-reveal-v1","listedAt":"2026-04-30T14:41:27.033Z","inputSchema":{"type":"object","required":["jobId","verdict","nonce"],"properties":{"jobId":{"type":"string"},"nonce":{"type":"string"},"verdict":{"type":"string"}}},"outputMapping":null,"priceUsdcPerCall":null,"organizationId":"da6b0f9e-bed9-44a0-90ae-6e723dc22b4a","createdAt":"2026-04-30T11:42:32.179Z","updatedAt":"2026-04-30T19:00:05.590Z","isListed":true,"workflowType":"write","category":"verification","chain":"16602"},{"id":"7c611oldnlfch263ug9he","name":"aegis-commit-vote","description":"Quorum: verifier commits a vote hash to AegisContract on 0G Galileo.","listedSlug":"quorum-aegis-commit-v1","listedAt":"2026-04-30T14:41:26.666Z","inputSchema":{"type":"object","required":["jobId","commitHash"],"properties":{"jobId":{"type":"string"},"commitHash":{"type":"string"}}},"outputMapping":null,"priceUsdcPerCall":null,"organizationId":"da6b0f9e-bed9-44a0-90ae-6e723dc22b4a","createdAt":"2026-04-30T09:53:45.819Z","updatedAt":"2026-05-01T09:53:30.071Z","isListed":true,"workflowType":"write","category":"verification","chain":"16602"},{"id":"rogyug1lg4fys8vf7eawe","name":"Zwarm Strategy Runner V2 (personal · 0x5623D4a6…) · tokenId 83","description":"Personal copy of the Zwarm V2 strategy runner with 0x5623D4a6A316Cf9b16fF92808E3931E17CcB960C hardcoded. LLM-curated USDC yield rotation across Aave / Compound / Morpho on Base mainnet. Per-rebalance ","listedSlug":"zwarm-strategy-runner-v2-personal-0x5623d4a6-tokenid-83-v1","listedAt":"2026-04-29T13:15:42.218Z","inputSchema":null,"outputMapping":null,"priceUsdcPerCall":"0","organizationId":"57e92293-91b6-4b33-b194-b8909be20827","createdAt":"2026-04-29T13:14:02.074Z","updatedAt":"2026-04-29T13:15:43.177Z","isListed":true,"workflowType":"read","category":null,"chain":null},{"id":"5wkdnqomzzicxbu91wtmu","name":"Zwarm Strategy Runner V2 (personal · 0x5623D4a6…) · tokenId 83","description":"Personal copy of the Zwarm V2 strategy runner with 0x5623D4a6A316Cf9b16fF92808E3931E17CcB960C hardcoded. LLM-curated USDC yield rotation across Aave / Compound / Morpho on Base mainnet. Per-rebalance ","listedSlug":"zwarm-strategy-runner-v2-personal-0x5623d4a6-tokenid-83","listedAt":"2026-04-29T13:08:20.089Z","inputSchema":null,"outputMapping":null,"priceUsdcPerCall":"0","organizationId":"57e92293-91b6-4b33-b194-b8909be20827","createdAt":"2026-04-29T12:54:38.147Z","updatedAt":"2026-04-29T13:08:20.089Z","isListed":true,"workflowType":"read","category":null,"chain":null},{"id":"f1rq5h53nwylfywdle3j7","name":"Aave v3 Health Check","description":"Checks the health of an Aave v3 position for any wallet address on Ethereum mainnet. Returns health factor (normalized), total collateral in USD, total debt in USD, available borrow capacity, and a pl","listedSlug":"aave-v3-health-check","listedAt":"2026-04-29T09:11:21.091Z","inputSchema":{"type":"object","properties":{"address":{"type":"string","description":"EVM wallet address to check"}}},"outputMapping":{"field":"","nodeId":"normalize"},"priceUsdcPerCall":"0.01","organizationId":"EDKxp3XqFCvHfKhm7JdKj8H9kMJ0iZT2","createdAt":"2026-04-27T10:46:16.033Z","updatedAt":"2026-04-29T09:12:07.717Z","isListed":true,"workflowType":"read","category":null,"chain":null},{"id":"75vufta6j0up6q1tcghcm","name":"Zwarm Strategy Runner V2","description":"LLM-curated USDC yield rotation across Aave / Compound / Morpho on Base mainnet. Calls Zwarm's decision engine (zwarm.com/api/decide) via webhook; routes the decision through a 4-pair execution matrix","listedSlug":"zwarm-strategy-runner-v2","listedAt":"2026-04-27T21:08:31.429Z","inputSchema":null,"outputMapping":null,"priceUsdcPerCall":"0","organizationId":"57e92293-91b6-4b33-b194-b8909be20827","createdAt":"2026-04-27T21:04:54.205Z","updatedAt":"2026-04-28T08:24:50.473Z","isListed":true,"workflowType":"read","category":null,"chain":null}],"total":29,"page":1,"limit":20} \ No newline at end of file diff --git a/tests/fixtures/keeperhub_openapi.json b/tests/fixtures/keeperhub_openapi.json new file mode 100644 index 0000000..25333ba --- /dev/null +++ b/tests/fixtures/keeperhub_openapi.json @@ -0,0 +1 @@ +{"openapi":"3.1.0","info":{"title":"KeeperHub","version":"1.0.0","description":"Web3 workflow automation platform. Workflows are callable by AI agents via REST or MCP.","x-guidance":"KeeperHub exposes workflows as REST endpoints under /api/mcp/workflows/{slug}/call.\nEach workflow has a slug, accepts a JSON body, and returns a JSON response with `executionId`, `status`, and `output`.\nAuth: paid workflows return HTTP 402 with x402/MPP payment info on the first call; pay (e.g. via agentcash, openclaw, or any x402 client) and replay. Free workflows can be called directly.\nCategories of workflows include: DeFi yield/risk reads (e.g. usdc-yield-rates-aave-vs-compound, aave-v3-health-check, defi-risk-snapshot), tipping primitives (microtip), and write-type workflows that return unsigned calldata for the caller to sign and broadcast.\n\n## Worked examples\n\n### Example 1 — Compare USDC yield (paid, $0.01)\nPOST /api/mcp/workflows/usdc-yield-rates-aave-vs-compound/call\nBody: {}\nResponse (after payment): { executionId, status: \"success\", output: { result: { rates: [...], bestRate, bestProtocol } } }\n\n### Example 2 — Aave v3 health check (paid, $0.01)\nPOST /api/mcp/workflows/aave-v3-health-check/call\nBody: { \"address\": \"0x...\" }\nResponse (after payment): { executionId, status: \"success\", output: { result: { healthFactor, totalCollateralUSD, totalDebtUSD, riskLevel } } }\n\n### Example 3 — Discover available workflows (free)\nGET /api/mcp/workflows (returns the list of all listed workflows + pricing)\nGET /openapi.json (this document — full schema for every workflow)\n\nWhen in doubt, fetch /openapi.json and read the per-workflow `x-payment-info`, `security`, and `requestBody` fields."},"x-service-info":{"categories":["web3","automation","blockchain"],"docs":{"homepage":"https://docs.keeperhub.com"}},"servers":[{"url":"https://app.keeperhub.com"}],"paths":{"/api/mcp/workflows/helloworld/call":{"post":{"operationId":"call-helloworld","summary":"HelloWorld","description":"Simple workflow that returns Hello, World! message.","security":[],"responses":{"200":{"description":"Workflow execution started","content":{"application/json":{"schema":{"type":"object","properties":{"executionId":{"type":"string"},"status":{"type":"string","const":"running"}}}}}}}}},"/api/mcp/workflows/microtip/call":{"post":{"operationId":"call-microtip","summary":"Microtip","description":"Receive small tip of $0.01","x-payment-info":{"price":{"mode":"fixed","amount":"0.01","currency":"USD"},"protocols":[{"x402":{"network":"eip155:8453"}},{"mpp":{"method":"tempo","intent":"charge","currency":"USDC"}}]},"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"description":"Workflow execution started","content":{"application/json":{"schema":{"type":"object","properties":{"executionId":{"type":"string"},"status":{"type":"string","const":"running"}}}}}},"402":{"description":"Payment Required"}}}},"/api/mcp/workflows/usdc-yield-rates-aave-vs-compound/call":{"post":{"operationId":"call-usdc-yield-rates-aave-vs-compound","summary":"USDC Yield Rates: Aave vs Compound","description":"Fetches current USDC supply rates from Aave v3 and Compound v3 on Ethereum mainnet and returns a ranked comparison. No input required. Aave rate comes from liquidityRate (ray, 1e27). Compound rate is ","x-payment-info":{"price":{"mode":"fixed","amount":"0.01","currency":"USD"},"protocols":[{"x402":{"network":"eip155:8453"}},{"mpp":{"method":"tempo","intent":"charge","currency":"USDC"}}]},"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"description":"Workflow execution started","content":{"application/json":{"schema":{"type":"object","properties":{"executionId":{"type":"string"},"status":{"type":"string","const":"running"}}}}}},"402":{"description":"Payment Required"}}}},"/api/mcp/workflows/pack-0-10-demo/call":{"post":{"operationId":"call-pack-0-10-demo","summary":"Pack 0.10 (Demo)","description":"Demo transaction: A $0.10 payment is submitted and automatically returned to the user. In production, a platform fee would be deducted, the remaining balance used to execute the task, and any unused f","x-payment-info":{"price":{"mode":"fixed","amount":"0.10","currency":"USD"},"protocols":[{"x402":{"network":"eip155:8453"}},{"mpp":{"method":"tempo","intent":"charge","currency":"USDC"}}]},"requestBody":{"required":false,"content":{"application/json":{"schema":{"type":"object"}}}},"responses":{"200":{"description":"Workflow execution started","content":{"application/json":{"schema":{"type":"object","properties":{"executionId":{"type":"string"},"status":{"type":"string","const":"running"}}}}}},"402":{"description":"Payment Required"}}}},"/api/mcp/workflows/yield-read-morpho-get-market/call":{"post":{"operationId":"call-yield-read-morpho-get-market","summary":"Yield read: morpho/get-market","description":"Live on-chain yield read for Alpha","security":[],"responses":{"200":{"description":"Workflow execution started","content":{"application/json":{"schema":{"type":"object","properties":{"executionId":{"type":"string"},"status":{"type":"string","const":"running"}}}}}}}}},"/api/mcp/workflows/zwarm-test-sepolia-balance-check/call":{"post":{"operationId":"call-zwarm-test-sepolia-balance-check","summary":"Zwarm Test: Sepolia Balance Check","description":"Alpha's first real KeeperHub execution — read-only","security":[],"responses":{"200":{"description":"Workflow execution started","content":{"application/json":{"schema":{"type":"object","properties":{"executionId":{"type":"string"},"status":{"type":"string","const":"running"}}}}}}}}},"/api/mcp/workflows/zwarm-strategy-runner-v2/call":{"post":{"operationId":"call-zwarm-strategy-runner-v2","summary":"Zwarm Strategy Runner V2","description":"LLM-curated USDC yield rotation across Aave / Compound / Morpho on Base mainnet. Calls Zwarm's decision engine (zwarm.com/api/decide) via webhook; routes the decision through a 4-pair execution matrix","security":[],"responses":{"200":{"description":"Workflow execution started","content":{"application/json":{"schema":{"type":"object","properties":{"executionId":{"type":"string"},"status":{"type":"string","const":"running"}}}}}}}}},"/api/mcp/workflows/zwarm-strategy-runner/call":{"post":{"operationId":"call-zwarm-strategy-runner","summary":"Zwarm Strategy Runner","description":"LLM-curated USDC yield rotation across Aave / Compound / Morpho on Base mainnet. Calls Zwarm's decision engine via webhook (zwarm.com/api/decide) and routes the result through KH execution primitives.","security":[],"responses":{"200":{"description":"Workflow execution started","content":{"application/json":{"schema":{"type":"object","properties":{"executionId":{"type":"string"},"status":{"type":"string","const":"running"}}}}}}}}},"/api/mcp/workflows/defi-risk-snapshot/call":{"post":{"operationId":"call-defi-risk-snapshot","summary":"DeFi Risk Snapshot","description":"Checks Aave v3 position health for a wallet address across both Ethereum mainnet and Base simultaneously. Returns a consolidated risk report: all active positions, whether any are at risk (health fact","x-payment-info":{"price":{"mode":"fixed","amount":"0.01","currency":"USD"},"protocols":[{"x402":{"network":"eip155:8453"}},{"mpp":{"method":"tempo","intent":"charge","currency":"USDC"}}]},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"address":{"type":"string","description":"EVM wallet address to check (0x...)"}}}}}},"responses":{"200":{"description":"Workflow execution started","content":{"application/json":{"schema":{"type":"object","properties":{"executionId":{"type":"string"},"status":{"type":"string","const":"running"}}}}}},"402":{"description":"Payment Required"}}}},"/api/mcp/workflows/aave-v3-health-check/call":{"post":{"operationId":"call-aave-v3-health-check","summary":"Aave v3 Health Check","description":"Checks the health of an Aave v3 position for any wallet address on Ethereum mainnet. Returns health factor (normalized), total collateral in USD, total debt in USD, available borrow capacity, and a pl","x-payment-info":{"price":{"mode":"fixed","amount":"0.01","currency":"USD"},"protocols":[{"x402":{"network":"eip155:8453"}},{"mpp":{"method":"tempo","intent":"charge","currency":"USDC"}}]},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"address":{"type":"string","description":"EVM wallet address to check"}}}}}},"responses":{"200":{"description":"Workflow execution started","content":{"application/json":{"schema":{"type":"object","properties":{"executionId":{"type":"string"},"status":{"type":"string","const":"running"}}}}}},"402":{"description":"Payment Required"}}}},"/api/mcp/workflows/zwarm-strategy-runner-v2-personal-0x5623d4a6-tokenid-83/call":{"post":{"operationId":"call-zwarm-strategy-runner-v2-personal-0x5623d4a6-tokenid-83","summary":"Zwarm Strategy Runner V2 (personal · 0x5623D4a6…) · tokenId 83","description":"Personal copy of the Zwarm V2 strategy runner with 0x5623D4a6A316Cf9b16fF92808E3931E17CcB960C hardcoded. LLM-curated USDC yield rotation across Aave / Compound / Morpho on Base mainnet. Per-rebalance ","security":[],"responses":{"200":{"description":"Workflow execution started","content":{"application/json":{"schema":{"type":"object","properties":{"executionId":{"type":"string"},"status":{"type":"string","const":"running"}}}}}}}}},"/api/mcp/workflows/zwarm-strategy-runner-v2-personal-0x5623d4a6-tokenid-83-v1/call":{"post":{"operationId":"call-zwarm-strategy-runner-v2-personal-0x5623d4a6-tokenid-83-v1","summary":"Zwarm Strategy Runner V2 (personal · 0x5623D4a6…) · tokenId 83","description":"Personal copy of the Zwarm V2 strategy runner with 0x5623D4a6A316Cf9b16fF92808E3931E17CcB960C hardcoded. LLM-curated USDC yield rotation across Aave / Compound / Morpho on Base mainnet. Per-rebalance ","security":[],"responses":{"200":{"description":"Workflow execution started","content":{"application/json":{"schema":{"type":"object","properties":{"executionId":{"type":"string"},"status":{"type":"string","const":"running"}}}}}}}}},"/api/mcp/workflows/mcp-test/call":{"post":{"operationId":"call-mcp-test","summary":"MCP Test: Prod (Updated)","description":"Updated via MCP","x-payment-info":{"price":{"mode":"fixed","amount":"0.01","currency":"USD"},"protocols":[{"x402":{"network":"eip155:8453"}},{"mpp":{"method":"tempo","intent":"charge","currency":"USDC"}}]},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["address"],"properties":{"address":{"type":"string","description":"eth address"}}}}}},"responses":{"200":{"description":"Workflow execution started","content":{"application/json":{"schema":{"type":"object","properties":{"executionId":{"type":"string"},"status":{"type":"string","const":"running"}}}}}},"402":{"description":"Payment Required"}}}},"/api/mcp/workflows/quorum-aegis-commit-v2/call":{"post":{"operationId":"call-quorum-aegis-commit-v2","summary":"aegis-commit-vote","description":"Quorum: verifier commits a vote hash to AegisContract on 0G Galileo.","security":[],"x-workflow-type":"write","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["jobId","commitHash"],"properties":{"jobId":{"type":"string"},"commitHash":{"type":"string"}}}}}},"responses":{"200":{"description":"Unsigned transaction calldata","content":{"application/json":{"schema":{"type":"object","properties":{"type":{"type":"string","const":"calldata"},"to":{"type":"string"},"data":{"type":"string"},"value":{"type":"string"}}}}}}}}},"/api/mcp/workflows/quorum-aegis-reveal-v3/call":{"post":{"operationId":"call-quorum-aegis-reveal-v3","summary":"aegis-reveal-vote","description":"Quorum: verifier reveals their vote on AegisContract.","security":[],"x-workflow-type":"write","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["jobId","verdict","nonce"],"properties":{"jobId":{"type":"string"},"nonce":{"type":"string"},"verdict":{"type":"string"}}}}}},"responses":{"200":{"description":"Unsigned transaction calldata","content":{"application/json":{"schema":{"type":"object","properties":{"type":{"type":"string","const":"calldata"},"to":{"type":"string"},"data":{"type":"string"},"value":{"type":"string"}}}}}}}}},"/api/mcp/workflows/quorum-aegis-settle-v3/call":{"post":{"operationId":"call-quorum-aegis-settle-v3","summary":"aegis-settle","description":"Quorum: anyone can call settle on AegisContract once reveal deadline passes.","security":[],"x-workflow-type":"write","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["jobId"],"properties":{"jobId":{"type":"string"}}}}}},"responses":{"200":{"description":"Unsigned transaction calldata","content":{"application/json":{"schema":{"type":"object","properties":{"type":{"type":"string","const":"calldata"},"to":{"type":"string"},"data":{"type":"string"},"value":{"type":"string"}}}}}}}}},"/api/mcp/workflows/quorum-aegis-reveal-v1/call":{"post":{"operationId":"call-quorum-aegis-reveal-v1","summary":"aegis-reveal-vote","description":"Quorum: verifier reveals their vote on AegisContract.","security":[],"x-workflow-type":"write","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["jobId","verdict","nonce"],"properties":{"jobId":{"type":"string"},"nonce":{"type":"string"},"verdict":{"type":"string"}}}}}},"responses":{"200":{"description":"Unsigned transaction calldata","content":{"application/json":{"schema":{"type":"object","properties":{"type":{"type":"string","const":"calldata"},"to":{"type":"string"},"data":{"type":"string"},"value":{"type":"string"}}}}}}}}},"/api/mcp/workflows/quorum-aegis-reveal-v2/call":{"post":{"operationId":"call-quorum-aegis-reveal-v2","summary":"aegis-reveal-vote","description":"Quorum: verifier reveals their vote on AegisContract.","security":[],"x-workflow-type":"write","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["jobId","verdict","nonce"],"properties":{"jobId":{"type":"string"},"nonce":{"type":"string"},"verdict":{"type":"string"}}}}}},"responses":{"200":{"description":"Unsigned transaction calldata","content":{"application/json":{"schema":{"type":"object","properties":{"type":{"type":"string","const":"calldata"},"to":{"type":"string"},"data":{"type":"string"},"value":{"type":"string"}}}}}}}}},"/api/mcp/workflows/quorum-aegis-settle-v2/call":{"post":{"operationId":"call-quorum-aegis-settle-v2","summary":"aegis-settle","description":"Quorum: anyone can call settle on AegisContract once reveal deadline passes.","security":[],"x-workflow-type":"write","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["jobId"],"properties":{"jobId":{"type":"string"}}}}}},"responses":{"200":{"description":"Unsigned transaction calldata","content":{"application/json":{"schema":{"type":"object","properties":{"type":{"type":"string","const":"calldata"},"to":{"type":"string"},"data":{"type":"string"},"value":{"type":"string"}}}}}}}}},"/api/mcp/workflows/quorum-aegis-settle-v1/call":{"post":{"operationId":"call-quorum-aegis-settle-v1","summary":"aegis-settle","description":"Quorum: anyone can call settle on AegisContract once reveal deadline passes.","security":[],"x-workflow-type":"write","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["jobId"],"properties":{"jobId":{"type":"string"}}}}}},"responses":{"200":{"description":"Unsigned transaction calldata","content":{"application/json":{"schema":{"type":"object","properties":{"type":{"type":"string","const":"calldata"},"to":{"type":"string"},"data":{"type":"string"},"value":{"type":"string"}}}}}}}}},"/api/mcp/workflows/quorum-aegis-commit-v3/call":{"post":{"operationId":"call-quorum-aegis-commit-v3","summary":"aegis-commit-vote","description":"Quorum: verifier commits a vote hash to AegisContract on 0G Galileo.","security":[],"x-workflow-type":"write","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["jobId","commitHash"],"properties":{"jobId":{"type":"string"},"commitHash":{"type":"string"}}}}}},"responses":{"200":{"description":"Unsigned transaction calldata","content":{"application/json":{"schema":{"type":"object","properties":{"type":{"type":"string","const":"calldata"},"to":{"type":"string"},"data":{"type":"string"},"value":{"type":"string"}}}}}}}}},"/api/mcp/workflows/quorum-aegis-commit-v1/call":{"post":{"operationId":"call-quorum-aegis-commit-v1","summary":"aegis-commit-vote","description":"Quorum: verifier commits a vote hash to AegisContract on 0G Galileo.","security":[],"x-workflow-type":"write","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["jobId","commitHash"],"properties":{"jobId":{"type":"string"},"commitHash":{"type":"string"}}}}}},"responses":{"200":{"description":"Unsigned transaction calldata","content":{"application/json":{"schema":{"type":"object","properties":{"type":{"type":"string","const":"calldata"},"to":{"type":"string"},"data":{"type":"string"},"value":{"type":"string"}}}}}}}}},"/api/mcp/workflows/simple-workflow/call":{"post":{"operationId":"call-simple-workflow","summary":"Simple Workflow","description":"Simple for testing lendpay workflow gateway execution","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"txid":{"type":"string","description":"tx hash"},"amount":{"type":"number","description":"payment amount"}}}}}},"responses":{"200":{"description":"Workflow execution started","content":{"application/json":{"schema":{"type":"object","properties":{"executionId":{"type":"string"},"status":{"type":"string","const":"running"}}}}}}}}},"/api/mcp/workflows/test-sepolia-weth-deposit-issue2/call":{"post":{"operationId":"call-test-sepolia-weth-deposit-issue2","summary":"Sepolia WETH Deposit (write test)","description":"Test workflow: web3/write-contract on Sepolia WETH9 deposit() to reproduce Issues 1-3","security":[],"x-workflow-type":"write","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{},"additionalProperties":false}}}},"responses":{"200":{"description":"Unsigned transaction calldata","content":{"application/json":{"schema":{"type":"object","properties":{"type":{"type":"string","const":"calldata"},"to":{"type":"string"},"data":{"type":"string"},"value":{"type":"string"}}}}}}}}},"/api/mcp/workflows/stablecoin-yield-compare-base/call":{"post":{"operationId":"call-stablecoin-yield-compare-base","summary":"Stablecoin Yield Compare: USDC on Base","description":"Compares supply yield on USDC across the three blue-chip stablecoin venues KeeperHub supports on Base — Aave V3, Compound V3, and the Steakhouse USDC vault on Morpho. Manual / read-only; designed for ","x-payment-info":{"price":{"mode":"fixed","amount":"0.05","currency":"USD"},"protocols":[{"x402":{"network":"eip155:8453"}},{"mpp":{"method":"tempo","intent":"charge","currency":"USDC"}}]},"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{},"additionalProperties":false}}}},"responses":{"200":{"description":"Workflow execution started","content":{"application/json":{"schema":{"type":"object","properties":{"executionId":{"type":"string"},"status":{"type":"string","const":"running"}}}}}},"402":{"description":"Payment Required"}}}},"/api/mcp/workflows/defi-position-aggregator-base/call":{"post":{"operationId":"call-defi-position-aggregator-base","summary":"DeFi Position Aggregator: Wallet on Base","description":"Aggregates a wallet's DeFi positions across the major lending and yield venues KeeperHub supports on Base in a single call - Aave V3 (with health factor), Compound V3 USDC market, Morpho Steakhouse US","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["wallet"],"properties":{"wallet":{"type":"string","description":"Wallet address (0x...) to aggregate DeFi positions for. Must be a valid Base-compatible EVM address."}},"additionalProperties":false}}}},"responses":{"200":{"description":"Workflow execution started","content":{"application/json":{"schema":{"type":"object","properties":{"executionId":{"type":"string"},"status":{"type":"string","const":"running"}}}}}}}}},"/api/mcp/workflows/defi-position-aggregator-ethereum/call":{"post":{"operationId":"call-defi-position-aggregator-ethereum","summary":"DeFi Position Aggregator: Wallet on Ethereum","description":"Aggregates a wallet's DeFi positions across the major lending and yield venues KeeperHub supports on Ethereum mainnet in a single call - Aave V3, Spark (DAI ecosystem), Compound V3 USDC, Lido stETH + ","security":[],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["wallet"],"properties":{"wallet":{"type":"string","description":"Wallet address (0x...) to aggregate DeFi positions for. Must be a valid Ethereum-compatible EVM address."}},"additionalProperties":false}}}},"responses":{"200":{"description":"Workflow execution started","content":{"application/json":{"schema":{"type":"object","properties":{"executionId":{"type":"string"},"status":{"type":"string","const":"running"}}}}}}}}}}} \ No newline at end of file diff --git a/tests/test_elizaos.py b/tests/test_elizaos.py new file mode 100644 index 0000000..16d8501 --- /dev/null +++ b/tests/test_elizaos.py @@ -0,0 +1,32 @@ +"""ElizaOS plugin descriptor tests.""" + +from __future__ import annotations + +from keeperkit import MockKeeperHubClient +from keeperkit.tools.elizaos import ( + build_elizaos_plugin_descriptor, + write_elizaos_plugin, +) + + +def test_static_only_descriptor_includes_core_actions(): + desc = build_elizaos_plugin_descriptor() + names = {a["name"] for a in desc["actions"]} + assert {"keeperhub_list_workflows", "keeperhub_call_workflow"} <= names + assert all(a["dispatch"]["kind"] == "http" for a in desc["actions"]) + + +def test_descriptor_with_client_includes_per_workflow_actions(): + client = MockKeeperHubClient() + desc = build_elizaos_plugin_descriptor(client=client) + names = {a["name"] for a in desc["actions"]} + assert "keeperhub_helloworld" in names + assert "keeperhub_aave_v3_health_check" in names + + +def test_write_elizaos_plugin_to_disk(tmp_path): + out = write_elizaos_plugin(tmp_path / "plugin.json", + client=MockKeeperHubClient()) + assert out.exists() + text = out.read_text() + assert "keeperhub_helloworld" in text diff --git a/tests/test_langchain_tools.py b/tests/test_langchain_tools.py index 2ae30a2..bb28bb3 100644 --- a/tests/test_langchain_tools.py +++ b/tests/test_langchain_tools.py @@ -1,28 +1,69 @@ -"""Smoke tests for the LangChain adapter, skipped if langchain-core missing.""" +"""LangChain adapter tests.""" + from __future__ import annotations import pytest -pytest.importorskip("langchain_core") - from keeperkit import MockKeeperHubClient -from keeperkit.tools.langchain import build_langchain_tools +from keeperkit.tools._common import STATIC_TOOL_SPECS + +langchain_core = pytest.importorskip("langchain_core") + + +def test_build_langchain_tools_includes_static_and_workflow(): + from keeperkit.tools.langchain import build_langchain_tools + + client = MockKeeperHubClient() + tools = build_langchain_tools(client) + names = {t.name for t in tools} + + static_names = {s.name for s in STATIC_TOOL_SPECS} + assert static_names <= names + assert "keeperhub_helloworld" in names + assert "keeperhub_aave_v3_health_check" in names + + +def test_workflow_tool_runs_against_mock(): + from keeperkit.tools.langchain import build_langchain_tools + + client = MockKeeperHubClient() + tools = build_langchain_tools(client) + helloworld = next(t for t in tools if t.name == "keeperhub_helloworld") + + out = helloworld.invoke({}) + assert out["ok"] is True + assert out["data"]["status"] == "success" + + +def test_paid_workflow_tool_returns_payment_required(): + from keeperkit.tools.langchain import build_langchain_tools + + client = MockKeeperHubClient() + tools = build_langchain_tools(client) + aave = next(t for t in tools if t.name == "keeperhub_aave_v3_health_check") + + out = aave.invoke({"address": "0x0000000000000000000000000000000000000000"}) + assert out["ok"] is False + assert out["error"] == "payment_required" + assert out["x402"]["x402Version"] == 2 + + +def test_static_call_workflow_tool_can_call_anything(): + from keeperkit.tools.langchain import build_langchain_tools + + client = MockKeeperHubClient() + tools = build_langchain_tools(client) + universal = next(t for t in tools if t.name == "keeperhub_call_workflow") + + out = universal.invoke({"slug": "helloworld", "body": {}}) + assert out["ok"] is True + assert out["data"]["output"]["result"]["message"] == "Hello World!" + +def test_include_per_workflow_false_keeps_only_static(): + from keeperkit.tools.langchain import build_langchain_tools -def test_builds_tools_against_mock(): - tools = build_langchain_tools(MockKeeperHubClient()) + client = MockKeeperHubClient() + tools = build_langchain_tools(client, include_per_workflow=False) names = {t.name for t in tools} - assert "keeperhub_check_balance" in names - assert "keeperhub_execute_workflow" in names - - -def test_tool_invocation_returns_ok(): - tools = build_langchain_tools(MockKeeperHubClient(), - only=["keeperhub_check_balance"]) - tool = tools[0] - result = tool.invoke({ - "network": "11155111", - "address": "0x0000000000000000000000000000000000000001", - }) - assert result["ok"] is True - assert "data" in result + assert names == {s.name for s in STATIC_TOOL_SPECS} diff --git a/tests/test_mock.py b/tests/test_mock.py index 6351733..fcefbdd 100644 --- a/tests/test_mock.py +++ b/tests/test_mock.py @@ -1,69 +1,83 @@ -from keeperkit import MockKeeperHubClient, Network, WorkflowBuilder -from keeperkit.exceptions import KeeperHubNotFoundError +"""Tests for the in-memory MockKeeperHubClient and its public-API parity.""" +from __future__ import annotations -def test_create_and_execute_workflow(): +import json +from pathlib import Path + +import pytest + +from keeperkit import MockKeeperHubClient +from keeperkit.exceptions import KeeperHubNotFoundError, KeeperHubPaymentRequired + +FIXTURES = Path(__file__).parent / "fixtures" + + +def test_default_catalogue_is_non_empty(): client = MockKeeperHubClient() - wf = ( - WorkflowBuilder("test") - .manual_trigger() - .action("balance", "web3/check-balance", - network=Network.SEPOLIA, - address="0x0000000000000000000000000000000000000001") - .build() - ) - saved = client.create_workflow(wf) - assert saved.id is not None - assert len(saved.nodes) == 2 - assert len(saved.edges) == 1 + cat = client.list_workflows() + assert cat["items"] + # Each entry has the fields production /api/mcp/workflows returns. + for wf in cat["items"]: + assert {"id", "name", "listedSlug", "inputSchema", "workflowType"} <= set(wf) - execution = client.execute_workflow(saved.id) - assert execution.status.value == "success" - assert execution.workflowId == saved.id - logs = client.get_execution_logs(execution.id) - assert len(logs) == 1 - assert logs[0].status.value == "success" - assert logs[0].output is not None - assert logs[0].output["balance"] == "0.1234" +def test_real_catalogue_fixture_is_well_formed(): + """Smoke test: the saved real-API snapshot still parses.""" + raw = json.loads((FIXTURES / "keeperhub_catalogue.json").read_text()) + assert "items" in raw + # Use the real snapshot as a mock catalogue and verify key methods. + client = MockKeeperHubClient(catalogue=raw["items"]) + cat = client.list_workflows() + assert len(cat["items"]) == len(raw["items"]) -def test_check_balance_shortcut(): +def test_helloworld_is_free_and_returns_executionid(): client = MockKeeperHubClient() - out = client.check_balance("11155111", "0xabcdef0123456789abcdef0123456789abcdef01") - assert out["balance"] == "0.1234" - assert "executionId" in out + res = client.call_workflow("helloworld", {}) + assert res["status"] == "success" + assert "executionId" in res + assert res["output"]["result"]["message"] == "Hello World!" -def test_missing_workflow_404(): +def test_paid_workflow_raises_payment_required_without_token(): client = MockKeeperHubClient() - try: - client.get_workflow("wf_does_not_exist") - except KeeperHubNotFoundError: - pass - else: - raise AssertionError("expected KeeperHubNotFoundError") + with pytest.raises(KeeperHubPaymentRequired) as excinfo: + client.call_workflow("aave-v3-health-check", + {"address": "0x000000000000000000000000000000000000dEaD"}) + err = excinfo.value + assert err.x402["x402Version"] == 2 + assert err.x402["accepts"][0]["network"] == "eip155:8453" + assert err.amount_usdc # not None -def test_transfer_returns_tx_metadata(): +def test_paid_workflow_succeeds_with_token(): client = MockKeeperHubClient() - wallet = client.get_wallet_integration() - execution = client.transfer_funds( - "11155111", - "0x9c8f005ab27adb94f3d49020a15722db2fcd9f27", - "0.001", - wallet.id, + res = client.call_workflow( + "aave-v3-health-check", + {"address": "0x000000000000000000000000000000000000dEaD"}, + x_payment="mock-token", ) - assert execution.status.value == "success" - output = execution.output or {} - assert output["status"] == "confirmed" - assert output["private"] is True - assert output["txHash"].startswith("0x") - assert output["transactionLink"].startswith("https://") + assert res["status"] == "success" + assert "healthFactor" in res["output"]["result"] + + +def test_unknown_slug_raises_not_found(): + client = MockKeeperHubClient() + with pytest.raises(KeeperHubNotFoundError): + client.call_workflow("does-not-exist", {}) + + +def test_get_openapi_describes_listed_workflows(): + client = MockKeeperHubClient() + spec = client.get_openapi() + paths = spec["paths"] + assert "/api/mcp/workflows/helloworld/call" in paths + schema = paths["/api/mcp/workflows/aave-v3-health-check/call"]["post"] + assert schema["summary"] == "Aave v3 Health Check" -def test_ai_generate_workflow_creates_real_workflow(): +def test_org_endpoints_return_lists(): client = MockKeeperHubClient() - wf = client.ai_generate_workflow("send daily balance report") - assert wf.id is not None - assert any(n.type == "action" for n in wf.nodes) + assert isinstance(client.list_org_workflows(), list) + assert isinstance(client.list_integrations(), list) diff --git a/tests/test_real_client_errors.py b/tests/test_real_client_errors.py index 6dc609d..89e9fa5 100644 --- a/tests/test_real_client_errors.py +++ b/tests/test_real_client_errors.py @@ -1,48 +1,128 @@ -"""Verify the REST client handles auth / status errors correctly.""" +"""Verify the real KeeperHubClient maps HTTP statuses to typed exceptions. + +These tests use respx to fake the upstream API — no network required. +""" + from __future__ import annotations -import pytest +import base64 +import json -respx = pytest.importorskip("respx") import httpx +import pytest +import respx from keeperkit import KeeperHubClient from keeperkit.exceptions import ( + KeeperHubAPIError, KeeperHubAuthError, KeeperHubNotFoundError, - KeeperHubValidationError, + KeeperHubPaymentRequired, ) -def _client(api_key="kh_test", base_url="https://kh-test.example/api"): - return KeeperHubClient(api_key=api_key, base_url=base_url, max_retries=0) +@pytest.fixture +def client(monkeypatch: pytest.MonkeyPatch) -> KeeperHubClient: + monkeypatch.setenv("KEEPERHUB_API_KEY", "kh_test") + return KeeperHubClient.from_env(max_retries=0, backoff_initial=0) + + +@respx.mock +def test_list_workflows_happy_path(client: KeeperHubClient): + respx.get("https://app.keeperhub.com/api/mcp/workflows").mock( + return_value=httpx.Response(200, json={"items": [{"listedSlug": "helloworld"}]}) + ) + cat = client.list_workflows() + assert cat == {"items": [{"listedSlug": "helloworld"}]} + + +@respx.mock +def test_call_workflow_success(client: KeeperHubClient): + respx.post( + "https://app.keeperhub.com/api/mcp/workflows/helloworld/call" + ).mock(return_value=httpx.Response( + 200, json={"executionId": "exec_1", "status": "success", + "output": {"result": {"message": "Hello World!"}}} + )) + res = client.call_workflow("helloworld", {}) + assert res["executionId"] == "exec_1" + assert res["output"]["result"]["message"] == "Hello World!" @respx.mock -def test_401_raises_auth_error(): - respx.get("https://kh-test.example/api/workflows").mock( - return_value=httpx.Response(401, json={"error": "bad token"}) +def test_call_workflow_402_raises_payment_required(client: KeeperHubClient): + descriptor = { + "x402Version": 2, + "error": "Payment required", + "resource": {"url": "x", "description": "y", "mimeType": "application/json"}, + "accepts": [{ + "scheme": "exact", + "network": "eip155:8453", + "asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + "amount": "10000", + "payTo": "0xpayto", + }], + } + encoded = base64.b64encode(json.dumps(descriptor).encode()).decode() + respx.post( + "https://app.keeperhub.com/api/mcp/workflows/aave-v3-health-check/call" + ).mock(return_value=httpx.Response( + 402, + headers={ + "x-payment-requirements": encoded, + "www-authenticate": 'Payment id="abc"', + }, + json=descriptor, + )) + + with pytest.raises(KeeperHubPaymentRequired) as excinfo: + client.call_workflow("aave-v3-health-check", + {"address": "0x000000000000000000000000000000000000dEaD"}) + err = excinfo.value + assert err.amount_usdc == "10000" + assert err.www_authenticate.startswith("Payment ") + assert err.x402["accepts"][0]["network"] == "eip155:8453" + + +@respx.mock +def test_401_raises_auth_error(client: KeeperHubClient): + respx.get("https://app.keeperhub.com/api/mcp/workflows").mock( + return_value=httpx.Response(401, json={"error": "bad key"}) ) - with _client() as c: - with pytest.raises(KeeperHubAuthError): - c.list_workflows() + with pytest.raises(KeeperHubAuthError) as excinfo: + client.list_workflows() + assert "bad key" in str(excinfo.value) + + +@respx.mock +def test_404_raises_not_found(client: KeeperHubClient): + respx.post( + "https://app.keeperhub.com/api/mcp/workflows/missing/call" + ).mock(return_value=httpx.Response(404, json={"error": "not found"})) + with pytest.raises(KeeperHubNotFoundError): + client.call_workflow("missing", {}) @respx.mock -def test_404_raises_not_found(): - respx.get("https://kh-test.example/api/workflows/wf_x").mock( - return_value=httpx.Response(404, json={"error": "missing"}) +def test_5xx_eventually_raises(client: KeeperHubClient): + respx.get("https://app.keeperhub.com/api/mcp/workflows").mock( + return_value=httpx.Response(503, text="bad gateway") ) - with _client() as c: - with pytest.raises(KeeperHubNotFoundError): - c.get_workflow("wf_x") + with pytest.raises(KeeperHubAPIError): + client.list_workflows() @respx.mock -def test_400_raises_validation_error(): - respx.post("https://kh-test.example/api/execute").mock( - return_value=httpx.Response(400, json={"error": "bad config"}) +def test_call_workflow_forwards_x_payment_header(client: KeeperHubClient): + route = respx.post( + "https://app.keeperhub.com/api/mcp/workflows/aave-v3-health-check/call" + ).mock(return_value=httpx.Response( + 200, json={"executionId": "ok", "status": "success", "output": {}} + )) + client.call_workflow( + "aave-v3-health-check", + {"address": "0x0"}, + x_payment="x402-token", ) - with _client() as c: - with pytest.raises(KeeperHubValidationError): - c.execute_action("web3/check-balance", {}) + request = route.calls.last.request + assert request.headers["x-payment"] == "x402-token" diff --git a/tests/test_tool_specs.py b/tests/test_tool_specs.py index a37035b..0482ac5 100644 --- a/tests/test_tool_specs.py +++ b/tests/test_tool_specs.py @@ -1,40 +1,84 @@ +"""Tests for the framework-agnostic ToolSpec catalogue.""" + +from __future__ import annotations + from keeperkit import MockKeeperHubClient -from keeperkit.tools._common import KEEPERHUB_TOOL_SPECS, default_dispatch, find_spec +from keeperkit.tools._common import ( + STATIC_TOOL_SPECS, + build_all_tool_specs, + build_workflow_tools, + safe_dispatch, +) + + +def test_static_tools_have_unique_names_and_schemas(): + names = [s.name for s in STATIC_TOOL_SPECS] + assert len(names) == len(set(names)) + for s in STATIC_TOOL_SPECS: + assert s.parameters.get("type") == "object" + assert s.metadata.get("static") is True + + +def test_workflow_tools_generated_from_catalogue(): + client = MockKeeperHubClient() + wf_specs = build_workflow_tools(client) + expected_slugs = {wf["listedSlug"] for wf in client.list_workflows()["items"] + if wf.get("listedSlug")} + actual_slugs = {s.metadata["slug"] for s in wf_specs} + assert actual_slugs == expected_slugs + # Names are namespaced with `keeperhub_`. + for s in wf_specs: + assert s.name.startswith("keeperhub_") + assert s.metadata.get("static") is False + + +def test_build_all_combines_static_and_workflow(): + client = MockKeeperHubClient() + all_specs = build_all_tool_specs(client) + static_names = {s.name for s in STATIC_TOOL_SPECS} + assert static_names <= {s.name for s in all_specs} + assert len(all_specs) == len(STATIC_TOOL_SPECS) + len(build_workflow_tools(client)) + + +def test_safe_dispatch_handles_payment_required(): + client = MockKeeperHubClient() + specs = build_workflow_tools(client) + aave_spec = next(s for s in specs if s.metadata["slug"] == "aave-v3-health-check") + res = safe_dispatch(aave_spec, client, + address="0x000000000000000000000000000000000000dEaD") + assert res["ok"] is False + assert res["error"] == "payment_required" + assert res["x402"]["x402Version"] == 2 + assert res["amount_usdc"] + + +def test_safe_dispatch_unwraps_success(): + client = MockKeeperHubClient() + specs = build_workflow_tools(client) + hello = next(s for s in specs if s.metadata["slug"] == "helloworld") + res = safe_dispatch(hello, client) + assert res["ok"] is True + assert res["data"]["status"] == "success" + + +def test_safe_dispatch_static_list_workflows(): + client = MockKeeperHubClient() + list_spec = next(s for s in STATIC_TOOL_SPECS + if s.name == "keeperhub_list_workflows") + res = safe_dispatch(list_spec, client) + assert res["ok"] is True + assert res["data"]["items"] -def test_every_spec_dispatches_against_mock(): +def test_safe_dispatch_static_call_workflow_with_payment(): client = MockKeeperHubClient() - # The mock has no created workflows, so calls expecting an id should not - # be dispatched here. We just smoke-test the read-only / no-arg ones. - for spec in KEEPERHUB_TOOL_SPECS: - if spec.dispatch_key in ("get_workflow", "execute_workflow", - "get_execution_status", "get_execution_logs"): - continue - if spec.dispatch_key in ("transfer_funds", "write_contract"): - args = {"network": "11155111", - "to_address": "0x0000000000000000000000000000000000000001", - "amount": "0.001", - "wallet_id": "wallet_mock_main"} - if spec.dispatch_key == "write_contract": - args = {"network": "11155111", - "contract_address": "0xcafe000000000000000000000000000000000000", - "function_name": "transfer", - "wallet_id": "wallet_mock_main", - "args": ["0x0000000000000000000000000000000000000001", "1"]} - result = default_dispatch(client, spec, **args) - elif spec.dispatch_key == "check_balance": - result = default_dispatch( - client, spec, - network="11155111", - address="0x0000000000000000000000000000000000000001", - ) - elif spec.dispatch_key == "ai_generate_workflow": - result = default_dispatch(client, spec, description="test") - else: - result = default_dispatch(client, spec) - assert result["ok"] is True - - -def test_find_spec_roundtrip(): - spec = find_spec("keeperhub_check_balance") - assert spec.dispatch_key == "check_balance" + call_spec = next(s for s in STATIC_TOOL_SPECS + if s.name == "keeperhub_call_workflow") + res = safe_dispatch( + call_spec, client, + slug="aave-v3-health-check", + body={"address": "0x000000000000000000000000000000000000dEaD"}, + x_payment="mock-token", + ) + assert res["ok"] is True + assert res["data"]["output"]["result"]["healthFactor"] == "2.13" diff --git a/tests/test_workflow_builder.py b/tests/test_workflow_builder.py deleted file mode 100644 index 7d07122..0000000 --- a/tests/test_workflow_builder.py +++ /dev/null @@ -1,58 +0,0 @@ -from keeperkit import Network, WorkflowBuilder - - -def test_builder_chains_edges(): - wf = ( - WorkflowBuilder("chain test") - .manual_trigger() - .action("balance", "web3/check-balance", - network=Network.BASE_SEPOLIA, - address="0xdeadbeef0000000000000000000000000000beef") - .action("read", "web3/read-contract", - network="84532", - contractAddress="0xcafe000000000000000000000000000000000000", - functionName="totalSupply") - .build() - ) - assert wf.name == "chain test" - assert len(wf.nodes) == 3 - # Edges chain trigger->balance->read. - assert len(wf.edges) == 2 - assert wf.edges[0].source == wf.nodes[0].id - assert wf.edges[0].target == wf.nodes[1].id - assert wf.edges[1].source == wf.nodes[1].id - assert wf.edges[1].target == wf.nodes[2].id - - -def test_schedule_trigger_carries_cron(): - wf = ( - WorkflowBuilder("hourly") - .schedule_trigger("0 * * * *") - .action("balance", "web3/check-balance", - network=Network.SEPOLIA, - address="0x0000000000000000000000000000000000000000") - .build() - ) - trigger = wf.nodes[0] - assert trigger.type == "trigger" - assert trigger.data.config["cron"] == "0 * * * *" - assert trigger.data.config["triggerType"] == "Schedule" - - -def test_explicit_after_anchors_branch(): - wf = ( - WorkflowBuilder("branch") - .manual_trigger() - .action("a", "web3/check-balance", - network=Network.SEPOLIA, - address="0x0000000000000000000000000000000000000000") - ) - a_id = wf._last_id # noqa: SLF001 - testing internal anchor - wf.action("b", "web3/check-balance", network=Network.SEPOLIA, - address="0x0000000000000000000000000000000000000000") - wf.action("c", "web3/check-balance", network=Network.SEPOLIA, - address="0x0000000000000000000000000000000000000000", - after=a_id) - built = wf.build() - sources = [edge.source for edge in built.edges] - assert sources.count(a_id) == 2 # b and c both branch off a