diff --git a/.wave/repo.json b/.wave/repo.json index f1b0145..33527a1 100644 --- a/.wave/repo.json +++ b/.wave/repo.json @@ -2,14 +2,18 @@ "name": "adk", "kind": "library", "domain": "agents", - "purpose": "WAVE is media infrastructure for the agentic internet: one call shape moves live and on-demand media across every transport, and both kinds of user, people and agents, discover it, call it, and pay for it per call. @wave-av/adk is the agent development kit for that call shape: a TypeScript SDK with 5 ready-made agent templates, an MCP toolkit exposing 10 tools, an agent runtime (health, heartbeat, graceful shutdown), and adapters for Mastra, LangGraph, LiveKit, and Kernel.sh.", - "description": "WAVE Agent Developer Kit — 10 MCP tools, 5 agent templates for AI video agents", + "purpose": "WAVE is media infrastructure for the agentic internet: one call shape moves live and on-demand media across every transport, and both kinds of user, people and agents, discover it, call it, and pay for it per call. @wave-av/adk is the agent development kit for that call shape: a TypeScript SDK with 5 ready-made agent templates, four MCP-compatible toolkits exposing 17 tools across streams/production, the voice-transcribe-captions product spokes, Dispatch model routing, and the x402/MPP agent-payment rails, an agent runtime (health, heartbeat, graceful shutdown), and adapters for Mastra, LangGraph, LiveKit, and Kernel.sh.", + "description": "WAVE Agent Developer Kit — 17 MCP tools across 4 toolkits, 5 agent templates for AI video agents", "visibility": "public", "primaryLanguage": "TypeScript", "topics": ["agents", "sdk", "video", "streaming", "mcp", "ai", "developer-kit", "typescript"], "capabilities": [ { "id": "agent-templates", "does": "5 ready-made agent template classes extending WaveAgent: StreamMonitorAgent, AutoProducerAgent, ClipFactoryAgent, ModerationAgent, CaptionAgent.", "status": "ga" }, - { "id": "mcp-toolkit", "does": "AgentToolkit.toMCPTools() exposes 10 MCP tool definitions (wave_create_stream, wave_monitor_stream, wave_create_clip, wave_switch_camera, wave_show_graphic, wave_moderate_chat, wave_start_captions, wave_analyze_quality, wave_mark_highlight, wave_control_camera).", "status": "ga" }, + { "id": "mcp-toolkit", "does": "AgentToolkit.toMCPTools() exposes 10 MCP tool definitions for streams and live production (wave_create_stream, wave_monitor_stream, wave_create_clip, wave_switch_camera, wave_show_graphic, wave_moderate_chat, wave_start_captions, wave_analyze_quality, wave_mark_highlight, wave_control_camera).", "status": "ga" }, + { "id": "fleet-toolkit", "does": "FleetToolkit wraps the three gateway-fronted product spokes as 3 tools: wave_speak (POST /v1/voice), wave_transcribe (POST /v1/transcribe), wave_caption (POST /v1/captions). speak() returns a VoiceResult holding the real audio bytes, whose toJSON() emits a usage receipt instead of the bytes so a framework adapter cannot serialise megabytes of audio into a model's context.", "status": "ga" }, + { "id": "dispatch-toolkit", "does": "DispatchToolkit wraps WAVE Dispatch model routing as 2 tools: wave_route (POST /) and wave_list_routing_profiles (GET /profiles). Defaults to dispatch.wave.online, which is a different host from the api.wave.online product gateway.", "status": "ga" }, + { "id": "payments-toolkit", "does": "PaymentsToolkit wraps the read half of WAVE's agent-payment rails as 2 tools: wave_find_paid_services (GET /v1/mpp/services) and wave_payment_schemes (GET /v1/{x402,mpp}/facilitator/supported). These gateway routes are public, so the class takes no apiKey at all; the facilitator's money-moving verify and settle endpoints are deliberately not wrapped.", "status": "ga" }, + { "id": "typed-tool-errors", "does": "WaveToolError is thrown on any non-2xx response from FleetToolkit, DispatchToolkit, or PaymentsToolkit, carrying the status and the response body verbatim, with isRateLimited flagging 429. AgentToolkit's own call() predates this and still returns error bodies as if they were successful results.", "status": "ga" }, { "id": "agent-runtime", "does": "AgentRuntime provides an HTTP health server (/health, /ready, /metrics), a 30s heartbeat loop, structured JSON logging, and graceful SIGTERM/SIGINT shutdown.", "status": "ga" }, { "id": "framework-adapters", "does": "Adapter functions for Mastra (createMastraTools), LangGraph (createLangGraphTools), LiveKit (createLiveKitWaveTools), and Kernel.sh (createKernelTools).", "status": "ga" }, { "id": "subpath-exports", "does": "README documents 6 tree-shakeable subpath imports (root, /tools, /agents, /adapters, /templates, /types), but package.json's exports map only declares the root \".\" entry, so the subpaths are not resolvable (dist/ is gitignored build output, not tracked in git).", "status": "planned" }, @@ -23,7 +27,13 @@ { "id": "ten-mcp-tools", "text": "AgentToolkit exposes MCP tool definitions via toMCPTools().", "resolver": { "type": "grep", "target": "src/tools/AgentToolkit.ts", "expect": "toMCPTools" } }, { "id": "heartbeat-30s", "text": "AgentRuntime defaults heartbeatIntervalMs to 30 seconds.", "resolver": { "type": "grep", "target": "src/agents/AgentRuntime.ts", "expect": "heartbeatIntervalMs ?? 30_000" } }, { "id": "graceful-sigterm", "text": "AgentRuntime handles SIGTERM for graceful shutdown.", "resolver": { "type": "grep", "target": "src/agents/AgentRuntime.ts", "expect": "SIGTERM" } }, - { "id": "cli-bin-declared", "text": "package.json declares a wave-adk CLI binary at ./dist/cli/index.js, built from src/cli/index.ts by the tsup build.", "resolver": { "type": "grep", "target": "package.json", "expect": "./dist/cli/index.js" } } + { "id": "cli-bin-declared", "text": "package.json declares a wave-adk CLI binary at ./dist/cli/index.js, built from src/cli/index.ts by the tsup build.", "resolver": { "type": "grep", "target": "package.json", "expect": "./dist/cli/index.js" } }, + { "id": "fleet-speak-tool", "text": "FleetToolkit exposes wave_speak, backed by POST /v1/voice.", "resolver": { "type": "grep", "target": "src/tools/FleetToolkit.ts", "expect": "wave_speak" } }, + { "id": "fleet-grounded-in-spokes", "text": "The voice/transcribe/captions paths are taken from the spokes' own routers, not from api-spec/openapi.yaml, which over-declares endpoints that 404 in production.", "resolver": { "type": "grep", "target": "src/tools/FleetToolkit.ts", "expect": "api-spec#33" } }, + { "id": "voice-result-omits-bytes-on-serialise", "text": "VoiceResult.toJSON() omits the audio bytes and emits a usage receipt, so serialising a tool result never pushes raw audio into a model's context.", "resolver": { "type": "grep", "target": "src/tools/FleetToolkit.ts", "expect": "toJSON" } }, + { "id": "dispatch-own-host", "text": "DispatchToolkit defaults to dispatch.wave.online, not the api.wave.online product gateway.", "resolver": { "type": "grep", "target": "src/tools/DispatchToolkit.ts", "expect": "https://dispatch.wave.online" } }, + { "id": "payments-sends-no-credential", "text": "PaymentsToolkit sends no Authorization header, because the MPP/x402 discovery routes are public and a key sent to them would leak for no benefit.", "resolver": { "type": "grep", "target": "src/tools/PaymentsToolkit.ts", "expect": "No Authorization header, on purpose" } }, + { "id": "typed-wave-tool-error", "text": "WaveToolError carries the HTTP status and the response body verbatim.", "resolver": { "type": "grep", "target": "src/tools/shared.ts", "expect": "export class WaveToolError" } } ], "endpoints": [ { "method": "GET", "path": "/health", "does": "Liveness probe returning { status, uptime } while an AgentRuntime is running." }, @@ -64,7 +74,7 @@ { "kind": "prose", "heading": "Status", - "body": "Beta. The core SDK is real and implemented: 5 agent templates, the 10-tool MCP toolkit, AgentRuntime's health/heartbeat/shutdown lifecycle, and the four framework adapters all exist as working source in src/, with test files under src/__tests__/ (though no test script or CI gate currently runs them). Two README-advertised surfaces are not yet delivered, however: the 6 tree-shakeable subpath exports (only the root \".\" is declared in package.json's exports map), and the wave-adk CLI binary (the build now compiles src/cli/index.ts and bin points at ./dist/cli/index.js, but no published release includes it yet). Both are marked planned rather than ga to keep this SSOT honest. The README's '$19/month usage-based pricing' claim is marketing copy with no billing config in this repo to verify it against, so it is omitted from claims here." + "body": "Beta. The core SDK is real and implemented: 5 agent templates, four MCP toolkits totalling 17 tools, AgentRuntime's health/heartbeat/shutdown lifecycle, and the four framework adapters all exist as working source in src/, with test files under src/__tests__/ (though no test script or CI gate currently runs them). Two README-advertised surfaces are not yet delivered, however: the 6 tree-shakeable subpath exports (only the root \".\" is declared in package.json's exports map), and the wave-adk CLI binary (the build now compiles src/cli/index.ts and bin points at ./dist/cli/index.js, but no published release includes it yet). Both are marked planned rather than ga to keep this SSOT honest. The README's '$19/month usage-based pricing' claim is marketing copy with no billing config in this repo to verify it against, so it is omitted from claims here." }, { "kind": "table", @@ -80,9 +90,20 @@ }, { "kind": "code", - "heading": "MCP tools (10 tools)", + "heading": "MCP tools (17 tools across 4 toolkits)", "lang": "typescript", - "body": "import { AgentToolkit } from '@wave-av/adk/tools';\n\nconst toolkit = new AgentToolkit({ apiKey: process.env.WAVE_AGENT_KEY });\n\n// Get MCP-compatible tool definitions\nconst tools = toolkit.toMCPTools();\n// → wave_create_stream, wave_monitor_stream, wave_create_clip,\n// wave_switch_camera, wave_show_graphic, wave_moderate_chat,\n// wave_start_captions, wave_analyze_quality, wave_mark_highlight,\n// wave_control_camera" + "body": "import { AgentToolkit, FleetToolkit, DispatchToolkit, PaymentsToolkit } from '@wave-av/adk';\n\n// Streams + live production (10 tools)\nconst studio = new AgentToolkit({ apiKey: process.env.WAVE_AGENT_KEY });\n// → wave_create_stream, wave_monitor_stream, wave_create_clip,\n// wave_switch_camera, wave_show_graphic, wave_moderate_chat,\n// wave_start_captions, wave_analyze_quality, wave_mark_highlight,\n// wave_control_camera\n\n// Voice, transcription, captions (3 tools)\nconst fleet = new FleetToolkit({ apiKey: process.env.WAVE_AGENT_KEY });\n// → wave_speak, wave_transcribe, wave_caption\n\n// Model routing (2 tools) — Dispatch runs on its own host\nconst dispatch = new DispatchToolkit({ apiKey: process.env.WAVE_AGENT_KEY });\n// → wave_route, wave_list_routing_profiles\n\n// Agent-payment rails (2 tools) — public, so no key is taken at all\nconst payments = new PaymentsToolkit();\n// → wave_find_paid_services, wave_payment_schemes\n\nconst tools = [studio, fleet, dispatch, payments].flatMap((k) => k.toMCPTools());" + }, + { + "kind": "prose", + "heading": "Working with audio", + "body": "`wave_speak` returns a `VoiceResult`. Read `result.audio` for the real bytes — but `JSON.stringify(result)` deliberately yields a receipt (`contentType`, `byteLength`, `usage`) rather than the bytes, so an agent framework that serialises tool results into a model's context cannot fill it with audio the model cannot listen to.\n\n`wave_transcribe` and `wave_caption` take a `url` that the WAVE spoke fetches server-side, so the audio must be reachable from the public internet. Every non-2xx response throws a `WaveToolError` carrying the status and the response body verbatim; check `.isRateLimited` to back off on a 429." + }, + { + "kind": "code", + "heading": "Working with audio — example", + "lang": "typescript", + "body": "import { FleetToolkit, WaveToolError } from '@wave-av/adk';\n\nconst fleet = new FleetToolkit({ apiKey: process.env.WAVE_AGENT_KEY });\n\ntry {\n const speech = await fleet.speak({ text: 'Ready when you are.' });\n await writeFile('out.mp3', speech.audio);\n console.log(speech.usage); // { meter, usageMinutes, rateLimitRemaining }\n\n const { transcript } = await fleet.transcribe({ url: 'https://example.com/clip.wav' });\n} catch (err) {\n if (err instanceof WaveToolError && err.isRateLimited) await backOff();\n else throw err;\n}" }, { "kind": "prose", @@ -126,7 +147,7 @@ { "kind": "prose", "heading": "Why WAVE ADK?", - "body": "- **10 MCP tools** — plug into Claude, Cursor, or any MCP client\n- **5 agent templates** — start producing in minutes, not weeks\n- **6 subpath exports** — tree-shake to only what you need (planned — see Status)\n- **Real infrastructure** — not a wrapper, actual video processing\n- **Enterprise-ready** — multi-region architecture, designed for scale\n\n(The README also advertises \"usage-based pricing, plans from $19/month\" — that is marketing copy with no billing config in this repo to verify it against, so it is omitted here per the SSOT grounding law; see Status.)" + "body": "- **17 MCP tools** — plug into Claude, Cursor, or any MCP client\n- **5 agent templates** — start producing in minutes, not weeks\n- **6 subpath exports** — tree-shake to only what you need (planned — see Status)\n- **Real infrastructure** — not a wrapper, actual video processing\n- **Enterprise-ready** — multi-region architecture, designed for scale\n\n(The README also advertises \"usage-based pricing, plans from $19/month\" — that is marketing copy with no billing config in this repo to verify it against, so it is omitted here per the SSOT grounding law; see Status.)" }, { "kind": "prose", diff --git a/src/index.ts b/src/index.ts index 27ed99b..8e5e042 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,22 @@ export { CaptionAgent } from './templates/CaptionAgent'; // Agent tools (MCP-compatible) export { AgentToolkit, type AgentTool } from './tools/AgentToolkit'; +export { WaveToolError, toMCPToolDefs, readUsage, type MCPToolDef, type WaveUsage } from './tools/shared'; + +// Fleet product tools — voice, transcribe, captions (gateway-fronted spokes) +export { + FleetToolkit, + VoiceResult, + type FleetToolkitConfig, + type TranscriptResult, + type CaptionResult, +} from './tools/FleetToolkit'; + +// Model routing (WAVE Dispatch — its own host, not the gateway) +export { DispatchToolkit, type DispatchToolkitConfig } from './tools/DispatchToolkit'; + +// Agent-payment rails — x402 / MPP discovery (public, unauthenticated) +export { PaymentsToolkit, type PaymentsToolkitConfig } from './tools/PaymentsToolkit'; // Framework adapters export { createMastraTools, createWaveMCPConfig, createStreamMonitorStep } from './adapters/mastra'; diff --git a/src/tools/AgentToolkit.ts b/src/tools/AgentToolkit.ts index 75a346b..928f4db 100644 --- a/src/tools/AgentToolkit.ts +++ b/src/tools/AgentToolkit.ts @@ -6,14 +6,11 @@ */ import { z } from 'zod'; +import { toMCPToolDefs, validated, type AgentTool, type MCPToolDef } from './shared'; -export interface AgentTool { - readonly name: string; - readonly description: string; - readonly parameters: Record; - readonly schema: z.ZodObject; - readonly handler: (params: Record) => Promise; -} +// `AgentTool` now lives in ./shared so every toolkit here shares one tool shape. Re-exported from +// its original home so `import { type AgentTool } from '@wave-av/adk'` keeps resolving unchanged. +export type { AgentTool } from './shared'; export class AgentToolkit { private readonly baseUrl: string; @@ -24,11 +21,13 @@ export class AgentToolkit { this.baseUrl = config.baseUrl ?? 'https://api.wave.online'; } - private validated(schema: z.ZodObject, handler: (params: Record) => Promise) { - return async (params: Record) => { - const parsed = schema.parse(params); - return handler(parsed as Record); - }; + // Kept as a thin instance method so the ten `this.validated(...)` call sites below stay untouched; + // the implementation is the shared one every toolkit uses. + private validated( + schema: z.ZodObject, + handler: (params: Record) => Promise, + ) { + return validated(schema, handler); } getTools(): AgentTool[] { @@ -151,18 +150,10 @@ export class AgentToolkit { ]; } - toMCPTools(): { name: string; description: string; inputSchema: Record }[] { - return this.getTools().map(tool => ({ - name: tool.name, - description: tool.description, - inputSchema: { - type: 'object', - properties: Object.fromEntries( - Object.entries(tool.parameters).map(([key, val]) => [key, { type: val.type, description: val.description }]) - ), - required: Object.entries(tool.parameters).filter(([_, v]) => v.required).map(([k]) => k), - }, - })); + // Delegates to the shared mapper so all four toolkits emit byte-identical MCP definitions. The + // return type is spelled out rather than aliased to keep the emitted .d.ts shape unchanged. + toMCPTools(): MCPToolDef[] { + return toMCPToolDefs(this.getTools()); } private async call(method: string, path: string, body?: Record): Promise { diff --git a/src/tools/DispatchToolkit.ts b/src/tools/DispatchToolkit.ts new file mode 100644 index 0000000..70b7ad8 --- /dev/null +++ b/src/tools/DispatchToolkit.ts @@ -0,0 +1,104 @@ +/** + * DispatchToolkit — WAVE Dispatch model routing as agent tools. + * + * Dispatch is NOT behind the product gateway: it runs on its OWN host and takes a plain bearer, so + * this toolkit defaults to `dispatch.wave.online` rather than the gateway front door. Mixing the two + * base URLs is the easy mistake here — `api.wave.online` fronts the product spokes and the payment + * rails; it does not front Dispatch. + * + * Paths come from wave-dispatch's own committed, resolver-verified facts: + * + * POST / classify a prompt → { route, decision } + * GET /profiles named routing profiles (Fast / Expert / Heavy / Code) + * + * `/profiles` is behind a server-side feature flag, so it can legitimately report as unavailable in a + * given environment. That is surfaced to the caller as a `WaveToolError` rather than hidden behind an + * empty list — an agent that silently gets `[]` cannot tell "no profiles" from "feature is off". + */ + +import { z } from 'zod'; +import { + assertOk, + toMCPToolDefs, + validated, + type AgentTool, + type MCPToolDef, +} from './shared'; + +export interface DispatchToolkitConfig { + /** WAVE API key. Read it from the environment — never hardcode it. */ + apiKey: string; + /** Dispatch host. Defaults to production Dispatch, NOT the product gateway. */ + baseUrl?: string; +} + +export class DispatchToolkit { + private readonly baseUrl: string; + private readonly apiKey: string; + + constructor(config: DispatchToolkitConfig) { + this.apiKey = config.apiKey; + this.baseUrl = config.baseUrl ?? 'https://dispatch.wave.online'; + } + + private async call(tool: string, path: string, body?: Record): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + method: body ? 'POST' : 'GET', + headers: { + Authorization: `Bearer ${this.apiKey}`, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + await assertOk(tool, response); + return response.json(); + } + + /** Classify a prompt and get back the route Dispatch selected plus its reasoning. */ + async route(params: { prompt: string; profile?: string }): Promise { + const body: Record = { prompt: params.prompt }; + if (params.profile) body.profile = params.profile; + return this.call('wave_route', '/', body); + } + + /** List the named routing profiles. Feature-flagged server-side. */ + async listProfiles(): Promise { + return this.call('wave_list_routing_profiles', '/profiles'); + } + + getTools(): AgentTool[] { + const routeSchema = z.object({ prompt: z.string().min(1), profile: z.string().optional() }); + const profilesSchema = z.object({}); + + return [ + { + name: 'wave_route', + description: + 'Classify a prompt with WAVE Dispatch and get back the model route it selected plus the ' + + 'reasoning behind that decision. Use this to pick the cheapest capable model for a task ' + + 'instead of hardcoding one.', + parameters: { + prompt: { type: 'string', description: 'The prompt to classify and route', required: true }, + profile: { type: 'string', description: 'Named routing profile to route under' }, + }, + schema: routeSchema, + handler: validated(routeSchema, (params) => + this.route(params as { prompt: string; profile?: string }), + ), + }, + { + name: 'wave_list_routing_profiles', + description: + "List WAVE Dispatch's named routing profiles (Fast / Expert / Heavy / Code chains). This is " + + 'feature-flagged server-side and may report as unavailable in some environments.', + parameters: {}, + schema: profilesSchema, + handler: validated(profilesSchema, () => this.listProfiles()), + }, + ]; + } + + toMCPTools(): MCPToolDef[] { + return toMCPToolDefs(this.getTools()); + } +} diff --git a/src/tools/FleetToolkit.ts b/src/tools/FleetToolkit.ts new file mode 100644 index 0000000..e83119c --- /dev/null +++ b/src/tools/FleetToolkit.ts @@ -0,0 +1,246 @@ +/** + * FleetToolkit — the shipped WAVE product spokes as agent tools: voice, transcribe, captions. + * + * GROUNDING: every path and parameter below comes from the spokes' OWN routers at `origin/main`, not + * from `api-spec/openapi.yaml`. The spec over-declares this surface (`/voice/generate`, + * `/voice/voices`, `/transcribe/{id}`, `/captions/{jobId}/download`, …) and none of those exist — + * each spoke owns its whole `/v1` namespace with an exact-match router that 404s anything else, and + * the gateway forwards `/v1/` verbatim with no path rewriting. Filed as wave-av/api-spec#33. + * The three real endpoints are: + * + * POST /v1/voice wave-voice-edge → audio bytes (audio/mpeg) + * POST /v1/transcribe wave-transcribe-edge → JSON transcript + * POST /v1/captions wave-captions-edge → a caption FILE (WebVTT / SubRip) or JSON cues + * + * AUDIO INPUT IS BY URL. Both STT spokes accept audio as a raw request body or via a `?url=` they + * fetch server-side. Tool arguments are structured data, so the URL path is the only workable one — + * and it is how an agent already holds media. The URL is validated as well-formed here; SSRF + * containment is the spoke's responsibility, since it is the party that performs the fetch. + */ + +import { z } from 'zod'; +import { + assertOk, + readUsage, + toMCPToolDefs, + validated, + type AgentTool, + type MCPToolDef, + type WaveUsage, +} from './shared'; + +export interface FleetToolkitConfig { + /** WAVE API key. Read it from the environment — never hardcode it (`.wave-rules/no-hardcoded-keys.md`). */ + apiKey: string; + /** Gateway front door. Defaults to the production gateway. */ + baseUrl?: string; +} + +/** STT engines both the transcribe and captions spokes accept. `auto` resolves to a concrete engine + * by payload size inside the spoke. */ +const ENGINE = z.enum(['auto', 'whisper', 'deepgram', 'elevenlabs']); + +/** + * The result of a speech-synthesis call. + * + * `audio` holds the real bytes, because discarding a response body in an SDK is lossy and the caller + * cannot recover it. But `toJSON()` deliberately omits them: agent-framework adapters routinely + * `JSON.stringify` a tool result into a model's context, and a megabyte of serialised byte array + * there is useless to a model that cannot listen to it. So code gets the audio and a serialiser gets + * a receipt — no caller has to choose. + */ +export class VoiceResult { + readonly audio: Uint8Array; + readonly contentType: string; + readonly usage: WaveUsage; + + constructor(audio: Uint8Array, contentType: string, usage: WaveUsage) { + this.audio = audio; + this.contentType = contentType; + this.usage = usage; + } + + get byteLength(): number { + return this.audio.byteLength; + } + + toJSON(): Record { + return { + contentType: this.contentType, + byteLength: this.byteLength, + usage: this.usage, + audio: '', + }; + } +} + +export interface TranscriptResult { + readonly transcript: unknown; + readonly usage: WaveUsage; +} + +export interface CaptionResult { + /** The caption file verbatim (WebVTT / SubRip text, or a JSON cue document as text). */ + readonly captions: string; + readonly contentType: string; + readonly usage: WaveUsage; +} + +/** Build the query string from the knobs the STT spokes actually read. */ +function audioQuery(args: { + url: string; + engine?: string; + language?: string; + diarize?: boolean; + format?: string; +}): string { + const p = new URLSearchParams({ url: args.url }); + if (args.engine) p.set('engine', args.engine); + if (args.language) p.set('language', args.language); + if (args.diarize) p.set('diarize', 'true'); + if (args.format) p.set('format', args.format); + return p.toString(); +} + +export class FleetToolkit { + private readonly baseUrl: string; + private readonly apiKey: string; + + constructor(config: FleetToolkitConfig) { + this.apiKey = config.apiKey; + this.baseUrl = config.baseUrl ?? 'https://api.wave.online'; + } + + /** + * POST to a gateway-fronted spoke. + * + * Content-Type is the CALLER's to set, not this method's: the STT spokes distinguish a + * query-parameterised request from a raw-media body by content type, so forcing + * `application/json` on every call (as `AgentToolkit.call` does) misroutes them. + */ + private async post(tool: string, path: string, init?: RequestInit): Promise { + const response = await fetch(`${this.baseUrl}${path}`, { + ...init, + method: 'POST', + headers: { Authorization: `Bearer ${this.apiKey}`, ...init?.headers }, + }); + await assertOk(tool, response); + return response; + } + + /** Synthesize speech. Returns the audio bytes plus what the call was billed. */ + async speak(params: { text: string; voiceId?: string; modelId?: string }): Promise { + const body: Record = { text: params.text }; + if (params.voiceId) body.voiceId = params.voiceId; + if (params.modelId) body.modelId = params.modelId; + + const response = await this.post('wave_speak', '/v1/voice', { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const audio = new Uint8Array(await response.arrayBuffer()); + return new VoiceResult(audio, response.headers.get('content-type') ?? 'audio/mpeg', readUsage(response)); + } + + /** Transcribe audio hosted at a publicly reachable URL. */ + async transcribe(params: { + url: string; + engine?: string; + language?: string; + diarize?: boolean; + }): Promise { + const response = await this.post('wave_transcribe', `/v1/transcribe?${audioQuery(params)}`); + const usage = readUsage(response); + return { transcript: await response.json(), usage }; + } + + /** Generate a caption file from audio hosted at a publicly reachable URL. */ + async caption(params: { + url: string; + format?: string; + engine?: string; + language?: string; + }): Promise { + const response = await this.post('wave_caption', `/v1/captions?${audioQuery(params)}`); + const usage = readUsage(response); + return { + captions: await response.text(), + contentType: response.headers.get('content-type') ?? 'text/vtt', + usage, + }; + } + + getTools(): AgentTool[] { + const speakSchema = z.object({ + text: z.string().min(1), + voiceId: z.string().optional(), + modelId: z.string().optional(), + }); + const transcribeSchema = z.object({ + url: z.url(), + engine: ENGINE.optional(), + language: z.string().optional(), + diarize: z.boolean().optional(), + }); + const captionSchema = z.object({ + url: z.url(), + format: z.enum(['vtt', 'srt', 'json']).optional(), + engine: ENGINE.optional(), + language: z.string().optional(), + }); + + return [ + { + name: 'wave_speak', + description: + 'Synthesize speech from text with WAVE Voice. Returns the audio bytes plus the billed usage; ' + + 'serialising the result yields a receipt rather than the bytes.', + parameters: { + text: { type: 'string', description: 'Text to synthesize', required: true }, + voiceId: { type: 'string', description: 'Voice to use (defaults to the WAVE default voice)' }, + modelId: { type: 'string', description: 'TTS model id (defaults to the multilingual model)' }, + }, + schema: speakSchema, + handler: validated(speakSchema, (params) => + this.speak(params as { text: string; voiceId?: string; modelId?: string }), + ), + }, + { + name: 'wave_transcribe', + description: + 'Transcribe audio from a publicly reachable URL with WAVE Transcribe. The spoke fetches the ' + + 'URL itself, so it must be reachable from the public internet.', + parameters: { + url: { type: 'string', description: 'Publicly reachable URL of the audio', required: true }, + engine: { type: 'string', description: 'STT engine: auto, whisper, deepgram, elevenlabs' }, + language: { type: 'string', description: "BCP-47 language hint, e.g. 'en'" }, + diarize: { type: 'boolean', description: 'Label distinct speakers' }, + }, + schema: transcribeSchema, + handler: validated(transcribeSchema, (params) => + this.transcribe(params as { url: string; engine?: string; language?: string; diarize?: boolean }), + ), + }, + { + name: 'wave_caption', + description: + 'Generate a caption file (WebVTT, SubRip, or JSON cues) from audio at a publicly reachable ' + + 'URL with WAVE Captions.', + parameters: { + url: { type: 'string', description: 'Publicly reachable URL of the audio', required: true }, + format: { type: 'string', description: 'Caption format: vtt, srt, json' }, + engine: { type: 'string', description: 'STT engine: auto, whisper, deepgram, elevenlabs' }, + language: { type: 'string', description: "BCP-47 language hint, e.g. 'en'" }, + }, + schema: captionSchema, + handler: validated(captionSchema, (params) => + this.caption(params as { url: string; format?: string; engine?: string; language?: string }), + ), + }, + ]; + } + + toMCPTools(): MCPToolDef[] { + return toMCPToolDefs(this.getTools()); + } +} diff --git a/src/tools/PaymentsToolkit.ts b/src/tools/PaymentsToolkit.ts new file mode 100644 index 0000000..2de3114 --- /dev/null +++ b/src/tools/PaymentsToolkit.ts @@ -0,0 +1,113 @@ +/** + * PaymentsToolkit — WAVE's agent-payment rails (x402 and MPP) as agent tools. + * + * These gateway routes are PUBLIC BY DESIGN: the gateway serves them with no key auth, only a per-IP + * rate limit, because a paying agent has to discover what it can buy and which payment schemes are + * supported BEFORE it holds a WAVE key. + * + * GET /v1/mpp/services semantic search over WAVE's MPP service records + * GET /v1/x402/facilitator/supported payment schemes/networks the x402 facilitator supports + * GET /v1/mpp/facilitator/supported the same, for MPP + * + * This class takes NO API KEY — not an optional one, none at all. Sending a credential to an endpoint + * that does not authenticate it leaks the credential for zero benefit, and the surest way to never do + * that is to have nothing to send. + * + * Only the READ half of the rails is exposed. The facilitator's `verify` and `settle` endpoints are + * the money-moving side and are deliberately NOT wrapped as agent tools. + */ + +import { z } from 'zod'; +import { + assertOk, + toMCPToolDefs, + validated, + type AgentTool, + type MCPToolDef, +} from './shared'; + +export interface PaymentsToolkitConfig { + /** Gateway front door. Defaults to the production gateway. */ + baseUrl?: string; +} + +export class PaymentsToolkit { + private readonly baseUrl: string; + + constructor(config: PaymentsToolkitConfig = {}) { + this.baseUrl = config.baseUrl ?? 'https://api.wave.online'; + } + + /** Unauthenticated GET against the gateway's public plane. No Authorization header, on purpose. */ + private async publicGet(tool: string, path: string): Promise { + const response = await fetch(`${this.baseUrl}${path}`); + await assertOk(tool, response); + return response.json(); + } + + /** Search the MPP service directory for machine-payable services. */ + async findPaidServices(params: { + q: string; + protocol?: string; + tag?: string; + topK?: number; + }): Promise { + const p = new URLSearchParams({ q: params.q }); + if (params.protocol) p.set('protocol', params.protocol); + if (params.tag) p.set('tag', params.tag); + if (params.topK !== undefined) p.set('topK', String(params.topK)); + return this.publicGet('wave_find_paid_services', `/v1/mpp/services?${p.toString()}`); + } + + /** List the payment schemes and networks WAVE's facilitator supports for a rail. */ + async paymentSchemes(params: { rail: 'x402' | 'mpp' }): Promise { + return this.publicGet('wave_payment_schemes', `/v1/${params.rail}/facilitator/supported`); + } + + getTools(): AgentTool[] { + const findSchema = z.object({ + q: z.string().min(1), + protocol: z.string().optional(), + tag: z.string().optional(), + topK: z.number().int().positive().max(50).optional(), + }); + const schemesSchema = z.object({ rail: z.enum(['x402', 'mpp']) }); + + return [ + { + name: 'wave_find_paid_services', + description: + "Search WAVE's MPP service directory for machine-payable services an agent can buy from. " + + 'Semantic search — describe what you need in plain language. Public: no API key required.', + parameters: { + q: { type: 'string', description: "What you're looking for, in plain language", required: true }, + protocol: { type: 'string', description: "Filter by payment protocol, e.g. 'x402'" }, + tag: { type: 'string', description: 'Filter by service tag' }, + topK: { type: 'number', description: 'How many results to return (max 50)' }, + }, + schema: findSchema, + handler: validated(findSchema, (params) => + this.findPaidServices(params as { q: string; protocol?: string; tag?: string; topK?: number }), + ), + }, + { + name: 'wave_payment_schemes', + description: + "List the payment schemes and networks WAVE's facilitator supports, for x402 or MPP. Call " + + 'this before constructing a payment so you settle on a scheme WAVE actually accepts. ' + + 'Public: no API key required.', + parameters: { + rail: { type: 'string', description: 'Which payment rail to query: x402 or mpp', required: true }, + }, + schema: schemesSchema, + handler: validated(schemesSchema, (params) => + this.paymentSchemes(params as { rail: 'x402' | 'mpp' }), + ), + }, + ]; + } + + toMCPTools(): MCPToolDef[] { + return toMCPToolDefs(this.getTools()); + } +} diff --git a/src/tools/index.ts b/src/tools/index.ts index f8e2072..b69ed2f 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1 +1,17 @@ export { AgentToolkit, type AgentTool } from './AgentToolkit'; +export { + WaveToolError, + toMCPToolDefs, + readUsage, + type MCPToolDef, + type WaveUsage, +} from './shared'; +export { + FleetToolkit, + VoiceResult, + type FleetToolkitConfig, + type TranscriptResult, + type CaptionResult, +} from './FleetToolkit'; +export { DispatchToolkit, type DispatchToolkitConfig } from './DispatchToolkit'; +export { PaymentsToolkit, type PaymentsToolkitConfig } from './PaymentsToolkit'; diff --git a/src/tools/shared.ts b/src/tools/shared.ts new file mode 100644 index 0000000..d602c8d --- /dev/null +++ b/src/tools/shared.ts @@ -0,0 +1,118 @@ +/** + * Shared plumbing for the ADK toolkits. + * + * `AgentTool` and the MCP-shape mapping live here (rather than inside `AgentToolkit`) so every + * toolkit in this directory produces the SAME tool shape and the SAME MCP definitions. Behaviour is + * unchanged for existing consumers: `AgentToolkit` re-exports `AgentTool` from here, so + * `import { type AgentTool } from '@wave-av/adk'` resolves exactly as before. + */ + +import { z } from 'zod'; + +/** A single agent-invocable tool. `parameters` is the human/MCP-facing description of the inputs; + * `schema` is the zod contract actually enforced at call time. They are kept side by side because + * MCP clients need a JSON-Schema-ish shape while the handler needs real validation. */ +export interface AgentTool { + readonly name: string; + readonly description: string; + readonly parameters: Record; + readonly schema: z.ZodObject; + readonly handler: (params: Record) => Promise; +} + +/** An MCP tool definition, as consumed by Claude / Cursor / any MCP client. */ +export interface MCPToolDef { + name: string; + description: string; + inputSchema: Record; +} + +/** + * Thrown when a WAVE endpoint answers with a non-2xx status. + * + * The raw response body is preserved verbatim on `.body` rather than being summarised away: WAVE's + * gateway and spokes return structured, actionable error bodies (`{"error":"discovery_unavailable", + * "reason":"vectorize_unbound"}`), and swallowing them is how a real production gap goes unnoticed. + * These bodies are gateway-authored and carry no credential material. + */ +export class WaveToolError extends Error { + readonly status: number; + readonly body: string; + readonly tool: string; + + constructor(tool: string, status: number, body: string) { + super(`${tool} failed: HTTP ${status}${body ? ` — ${body.slice(0, 500)}` : ''}`); + this.name = 'WaveToolError'; + this.tool = tool; + this.status = status; + this.body = body; + } + + /** A 429 is the one status a caller is expected to back off on rather than treat as fatal + * (`.wave-rules/require-rate-limit-awareness.md`). */ + get isRateLimited(): boolean { + return this.status === 429; + } +} + +/** Metering + rate-limit facts every WAVE response carries. Surfaced on results so an agent can see + * what a call cost and how much headroom it has left, instead of discovering both from an invoice. */ +export interface WaveUsage { + /** The billing meter the spoke reported, e.g. `wave_voice_minutes`. */ + readonly meter: string | null; + /** Billable minutes for this call, if the spoke reported them. */ + readonly usageMinutes: number | null; + /** Remaining calls in the current rate-limit window, if the gateway reported it. */ + readonly rateLimitRemaining: number | null; +} + +/** Read the usage/rate-limit headers off a response. Absent headers become `null` — never `0`, + * which would falsely read as "no quota left". */ +export function readUsage(response: Response): WaveUsage { + const minutes = response.headers.get('x-wave-usage-minutes'); + const remaining = response.headers.get('x-ratelimit-remaining'); + return { + meter: response.headers.get('x-wave-meter'), + usageMinutes: minutes === null ? null : Number(minutes), + rateLimitRemaining: remaining === null ? null : Number(remaining), + }; +} + +/** Wrap a handler so its params are zod-validated before the network call is made. */ +export function validated( + schema: z.ZodObject, + handler: (params: Record) => Promise, +): (params: Record) => Promise { + return async (params: Record) => { + const parsed = schema.parse(params); + return handler(parsed as Record); + }; +} + +/** Map `AgentTool[]` to MCP tool definitions. */ +export function toMCPToolDefs(tools: readonly AgentTool[]): MCPToolDef[] { + return tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: { + type: 'object', + properties: Object.fromEntries( + Object.entries(tool.parameters).map(([key, val]) => [ + key, + { type: val.type, description: val.description }, + ]), + ), + required: Object.entries(tool.parameters) + .filter(([, v]) => v.required) + .map(([k]) => k), + }, + })); +} + +/** Throw `WaveToolError` unless the response is 2xx. Reads the body once, so callers must not read + * it again on the failure path. */ +export async function assertOk(tool: string, response: Response): Promise { + if (response.ok) return; + const body = await response.text().catch(() => ''); + throw new WaveToolError(tool, response.status, body); +}