diff --git a/bun.lock b/bun.lock index 055793b..c748b28 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,22 @@ "typescript": "^5.5.0", }, }, + "kits/bufi-on-shrooms": { + "name": "@agent-stack-ecosystem-kits/kit-bufi-on-shrooms", + "version": "0.0.0", + "dependencies": { + "@agent-stack-ecosystem-kits/agent-cli": "workspace:*", + "@agent-stack-ecosystem-kits/circle-tools": "workspace:*", + "@agent-stack-ecosystem-kits/kit-vercel-ai": "workspace:*", + "ai": "^4.3.0", + "dotenv": "^17.4.2", + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^20.14.0", + "typescript": "^5.5.0", + }, + }, "kits/claude-agent-sdk": { "name": "@agent-stack-ecosystem-kits/kit-claude-agent-sdk", "version": "0.0.0", @@ -136,6 +152,8 @@ "@agent-stack-ecosystem-kits/circle-tools": ["@agent-stack-ecosystem-kits/circle-tools@workspace:packages/circle-tools"], + "@agent-stack-ecosystem-kits/kit-bufi-on-shrooms": ["@agent-stack-ecosystem-kits/kit-bufi-on-shrooms@workspace:kits/bufi-on-shrooms"], + "@agent-stack-ecosystem-kits/kit-claude-agent-sdk": ["@agent-stack-ecosystem-kits/kit-claude-agent-sdk@workspace:kits/claude-agent-sdk"], "@agent-stack-ecosystem-kits/kit-google-adk": ["@agent-stack-ecosystem-kits/kit-google-adk@workspace:kits/google-adk"], @@ -410,6 +428,8 @@ "@ts-morph/common": ["@ts-morph/common@0.28.1", "", { "dependencies": { "minimatch": "^10.0.1", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.14" } }, "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/caseless": ["@types/caseless@0.12.5", "", {}, "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg=="], "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], @@ -516,6 +536,8 @@ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -1600,6 +1622,8 @@ "body-parser/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], + "bun-types/@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], + "cacache/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "cacache/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], @@ -1758,6 +1782,8 @@ "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], diff --git a/kits/bufi-on-shrooms/.env.example b/kits/bufi-on-shrooms/.env.example new file mode 100644 index 0000000..36c6079 --- /dev/null +++ b/kits/bufi-on-shrooms/.env.example @@ -0,0 +1,5 @@ +# Circle CLI chooses the active Circle session; no private BUFI API is required. +CIRCLE_CHAIN=BASE + +# Optional label for local demos. +BUFI_WORKSPACE_NAME="Example Agentic Workspace" diff --git a/kits/bufi-on-shrooms/GATEMAN.md b/kits/bufi-on-shrooms/GATEMAN.md new file mode 100644 index 0000000..6f072a0 --- /dev/null +++ b/kits/bufi-on-shrooms/GATEMAN.md @@ -0,0 +1,45 @@ +## Gateman Verification Report + +**Feature:** BU-217 public Circle starter-kit contribution +**Branch:** `codex/bu-217-bufi-on-shrooms` +**Upstream PR:** https://github.com/circlefin/agent-stack-starter-kits/pull/4 +**Date:** 2026-07-10 +**Verifier:** Codex + +### Score + +| Category | Score | Note | +| --- | ---: | --- | +| Error handling | 9 | Tool errors return model-readable results; bounded traces avoid becoming a failure path. | +| Logging | 9 | Consumer logger hook supports BUFI branding without forking Circle tool behavior. | +| Type safety | 9 | Vercel and BUFI kits typecheck and build against the public workspace packages. | +| Testability | 9 | The complete 17-tool roster, approval descriptions, and bounded redacted traces have contracts. | +| Performance | 9 | Trace storage is capped at 200 metadata-only events and does not retain model/tool payloads. | +| Security | 9 | Paid tools keep explicit in-tool approval; no private BUFI APIs, credentials, or customer data ship. | +| AI verification | 9 | Tool roster was composed without execution and all package checks were rerun after refactoring. | + +### Checks Passed + +- [x] Apache-2.0 Circle headers are preserved on source files. +- [x] `bufi-on-shrooms` composes public `circle-tools`, `agent-cli`, and Vercel AI kit exports. +- [x] All 17 requested tools are present, including `call_free_service` parity. +- [x] x402 payment and Gateway deposit retain a fresh human approval prompt. +- [x] Step hooks expose bounded metadata-only traces; prompts, text, payloads, and credentials are excluded. +- [x] BUFI branding is theme-only and reusable consumer logging is upstream-generic. +- [x] Typecheck passed for both Vercel and BUFI kits. +- [x] Three contract tests passed. +- [x] BUFI kit build and whitespace checks passed. +- [x] Public fork created and upstream PR opened; required StepSecurity check passed. + +### Checks Not Run + +- [ ] No live Circle login, wallet creation, funding, deployment, deposit, payment, or signing was run. These are intentionally excluded from unattended certification. +- [ ] Upstream maintainer review/merge remains external to BUFI. + +### Risk Level + +LOW for the public composition changes; HIGH-risk paid actions remain gated and were not executed. + +### Sign-off + +Safe to ship: **YES_WITH_FOLLOWUPS** — upstream review is the only external follow-up. diff --git a/kits/bufi-on-shrooms/README.md b/kits/bufi-on-shrooms/README.md new file mode 100644 index 0000000..7347ae4 --- /dev/null +++ b/kits/bufi-on-shrooms/README.md @@ -0,0 +1,47 @@ +# BUFI on Shrooms Kit - Circle Agent Stack + +This kit is an upstreamable reference for a BUFI-style agent workspace built on the public Circle Agent Stack primitives. + +It deliberately stays generic: + +- `packages/circle-tools` owns Circle CLI wrappers for wallets, balances, service discovery, and x402 payments. +- `packages/agent-cli` owns the reusable pinned-input terminal UI. +- This kit owns the workspace framing, branded console output, and the shape of a developer-facing operation surface. +- It does not call BUFI private APIs, ship customer data, or store secrets. + +## Run + +```bash +bun install +cp kits/bufi-on-shrooms/.env.example kits/bufi-on-shrooms/.env +bun run --cwd kits/bufi-on-shrooms demo +``` + +The kit composes the complete Vercel AI Circle roster: authentication, setup/sub-skills, wallet create/list/balance/deploy/fund, fiat funding links, service search/inspect/fetch, custom free-service calls, Gateway balance/deposit, and x402 payment. The two paid tools retain the Vercel kit's in-tool human approval boundary; declining returns a denial result before any payment or deposit call. + +The terminal emits bounded metadata-only step traces (sequence, finish reason, tool-call count, and text length). It deliberately does not copy prompts, model text, credentials, or tool payloads into the trace buffer. A browser product can replace this emitter with its own durable trace sink. + +## Why this exists + +BUFI's product surface is a browser and mobile agent workspace, but the public contribution should remain useful to Circle developers without BUFI infrastructure. This kit shows the reusable boundary: + +1. Circle wallet and x402 tools stay framework-agnostic. +2. The terminal console is a developer surface, similar to Stripe CLI, not a raw log stream. +3. Agent workspace traces and workflow state are readable as first-class, redacted events. +4. Branding is theme-level only; it does not leak private APIs or tenant assumptions. + +## Upstream PR boundary + +Safe upstream candidates: + +- A branded-kit example that composes `circle-tools` and `agent-cli`. +- Vercel AI SDK exports for composing its complete tool roster in other kits. +- A consumer logger hook for branded consoles. +- Framework-neutral step hooks for trace surfaces. +- A missing `call_free_service` parity tool for GET-query and POST-body free endpoints. + +BUFI-private follow-up outside this repo: + +- Desk-v1 workflow graph, trace drawer, and approval queue. +- BUFI wallet provisioning policy. +- Tenant-specific knowledge graph, ERP, and MCP integrations. diff --git a/kits/bufi-on-shrooms/package.json b/kits/bufi-on-shrooms/package.json new file mode 100644 index 0000000..1db56c5 --- /dev/null +++ b/kits/bufi-on-shrooms/package.json @@ -0,0 +1,29 @@ +{ + "name": "@agent-stack-ecosystem-kits/kit-bufi-on-shrooms", + "version": "0.0.0", + "private": true, + "description": "BUFI-style agent workspace example using Circle Agent Stack, x402, and a branded developer console.", + "type": "module", + "engines": { + "node": ">=20" + }, + "scripts": { + "demo": "bun run src/index.ts", + "test": "bun test src", + "typecheck": "tsc --noEmit", + "build": "tsc -p tsconfig.json", + "clean": "rm -rf dist *.tsbuildinfo" + }, + "dependencies": { + "@agent-stack-ecosystem-kits/agent-cli": "workspace:*", + "@agent-stack-ecosystem-kits/circle-tools": "workspace:*", + "@agent-stack-ecosystem-kits/kit-vercel-ai": "workspace:*", + "ai": "^4.3.0", + "dotenv": "^17.4.2" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^20.14.0", + "typescript": "^5.5.0" + } +} diff --git a/kits/bufi-on-shrooms/src/contracts.test.ts b/kits/bufi-on-shrooms/src/contracts.test.ts new file mode 100644 index 0000000..4bc4247 --- /dev/null +++ b/kits/bufi-on-shrooms/src/contracts.test.ts @@ -0,0 +1,74 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildTools } from "@agent-stack-ecosystem-kits/kit-vercel-ai/tools"; +import { describe, expect, test } from "bun:test"; + +import { createTraceCollector } from "./trace"; + +const expectedTools = [ + "circle_login", + "circle_logout", + "fetch_setup_skill", + "fetch_sub_skill", + "circle_list_wallets", + "circle_create_wallet", + "circle_get_balance", + "circle_deploy_wallet", + "circle_wallet_fund", + "circle_fund_fiat", + "circle_search_services", + "circle_inspect_service", + "fetch_service", + "call_free_service", + "circle_get_gateway_balance", + "circle_pay_service", + "circle_gateway_deposit", +]; + +describe("BUFI on Shrooms contracts", () => { + test("composes the complete Vercel AI Circle roster", () => { + const tools = buildTools(async () => "n"); + expect(Object.keys(tools)).toEqual(expectedTools); + }); + + test("keeps approval language on both paid tools", () => { + const tools = buildTools(async () => "n"); + expect(tools.circle_pay_service.description?.toLowerCase()).toContain( + "approval", + ); + expect(tools.circle_gateway_deposit.description?.toLowerCase()).toContain( + "spends usdc", + ); + }); + + test("stores bounded metadata-only step traces", () => { + const lines: string[] = []; + const trace = createTraceCollector((line) => lines.push(line), 2); + trace.record({ + step: 1, + finishReason: "tool-calls", + toolCallCount: 1, + text: "secret one", + }); + trace.record({ + step: 2, + finishReason: "tool-calls", + toolCallCount: 2, + text: "secret two", + }); + trace.record({ + step: 3, + finishReason: "stop", + toolCallCount: 0, + text: "secret three", + }); + const snapshot = trace.snapshot(); + expect(snapshot).toHaveLength(2); + expect(snapshot[0]?.sequence).toBe(2); + expect(JSON.stringify(snapshot)).not.toContain("secret"); + expect(lines.at(-1)).toContain("tools=0"); + }); +}); diff --git a/kits/bufi-on-shrooms/src/index.ts b/kits/bufi-on-shrooms/src/index.ts new file mode 100644 index 0000000..b80c302 --- /dev/null +++ b/kits/bufi-on-shrooms/src/index.ts @@ -0,0 +1,124 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import "dotenv/config"; + +import { createChatUi } from "@agent-stack-ecosystem-kits/agent-cli"; +import { + ensureSession, + formatUsdcBalance, + walletUsdcBalance, +} from "@agent-stack-ecosystem-kits/circle-tools"; +import { runTurn } from "@agent-stack-ecosystem-kits/kit-vercel-ai/agent"; +import { loadConfig } from "@agent-stack-ecosystem-kits/kit-vercel-ai/config"; +import { SETUP_SKILL_URL } from "@agent-stack-ecosystem-kits/kit-vercel-ai/skill"; +import { buildTools } from "@agent-stack-ecosystem-kits/kit-vercel-ai/tools"; +import type { CoreMessage } from "ai"; + +import { bufiLine, dim, heading, red, toolLine, yellow } from "./theme"; +import { createTraceCollector } from "./trace"; + +async function main(): Promise { + const workspace = process.env.BUFI_WORKSPACE_NAME ?? "Agentic Workspace"; + const ui = createChatUi({ title: heading(`BUFI on Shrooms — ${workspace}`) }); + const trace = createTraceCollector((event) => ui.log(bufiLine(event))); + const ask = async (question: string): Promise => { + const answer = await ui.ask(question); + if (answer.trim().toLowerCase() === "exit") { + ui.close(); + process.exit(0); + } + return answer; + }; + const refreshBalance = async () => { + try { + const summary = await walletUsdcBalance(); + ui.setBalance(summary ? formatUsdcBalance(summary) : null); + } catch { + // A read-only balance refresh never breaks the agent session. + } + }; + + try { + const config = loadConfig(); + ui.log( + bufiLine( + `workspace=${workspace} provider=${config.provider} model=${config.model}`, + ), + ); + ui.log( + bufiLine(dim("all paid tools retain an in-tool human approval prompt")), + ); + await ensureSession({ + ask, + log: (line) => ui.log(bufiLine(line)), + bold: heading, + }); + await refreshBalance(); + + const tools = buildTools(ask, { log: (line) => ui.log(toolLine(line)) }); + ui.log( + bufiLine(`circle tool roster ready (${Object.keys(tools).length} tools)`), + ); + let messages: CoreMessage[] = [ + { + role: "user", + content: + `Fetch ${SETUP_SKILL_URL} with fetch_setup_skill and follow it. ` + + "Use tools for actions, explain each result, and ask before every paid action.", + }, + ]; + + const run = async () => { + ui.setStatus("working…"); + const result = await runTurn(config, messages, tools, { + onStep: (event) => { + trace.record(event); + }, + }); + ui.setStatus(null); + await refreshBalance(); + messages = [...messages, ...result.responseMessages]; + }; + + await run(); + ui.log( + bufiLine( + 'bootstrap complete — continue the workspace session or type "exit"', + ), + ); + while (true) { + const input = (await ask("> ")).trim(); + if (!input) continue; + if (input.toLowerCase() === "quit") break; + messages.push({ role: "user", content: input }); + await run(); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ui.log(bufiLine(red(`fatal: ${message}`))); + ui.log( + bufiLine(yellow("No paid action runs without a fresh human approval.")), + ); + process.exitCode = 1; + } finally { + ui.close(); + } +} + +void main(); diff --git a/kits/bufi-on-shrooms/src/theme.ts b/kits/bufi-on-shrooms/src/theme.ts new file mode 100644 index 0000000..cd89e48 --- /dev/null +++ b/kits/bufi-on-shrooms/src/theme.ts @@ -0,0 +1,81 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +const enabled = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR; + +function sgr(open: number, close: number): (s: string) => string { + return (s) => (enabled ? `\x1b[${open}m${s}\x1b[${close}m` : s); +} + +export const bold = sgr(1, 22); +export const dim = sgr(2, 22); +export const red = sgr(31, 39); +export const green = sgr(32, 39); +export const yellow = sgr(33, 39); +export const violet = sgr(35, 39); +export const cyan = sgr(36, 39); +export const gray = sgr(90, 39); + +export function colorizeJson(value: unknown, indent = 2): string { + let json: string; + if (typeof value === "string") { + try { + json = JSON.stringify(JSON.parse(value), null, indent); + } catch { + return value; + } + } else { + json = JSON.stringify(value, null, indent); + } + if (json === undefined) return String(value); + if (!enabled) return json; + return json.replace( + /("(?:\\.|[^\\"])*")(\s*:)?|\b(true|false)\b|\b(null)\b|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g, + (full, str, colon, bool, nul, num) => { + if (str !== undefined) + return colon !== undefined ? cyan(str) + colon : green(str); + if (bool !== undefined) return yellow(bool); + if (nul !== undefined) return gray(nul); + if (num !== undefined) return violet(num); + return full; + }, + ); +} + +export function bufiLine(line: string): string { + return `${dim(violet("[bufi-on-shrooms]"))} ${line}`; +} + +export function toolLine(line: string): string { + const prefix = dim(cyan("[tool]")); + const m = /^(\S+)([\s\S]*)$/.exec(line); + if (!m) return `${prefix} ${line}`; + const name = bold(m[1] ?? ""); + const rest = m[2] ?? ""; + const fail = rest.indexOf("✗"); + if (fail >= 0) + return `${prefix} ${name}${rest.slice(0, fail)}${red("✗")}${red(rest.slice(fail + 1))}`; + const hit = rest.indexOf("←"); + if (hit >= 0) + return `${prefix} ${name}${rest.slice(0, hit)}${green("←")}${dim(rest.slice(hit + 1))}`; + return `${prefix} ${name}${rest.replace(/(\b\w+)=/g, (_w, k) => `${gray(k)}=`)}`; +} + +export function heading(label: string): string { + return bold(violet(label)); +} diff --git a/kits/bufi-on-shrooms/src/trace.ts b/kits/bufi-on-shrooms/src/trace.ts new file mode 100644 index 0000000..c9f05c1 --- /dev/null +++ b/kits/bufi-on-shrooms/src/trace.ts @@ -0,0 +1,45 @@ +/** + * Copyright 2026 Circle Internet Group, Inc. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { RunTurnStepEvent } from "@agent-stack-ecosystem-kits/kit-vercel-ai/agent"; + +export interface SafeStepTrace { + runId: string; + sequence: number; + recordedAt: string; + finishReason: string; + toolCallCount: number; + textCharacters: number; +} + +export function createTraceCollector( + emit: (line: string) => void, + capacity = 200, +) { + const runId = crypto.randomUUID(); + const events: SafeStepTrace[] = []; + return { + record(event: RunTurnStepEvent) { + const trace: SafeStepTrace = { + runId, + sequence: event.step, + recordedAt: new Date().toISOString(), + finishReason: event.finishReason, + toolCallCount: event.toolCallCount, + textCharacters: event.text.length, + }; + events.push(trace); + if (events.length > capacity) events.shift(); + emit( + `trace step=${trace.sequence} finish=${trace.finishReason} ` + + `tools=${trace.toolCallCount} textChars=${trace.textCharacters}`, + ); + return trace; + }, + snapshot(): readonly SafeStepTrace[] { + return events.map((event) => ({ ...event })); + }, + }; +} diff --git a/kits/bufi-on-shrooms/tsconfig.json b/kits/bufi-on-shrooms/tsconfig.json new file mode 100644 index 0000000..525d19a --- /dev/null +++ b/kits/bufi-on-shrooms/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "jsx": "react-jsx", + "types": ["node", "bun"] + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/kits/vercel-ai/package.json b/kits/vercel-ai/package.json index 1b99caf..8698ce9 100644 --- a/kits/vercel-ai/package.json +++ b/kits/vercel-ai/package.json @@ -13,6 +13,13 @@ "build": "tsc -p tsconfig.json", "clean": "rm -rf dist *.tsbuildinfo" }, + "exports": { + "./agent": "./src/agent.ts", + "./config": "./src/config.ts", + "./retry": "./src/retry.ts", + "./skill": "./src/skill.ts", + "./tools": "./src/tools.ts" + }, "dependencies": { "@agent-stack-ecosystem-kits/agent-cli": "workspace:*", "@agent-stack-ecosystem-kits/circle-tools": "workspace:*", diff --git a/kits/vercel-ai/src/agent.ts b/kits/vercel-ai/src/agent.ts index 476e6bd..715f0cf 100644 --- a/kits/vercel-ai/src/agent.ts +++ b/kits/vercel-ai/src/agent.ts @@ -24,6 +24,17 @@ import type { KitConfig, ProviderConfig } from './config'; import type { CircleTools } from './tools'; import { heading, kitLine, yellow } from './theme'; +export interface RunTurnStepEvent { + step: number; + finishReason: string; + toolCallCount: number; + text: string; +} + +export interface RunTurnOptions { + onStep?: (event: RunTurnStepEvent) => void | Promise; +} + /** * Pick the Vercel AI SDK LanguageModel based on the detected provider. * @@ -57,6 +68,7 @@ export async function runTurn( config: ProviderConfig, messages: CoreMessage[], tools: CircleTools, + options: RunTurnOptions = {}, ): Promise<{ text: string; responseMessages: CoreMessage[] }> { const model = pickModel(config); @@ -69,6 +81,12 @@ export async function runTurn( maxSteps: 30, onStepFinish: ({ text, toolCalls, finishReason }) => { stepCount++; + void options.onStep?.({ + step: stepCount, + finishReason, + toolCallCount: toolCalls.length, + text, + }); // Print any prose the model emitted in this step. Tool calls are logged // inside each tool's execute function, so we only need to surface text. if (text.trim()) { @@ -77,7 +95,9 @@ export async function runTurn( // Surface a warning when the cap is reached so users understand why the // agent stopped mid-task rather than silently abandoning work. if (finishReason === 'length' && toolCalls.length > 0) { - console.log(kitLine(yellow(`step cap reached (${stepCount} steps) — agent may be incomplete`))); + console.log( + kitLine(yellow(`step cap reached (${stepCount} steps) — agent may be incomplete`)), + ); } }, }); diff --git a/kits/vercel-ai/src/tools.ts b/kits/vercel-ai/src/tools.ts index c4634e1..fab1a7b 100644 --- a/kits/vercel-ai/src/tools.ts +++ b/kits/vercel-ai/src/tools.ts @@ -53,10 +53,15 @@ export type AskFn = (q: string) => Promise; const CHAIN = (process.env['CIRCLE_CHAIN'] ?? DEFAULT_CHAIN) as Chain; -function log(line: string): void { +function defaultLog(line: string): void { console.log(toolLine(line)); } +export interface ToolBuildOptions { + /** Optional consumer logger for branded consoles and trace surfaces. */ + log?: (line: string) => void; +} + function preview(value: string, max = 120): string { const oneLine = value.replace(/\s+/g, ' ').trim(); return oneLine.length > max ? `${oneLine.slice(0, max)}…` : oneLine; @@ -85,7 +90,8 @@ function toolError(e: unknown): { error: string } { * the tool itself, rather than in an external hook like LangChain's * `interruptOn` or the Claude Agent SDK's `canUseTool`. */ -export function buildTools(ask: AskFn) { +export function buildTools(ask: AskFn, options: ToolBuildOptions = {}) { + const log = options.log ?? defaultLog; const subSkillEnum = z.enum(SUB_SKILL_NAMES as [SubSkillName, ...SubSkillName[]]); const subSkillCatalog = SUB_SKILL_NAMES.map((n) => `- ${n} → ${SUB_SKILLS[n]}`).join('\n'); @@ -307,7 +313,10 @@ export function buildTools(ask: AskFn) { 'complete, confirm with circle_get_balance. Mainnet only.', parameters: z.object({ address: z.string().describe('Destination agent wallet address (0x...).'), - amount: z.number().positive().describe('Amount of token to buy, in human units (e.g. 10 for $10 of USDC).'), + amount: z + .number() + .positive() + .describe('Amount of token to buy, in human units (e.g. 10 for $10 of USDC).'), chain: z .enum(['BASE', 'POLYGON']) .optional() @@ -318,9 +327,17 @@ export function buildTools(ask: AskFn) { .describe('Token to buy. Defaults to usdc.'), }), execute: async ({ address, amount, chain, token }) => { - log(`circle_fund_fiat address=${address} amount=${amount} chain=${chain ?? 'BASE'} token=${token ?? 'usdc'}`); + log( + `circle_fund_fiat address=${address} amount=${amount} chain=${chain ?? 'BASE'} token=${token ?? 'usdc'}`, + ); try { - const result = await fundWalletFiat({ address, amount, chain: chain as Chain | undefined, token, open: true }); + const result = await fundWalletFiat({ + address, + amount, + chain: chain as Chain | undefined, + token, + open: true, + }); log(`circle_fund_fiat ← ${preview(result.url, 80)}`); return result; } catch (e) { @@ -398,6 +415,48 @@ export function buildTools(ask: AskFn) { }, }), + call_free_service: tool({ + description: + 'Call a free (no-payment) HTTP endpoint with GET query parameters or a POST JSON body. ' + + 'Prefer fetch_service for simple GET probing because it detects HTTP 402. This tool ' + + 'never attaches a payment signature.', + parameters: z.object({ + url: z.string().url().describe('The free service endpoint URL.'), + method: z.enum(['GET', 'POST']).default('GET'), + params: z.record(z.string(), z.unknown()).optional(), + }), + execute: async ({ url: rawUrl, method, params }) => { + const url = new URL(rawUrl); + if (!['https:', 'http:'].includes(url.protocol)) { + return { error: 'Only HTTP(S) free-service URLs are supported.' }; + } + log(`call_free_service url=${url.toString()} method=${method}`); + try { + const init: RequestInit = { method }; + if (method === 'GET' && params) { + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, String(value)); + } + } else if (method === 'POST' && params) { + init.headers = { 'Content-Type': 'application/json' }; + init.body = JSON.stringify(params); + } + const response = await fetch(url, init); + const body = await response.text(); + if (!response.ok) throw new Error(`HTTP ${response.status}: ${preview(body)}`); + log(`call_free_service ← HTTP ${response.status} ${body.length} bytes`); + return { + status: response.status, + contentType: response.headers.get('content-type'), + body, + }; + } catch (e) { + log(`call_free_service ✗ ${(e as Error).message}`); + return toolError(e); + } + }, + }), + circle_get_gateway_balance: tool({ description: "Check the wallet's Base Circle Gateway balance: the off-chain batched-payment pool, " + @@ -430,7 +489,8 @@ export function buildTools(ask: AskFn) { circle_pay_service: tool({ description: - 'Pay for an x402 service with a Circle USDC payment on Base. The kit reads the ' + + 'Pay for an x402 service with a Circle USDC payment on Base. Always pauses for ' + + 'explicit human approval before spending. The kit reads the ' + "service's published payment options and pays under the right scheme automatically: " + 'vanilla x402, or Circle Gateway when the seller requires it. If the seller requires ' + 'Gateway and the wallet has no Gateway balance, this fails with an actionable ' + @@ -477,7 +537,9 @@ export function buildTools(ask: AskFn) { try { accepts = await getServiceAccepts(url, httpMethod); } catch (e) { - log(`circle_pay_service ✗ could not read seller's payment options: ${(e as Error).message}`); + log( + `circle_pay_service ✗ could not read seller's payment options: ${(e as Error).message}`, + ); return toolError(e); } if (accepts.options.length === 0) { @@ -504,7 +566,12 @@ export function buildTools(ask: AskFn) { } try { - const result = await payService({ url, address, data, method: httpMethod }); + const result = await payService({ + url, + address, + data, + method: httpMethod, + }); const tx = (result as { txHash?: string }).txHash ? ` txHash=${(result as { txHash?: string }).txHash}` : ''; @@ -520,7 +587,8 @@ export function buildTools(ask: AskFn) { circle_gateway_deposit: tool({ description: "Fund the wallet's Circle Gateway balance so it can pay a seller that requires " + - 'Gateway (batched) x402 payments. Pass the service URL; the kit confirms the seller ' + + 'Gateway (batched) x402 payments. Always pauses for explicit human approval. ' + + 'Pass the service URL; the kit confirms the seller ' + 'requires Gateway, then deposits USDC. Spends USDC (the deposit amount plus fee). ' + 'After it succeeds, retry circle_pay_service for the same URL. ' + 'Use deposit_method="eco" (default) for Base→Polygon Gateway: ~30-50 s settlement, ' + @@ -558,11 +626,21 @@ export function buildTools(ask: AskFn) { execute: async ({ url, address, method, deposit_method, amount }) => { const httpMethod = (method ?? 'GET').toUpperCase(); const depositMethod = deposit_method ?? 'eco'; - log(`circle_gateway_deposit url=${url} address=${address} amount=${amount} deposit_method=${depositMethod}`); + log( + `circle_gateway_deposit url=${url} address=${address} amount=${amount} deposit_method=${depositMethod}`, + ); // ── Human-in-the-loop ────────────────────────────────────────────── console.log(`\n${yellow('⚠')} ${bold('approval required:')} circle_gateway_deposit`); - console.log(colorizeJson({ url, address, method: httpMethod, deposit_method: depositMethod, amount })); + console.log( + colorizeJson({ + url, + address, + method: httpMethod, + deposit_method: depositMethod, + amount, + }), + ); const answer = (await ask(`${bold('Approve? [y/N]')} `)).trim().toLowerCase(); if (answer !== 'y') { log(red('circle_gateway_deposit ✗ rejected by user')); @@ -586,8 +664,14 @@ export function buildTools(ask: AskFn) { } try { - const result = await gatewayDeposit({ address, amount, method: depositMethod }); - log(`circle_gateway_deposit ← ${result.amount} USDC via ${depositMethod} tx=${result.txId ?? 'n/a'}`); + const result = await gatewayDeposit({ + address, + amount, + method: depositMethod, + }); + log( + `circle_gateway_deposit ← ${result.amount} USDC via ${depositMethod} tx=${result.txId ?? 'n/a'}`, + ); return result; } catch (e) { log(`circle_gateway_deposit ✗ ${(e as Error).message}`);