diff --git a/typescript/.changeset/bright-dingos-approve.md b/typescript/.changeset/bright-dingos-approve.md new file mode 100644 index 000000000..e694002fa --- /dev/null +++ b/typescript/.changeset/bright-dingos-approve.md @@ -0,0 +1,6 @@ +--- +"@coinbase/agentkit": patch +--- + +Added a Robomoustachio action provider for querying ERC-8004 trust scores, detailed reports, and pre-flight risk evaluations on Base mainnet. + diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 7f2b0233a..0e7852971 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -40,3 +40,4 @@ export * from "./zerion"; export * from "./zerodev"; export * from "./zeroX"; export * from "./zora"; +export * from "./robomoustachio"; diff --git a/typescript/agentkit/src/action-providers/robomoustachio/README.md b/typescript/agentkit/src/action-providers/robomoustachio/README.md new file mode 100644 index 000000000..c2681b383 --- /dev/null +++ b/typescript/agentkit/src/action-providers/robomoustachio/README.md @@ -0,0 +1,28 @@ +# Robomoustachio Action Provider + +This provider adds trust-check actions for [Robomoustachio](https://robomoustach.io), an ERC-8004 trust oracle on Base. + +## Actions + +1. `get_agent_trust_score` +- Returns an agent's trust score and confidence. + +2. `get_agent_trust_report` +- Returns score plus report metadata (flags, risk factors, trend, feedback summary). + +3. `evaluate_agent_risk` +- Returns `APPROVED` / `REJECTED` using a configurable score threshold and report flags. + +## Defaults + +- `baseUrl`: `https://robomoustach.io` +- `defaultDemo`: `true` +- `requestTimeoutMs`: `10000` +- `defaultScoreThreshold`: `500` + +By default, requests use `?demo=true` so agents can evaluate trust without requiring x402 payment setup. + +## Network support + +This provider is enabled on Base mainnet (`chainId=8453`, `networkId=base-mainnet`). + diff --git a/typescript/agentkit/src/action-providers/robomoustachio/index.ts b/typescript/agentkit/src/action-providers/robomoustachio/index.ts new file mode 100644 index 000000000..f576d9732 --- /dev/null +++ b/typescript/agentkit/src/action-providers/robomoustachio/index.ts @@ -0,0 +1,3 @@ +export * from "./robomoustachioActionProvider"; +export * from "./schemas"; + diff --git a/typescript/agentkit/src/action-providers/robomoustachio/robomoustachioActionProvider.test.ts b/typescript/agentkit/src/action-providers/robomoustachio/robomoustachioActionProvider.test.ts new file mode 100644 index 000000000..5a64a0f91 --- /dev/null +++ b/typescript/agentkit/src/action-providers/robomoustachio/robomoustachioActionProvider.test.ts @@ -0,0 +1,253 @@ +import { Network } from "../../network"; +import { robomoustachioActionProvider } from "./robomoustachioActionProvider"; + +const fetchMock = jest.fn(); +global.fetch = fetchMock; + +function makeResponse( + status: number, + body: unknown, + headers: Record = { "content-type": "application/json" }, +): Response { + const lowerHeaders = new Map( + Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]), + ); + + return { + status, + ok: status >= 200 && status < 300, + headers: { + get: (name: string) => lowerHeaders.get(name.toLowerCase()) ?? null, + }, + text: jest.fn().mockResolvedValue(JSON.stringify(body)), + } as unknown as Response; +} + +function makeRawResponse( + status: number, + rawBody: string, + headers: Record = { "content-type": "text/plain" }, +): Response { + const lowerHeaders = new Map( + Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]), + ); + + return { + status, + ok: status >= 200 && status < 300, + headers: { + get: (name: string) => lowerHeaders.get(name.toLowerCase()) ?? null, + }, + text: jest.fn().mockResolvedValue(rawBody), + } as unknown as Response; +} + +describe("RobomoustachioActionProvider", () => { + const provider = robomoustachioActionProvider(); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("supports Base mainnet only", () => { + const baseMainnet: Network = { protocolFamily: "evm", chainId: "8453", networkId: "base-mainnet" }; + const baseSepolia: Network = { protocolFamily: "evm", chainId: "84532", networkId: "base-sepolia" }; + const ethereumMainnet: Network = { protocolFamily: "evm", chainId: "1", networkId: "ethereum" }; + + expect(provider.supportsNetwork(baseMainnet)).toBe(true); + expect(provider.supportsNetwork(baseSepolia)).toBe(false); + expect(provider.supportsNetwork(ethereumMainnet)).toBe(false); + }); + + it("returns trust score for successful response", async () => { + fetchMock.mockResolvedValueOnce( + makeResponse(200, { + agentId: "2", + score: 950, + confidence: 1, + }), + ); + + const result = JSON.parse(await provider.getAgentTrustScore({ agentId: "2" })); + + expect(result.success).toBe(true); + expect(result.mode).toBe("demo"); + expect(result.score).toBe(950); + expect(result.verdict).toBe("TRUSTED"); + }); + + it("returns a structured error when score endpoint returns 404", async () => { + fetchMock.mockResolvedValueOnce( + makeResponse(404, { + error: "Score not found for agent", + }), + ); + + const result = JSON.parse(await provider.getAgentTrustScore({ agentId: "6" })); + + expect(result.success).toBe(false); + expect(result.verdict).toBe("UNKNOWN"); + expect(result.status).toBe(404); + }); + + it("returns a structured error when score endpoint returns non-JSON success", async () => { + fetchMock.mockResolvedValueOnce(makeRawResponse(200, "Unauthorized")); + + const result = JSON.parse(await provider.getAgentTrustScore({ agentId: "10" })); + + expect(result.success).toBe(false); + expect(result.status).toBe(200); + expect(result.error).toContain("invalid JSON"); + }); + + it("surfaces payment requirement details when demo mode is disabled", async () => { + fetchMock.mockResolvedValueOnce( + makeResponse(402, { + error: "X-PAYMENT header is required", + }), + ); + + const result = JSON.parse(await provider.getAgentTrustScore({ agentId: "1", demo: false })); + + expect(result.success).toBe(false); + expect(result.status).toBe(402); + expect(result.error).toContain("Payment required"); + }); + + it("returns report fields for successful report response", async () => { + fetchMock.mockResolvedValueOnce( + makeResponse(200, { + agentId: "3", + score: 400, + confidence: 0.4, + totalFeedback: 20, + positiveFeedback: 8, + flagged: true, + riskFactors: ["high_negative_feedback_ratio"], + }), + ); + + const result = JSON.parse(await provider.getAgentTrustReport({ agentId: "3" })); + + expect(result.success).toBe(true); + expect(result.flagged).toBe(true); + expect(result.riskFactors).toEqual(["high_negative_feedback_ratio"]); + expect(result.verdict).toBe("CAUTION"); + }); + + it("evaluateAgentRisk approves when score meets threshold and not flagged", async () => { + fetchMock + .mockResolvedValueOnce( + makeResponse(200, { + agentId: "2", + score: 880, + confidence: 0.9, + }), + ) + .mockResolvedValueOnce( + makeResponse(200, { + agentId: "2", + flagged: false, + riskFactors: [], + }), + ); + + const result = JSON.parse( + await provider.evaluateAgentRisk({ + agentId: "2", + scoreThreshold: 700, + }), + ); + + expect(result.success).toBe(true); + expect(result.verdict).toBe("APPROVED"); + expect(result.flagged).toBe(false); + }); + + it("evaluateAgentRisk rejects when score endpoint is unavailable", async () => { + fetchMock + .mockResolvedValueOnce( + makeResponse(500, { + error: "Internal error", + }), + ) + .mockResolvedValueOnce( + makeResponse(200, { + agentId: "5", + flagged: true, + }), + ); + + const result = JSON.parse(await provider.evaluateAgentRisk({ agentId: "5" })); + + expect(result.success).toBe(false); + expect(result.verdict).toBe("REJECTED"); + expect(result.reason).toContain("Defaulting to REJECTED"); + }); + + it("evaluateAgentRisk rejects when flagged even if score is high", async () => { + fetchMock + .mockResolvedValueOnce( + makeResponse(200, { + agentId: "7", + score: 900, + confidence: 0.95, + }), + ) + .mockResolvedValueOnce( + makeResponse(200, { + agentId: "7", + flagged: true, + riskFactors: ["manual_flag"], + }), + ); + + const result = JSON.parse(await provider.evaluateAgentRisk({ agentId: "7" })); + + expect(result.success).toBe(true); + expect(result.verdict).toBe("REJECTED"); + expect(result.reason).toContain("flagged"); + }); + + it("evaluateAgentRisk rejects when report endpoint is unavailable", async () => { + fetchMock + .mockResolvedValueOnce( + makeResponse(200, { + agentId: "8", + score: 920, + confidence: 0.92, + }), + ) + .mockResolvedValueOnce( + makeResponse(503, { + error: "Service unavailable", + }), + ); + + const result = JSON.parse(await provider.evaluateAgentRisk({ agentId: "8", scoreThreshold: 700 })); + + expect(result.success).toBe(false); + expect(result.verdict).toBe("REJECTED"); + expect(result.reason).toContain("Trust report unavailable"); + }); + + it("preserves base path prefixes when constructing request URLs", async () => { + const prefixedProvider = robomoustachioActionProvider({ + baseUrl: "https://example.com/api/v1", + defaultDemo: true, + }); + + fetchMock.mockResolvedValueOnce( + makeResponse(404, { + error: "missing", + }), + ); + + await prefixedProvider.getAgentTrustScore({ agentId: "2" }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://example.com/api/v1/score/2?demo=true", + expect.any(Object), + ); + }); +}); diff --git a/typescript/agentkit/src/action-providers/robomoustachio/robomoustachioActionProvider.ts b/typescript/agentkit/src/action-providers/robomoustachio/robomoustachioActionProvider.ts new file mode 100644 index 000000000..74af47763 --- /dev/null +++ b/typescript/agentkit/src/action-providers/robomoustachio/robomoustachioActionProvider.ts @@ -0,0 +1,440 @@ +import { z } from "zod"; +import { ActionProvider } from "../actionProvider"; +import { CreateAction } from "../actionDecorator"; +import { Network } from "../../network"; +import { + EvaluateAgentRiskSchema, + GetAgentTrustReportSchema, + GetAgentTrustScoreSchema, + RobomoustachioActionProviderConfig, +} from "./schemas"; + +const DEFAULT_BASE_URL = "https://robomoustach.io"; +const DEFAULT_TIMEOUT_MS = 10_000; +const DEFAULT_SCORE_THRESHOLD = 500; + +interface ResolvedConfig { + baseUrl: string; + defaultDemo: boolean; + requestTimeoutMs: number; + defaultScoreThreshold: number; +} + +interface RequestResult { + ok: boolean; + status: number; + data: unknown; + error: string | null; +} + +interface ParseBodyResult { + parsed: boolean; + data: unknown; +} + +type Verdict = "TRUSTED" | "CAUTION" | "RISKY" | "UNKNOWN"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && value.trim().length > 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +function readBoolean(value: unknown): boolean | null { + if (typeof value === "boolean") { + return value; + } + if (typeof value === "string") { + if (value.toLowerCase() === "true") { + return true; + } + if (value.toLowerCase() === "false") { + return false; + } + } + return null; +} + +function readStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter(item => typeof item === "string"); +} + +function classifyScore(score: number | null): { verdict: Verdict; recommendation: string } { + if (score === null) { + return { + verdict: "UNKNOWN", + recommendation: "Score unavailable. Perform manual review before transacting.", + }; + } + + if (score >= 700) { + return { + verdict: "TRUSTED", + recommendation: "Strong reputation signal. Safe to proceed with normal safeguards.", + }; + } + + if (score >= 400) { + return { + verdict: "CAUTION", + recommendation: "Mixed reputation signal. Proceed with limits and additional checks.", + }; + } + + return { + verdict: "RISKY", + recommendation: "Weak reputation signal. Avoid transacting unless independently verified.", + }; +} + +/** + * Action provider for Robomoustachio's ERC-8004 trust oracle. + */ +export class RobomoustachioActionProvider extends ActionProvider { + private readonly config: ResolvedConfig; + + constructor(config: RobomoustachioActionProviderConfig = {}) { + super("robomoustachio", []); + + this.config = { + baseUrl: (config.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ""), + defaultDemo: config.defaultDemo ?? true, + requestTimeoutMs: config.requestTimeoutMs ?? DEFAULT_TIMEOUT_MS, + defaultScoreThreshold: config.defaultScoreThreshold ?? DEFAULT_SCORE_THRESHOLD, + }; + } + + @CreateAction({ + name: "get_agent_trust_score", + description: `Get an agent's trust score from Robomoustachio's ERC-8004 oracle. + +Inputs: +- agentId: Numeric ERC-8004 agent ID string +- demo: Optional boolean. If true, request uses demo mode and avoids x402 payments. + +Use this before transacting with unknown agents. A higher score means better reputation.`, + schema: GetAgentTrustScoreSchema, + }) + async getAgentTrustScore(args: z.infer): Promise { + const demo = this.resolveDemoMode(args.demo); + const result = await this.request(`/score/${args.agentId}`, demo); + + if (!result.ok) { + return this.failurePayload(args.agentId, result.status, result.error, demo); + } + + if (!isRecord(result.data)) { + return this.failurePayload( + args.agentId, + result.status, + "Oracle returned an unexpected response shape.", + demo, + ); + } + + const score = readNumber(result.data.score); + const confidence = readNumber(result.data.confidence); + const classification = classifyScore(score); + + return JSON.stringify( + { + success: true, + source: "robomoustachio_api", + mode: demo ? "demo" : "paid", + agentId: String(result.data.agentId ?? args.agentId), + score, + confidence, + verdict: classification.verdict, + recommendation: classification.recommendation, + }, + null, + 2, + ); + } + + @CreateAction({ + name: "get_agent_trust_report", + description: `Get a full risk report for an ERC-8004 agent from Robomoustachio. + +Inputs: +- agentId: Numeric ERC-8004 agent ID string +- demo: Optional boolean. If true, request uses demo mode and avoids x402 payments. + +The report includes score, feedback stats, flags, trend, and risk factors for deeper due diligence.`, + schema: GetAgentTrustReportSchema, + }) + async getAgentTrustReport(args: z.infer): Promise { + const demo = this.resolveDemoMode(args.demo); + const result = await this.request(`/report/${args.agentId}`, demo); + + if (!result.ok) { + return this.failurePayload(args.agentId, result.status, result.error, demo); + } + + if (!isRecord(result.data)) { + return this.failurePayload( + args.agentId, + result.status, + "Oracle returned an unexpected response shape.", + demo, + ); + } + + const score = readNumber(result.data.score); + const flagged = readBoolean(result.data.flagged); + const riskFactors = readStringArray(result.data.riskFactors); + const classification = classifyScore(score); + + return JSON.stringify( + { + success: true, + source: "robomoustachio_api", + mode: demo ? "demo" : "paid", + agentId: String(result.data.agentId ?? args.agentId), + score, + confidence: readNumber(result.data.confidence), + totalFeedback: readNumber(result.data.totalFeedback), + positiveFeedback: readNumber(result.data.positiveFeedback), + recentTrend: result.data.recentTrend ?? null, + negativeRateBps: readNumber(result.data.negativeRateBps), + flagged, + riskFactors, + verdict: classification.verdict, + recommendation: flagged + ? "Agent is flagged in the trust report. Do not transact without manual override." + : classification.recommendation, + }, + null, + 2, + ); + } + + @CreateAction({ + name: "evaluate_agent_risk", + description: `Evaluate whether an ERC-8004 agent passes a trust threshold. + +Inputs: +- agentId: Numeric ERC-8004 agent ID string +- scoreThreshold: Optional score threshold from 0-1000 (default 500) +- demo: Optional boolean. If true, request uses demo mode and avoids x402 payments. + +Returns APPROVED or REJECTED with reasoning. If the oracle is unavailable, this action fails closed and returns REJECTED.`, + schema: EvaluateAgentRiskSchema, + }) + async evaluateAgentRisk(args: z.infer): Promise { + const demo = this.resolveDemoMode(args.demo); + const threshold = args.scoreThreshold ?? this.config.defaultScoreThreshold; + + const [scoreResult, reportResult] = await Promise.all([ + this.request(`/score/${args.agentId}`, demo), + this.request(`/report/${args.agentId}`, demo), + ]); + + if (!scoreResult.ok || !isRecord(scoreResult.data)) { + return JSON.stringify( + { + success: false, + source: "robomoustachio_api", + mode: demo ? "demo" : "paid", + agentId: args.agentId, + verdict: "REJECTED", + threshold, + reason: + "Trust oracle unavailable for score retrieval. Defaulting to REJECTED for safety.", + error: scoreResult.error, + }, + null, + 2, + ); + } + + if (!reportResult.ok || !isRecord(reportResult.data)) { + return JSON.stringify( + { + success: false, + source: "robomoustachio_api", + mode: demo ? "demo" : "paid", + agentId: args.agentId, + verdict: "REJECTED", + threshold, + score: readNumber(scoreResult.data.score), + confidence: readNumber(scoreResult.data.confidence), + reason: + "Trust report unavailable. Defaulting to REJECTED for safety until full risk context is available.", + error: reportResult.error, + }, + null, + 2, + ); + } + + const score = readNumber(scoreResult.data.score); + const reportData = reportResult.data as Record; + const flagged = readBoolean(reportData.flagged) === true; + const riskFactors = readStringArray(reportData.riskFactors); + const hasRequiredScore = score !== null && score >= threshold; + const approved = hasRequiredScore && !flagged; + + let reason = approved + ? `Score ${score} meets threshold ${threshold} and no active report flag is present.` + : `Score ${score ?? "N/A"} is below threshold ${threshold}.`; + + if (!approved && flagged) { + reason = `Agent is flagged by trust report${riskFactors.length ? ` (${riskFactors.join(", ")})` : ""}.`; + } + + return JSON.stringify( + { + success: true, + source: "robomoustachio_api", + mode: demo ? "demo" : "paid", + agentId: String(scoreResult.data.agentId ?? args.agentId), + verdict: approved ? "APPROVED" : "REJECTED", + threshold, + score, + confidence: readNumber(scoreResult.data.confidence), + flagged, + riskFactors, + reason, + }, + null, + 2, + ); + } + + supportsNetwork = (network: Network) => + network.protocolFamily === "evm" && + (network.chainId === "8453" || network.networkId === "base-mainnet"); + + private resolveDemoMode(actionDemo?: boolean): boolean { + return actionDemo ?? this.config.defaultDemo; + } + + private buildUrl(path: string, demoMode: boolean): string { + const normalizedPath = path.replace(/^\/+/, ""); + const url = new URL(normalizedPath, `${this.config.baseUrl}/`); + if (demoMode) { + url.searchParams.set("demo", "true"); + } + return url.toString(); + } + + private async request(path: string, demoMode: boolean): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs); + const url = this.buildUrl(path, demoMode); + + try { + const response = await fetch(url, { + method: "GET", + headers: { Accept: "application/json" }, + signal: controller.signal, + }); + + const rawText = await response.text(); + const parseResult = this.parseBody(rawText); + + if (!response.ok) { + return { + ok: false, + status: response.status, + data: parseResult.data, + error: this.describeHttpFailure(response.status, parseResult.data, demoMode), + }; + } + + if (!parseResult.parsed) { + return { + ok: false, + status: response.status, + data: parseResult.data, + error: "Oracle returned invalid JSON for a successful response.", + }; + } + + return { + ok: true, + status: response.status, + data: parseResult.data, + error: null, + }; + } catch (error) { + const message = + error instanceof Error && error.name === "AbortError" + ? `Request timed out after ${this.config.requestTimeoutMs}ms` + : error instanceof Error + ? error.message + : String(error); + return { + ok: false, + status: 0, + data: null, + error: `Failed to reach oracle: ${message}`, + }; + } finally { + clearTimeout(timeout); + } + } + + private parseBody(rawText: string): ParseBodyResult { + if (rawText.trim().length === 0) { + return { parsed: true, data: {} }; + } + try { + return { parsed: true, data: JSON.parse(rawText) }; + } catch { + return { parsed: false, data: { raw: rawText } }; + } + } + + private describeHttpFailure(status: number, data: unknown, demoMode: boolean): string { + if (status === 404) { + return "Agent was not found in the trust oracle."; + } + + if (status === 402) { + if (demoMode) { + return "Payment required even in demo mode. Verify oracle pricing configuration."; + } + return "Payment required. Retry with demo=true for free limited output or use x402 payment flow."; + } + + if (isRecord(data) && typeof data.error === "string") { + return data.error; + } + + return `Oracle returned HTTP ${status}.`; + } + + private failurePayload(agentId: string, status: number, error: string | null, demo: boolean): string { + return JSON.stringify( + { + success: false, + source: "robomoustachio_api", + mode: demo ? "demo" : "paid", + agentId, + verdict: "UNKNOWN", + recommendation: "Could not verify trust score. Perform manual review before transacting.", + status, + error: error ?? "Unknown oracle error", + }, + null, + 2, + ); + } +} + +export const robomoustachioActionProvider = (config?: RobomoustachioActionProviderConfig) => + new RobomoustachioActionProvider(config); diff --git a/typescript/agentkit/src/action-providers/robomoustachio/schemas.ts b/typescript/agentkit/src/action-providers/robomoustachio/schemas.ts new file mode 100644 index 000000000..cd1c0757c --- /dev/null +++ b/typescript/agentkit/src/action-providers/robomoustachio/schemas.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; + +const NumericAgentIdSchema = z + .string() + .regex(/^\d+$/, "agentId must be a numeric string") + .describe("The ERC-8004 agent ID to inspect (numeric string, for example: '2')."); + +export interface RobomoustachioActionProviderConfig { + /** + * Base URL for the trust oracle API. + * + * @default "https://robomoustach.io" + */ + baseUrl?: string; + + /** + * Whether API calls should default to demo mode. + * + * Demo mode appends `?demo=true` and is designed for no-wallet reads. + * + * @default true + */ + defaultDemo?: boolean; + + /** + * Request timeout in milliseconds. + * + * @default 10000 + */ + requestTimeoutMs?: number; + + /** + * Default minimum score for `evaluate_agent_risk`. + * + * @default 500 + */ + defaultScoreThreshold?: number; +} + +export const GetAgentTrustScoreSchema = z + .object({ + agentId: NumericAgentIdSchema, + demo: z + .boolean() + .optional() + .describe("Optional override for demo mode. `true` avoids x402 payment requirements."), + }) + .strip() + .describe("Inputs for fetching an agent trust score."); + +export const GetAgentTrustReportSchema = z + .object({ + agentId: NumericAgentIdSchema, + demo: z + .boolean() + .optional() + .describe("Optional override for demo mode. `true` avoids x402 payment requirements."), + }) + .strip() + .describe("Inputs for fetching an agent trust report."); + +export const EvaluateAgentRiskSchema = z + .object({ + agentId: NumericAgentIdSchema, + scoreThreshold: z + .number() + .min(0) + .max(1000) + .optional() + .describe("Minimum acceptable score from 0-1000. Defaults to provider config value."), + demo: z + .boolean() + .optional() + .describe("Optional override for demo mode. `true` avoids x402 payment requirements."), + }) + .strip() + .describe("Inputs for pre-flight risk evaluation before transacting with an agent."); +