diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10485065..169500c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -791,6 +791,40 @@ importers: specifier: ^1.0.0 version: 1.6.1(@types/node@20.19.39)(jsdom@23.2.0) + services/ai-agent: + dependencies: + express: + specifier: ^4.18.2 + version: 4.22.1 + zod: + specifier: ^3.22.4 + version: 3.25.76 + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/jest': + specifier: ^29.5.0 + version: 29.5.14 + '@types/node': + specifier: ^20.0.0 + version: 20.19.39 + '@types/supertest': + specifier: ^6.0.2 + version: 6.0.3 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.39) + supertest: + specifier: ^6.3.4 + version: 6.3.4 + ts-jest: + specifier: ^29.1.0 + version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.39))(typescript@5.9.3) + typescript: + specifier: ^5.3.0 + version: 5.9.3 + services/relayer: dependencies: express: @@ -8670,7 +8704,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 20.19.39 + '@types/node': 25.6.0 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -8692,14 +8726,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.39 + '@types/node': 25.6.0 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.19.39) + jest-config: 29.7.0(@types/node@25.6.0) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -8855,7 +8889,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 20.19.39 + '@types/node': 25.6.0 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit: 0.1.2 @@ -9002,7 +9036,7 @@ snapshots: dependencies: '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.19.39 + '@types/node': 25.6.0 '@types/yargs': 16.0.11 chalk: 4.1.2 @@ -10309,7 +10343,7 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 20.19.39 + '@types/node': 25.6.0 '@types/chrome@0.1.40': dependencies: @@ -10318,7 +10352,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 20.19.39 + '@types/node': 25.6.0 '@types/cookiejar@2.1.5': {} @@ -10344,7 +10378,7 @@ snapshots: '@types/express-serve-static-core@4.19.8': dependencies: - '@types/node': 20.19.39 + '@types/node': 25.6.0 '@types/qs': 6.15.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -10416,7 +10450,7 @@ snapshots: '@types/node-fetch@2.6.13': dependencies: - '@types/node': 20.19.39 + '@types/node': 25.6.0 form-data: 4.0.5 '@types/node@18.19.130': @@ -10457,16 +10491,16 @@ snapshots: '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 - '@types/node': 20.19.39 + '@types/node': 25.6.0 '@types/send@1.2.1': dependencies: - '@types/node': 20.19.39 + '@types/node': 25.6.0 '@types/serve-static@1.15.10': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 20.19.39 + '@types/node': 25.6.0 '@types/send': 0.17.6 '@types/stack-utils@2.0.3': {} @@ -10475,7 +10509,7 @@ snapshots: dependencies: '@types/cookiejar': 2.1.5 '@types/methods': 1.1.4 - '@types/node': 20.19.39 + '@types/node': 25.6.0 form-data: 4.0.5 '@types/supertest@6.0.3': @@ -13085,7 +13119,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 20.19.39 + '@types/node': 25.6.0 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -13163,7 +13197,7 @@ snapshots: jest-mock@27.5.1: dependencies: '@jest/types': 27.5.1 - '@types/node': 20.19.39 + '@types/node': 25.6.0 jest-mock@29.7.0: dependencies: @@ -13233,7 +13267,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.39 + '@types/node': 25.6.0 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -13288,7 +13322,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.39 + '@types/node': 25.6.0 chalk: 4.1.2 cjs-module-lexer: 1.4.3 collect-v8-coverage: 1.0.3 @@ -13424,7 +13458,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.39 + '@types/node': 25.6.0 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -15301,6 +15335,26 @@ snapshots: babel-jest: 30.3.0(@babel/core@7.29.0) jest-util: 30.3.0 + ts-jest@29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.39))(typescript@5.9.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.9 + jest: 29.7.0(@types/node@20.19.39) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.4 + type-fest: 4.41.0 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionalDependencies: + '@babel/core': 7.29.0 + '@jest/transform': 30.3.0 + '@jest/types': 30.3.0 + babel-jest: 30.3.0(@babel/core@7.29.0) + jest-util: 30.3.0 + tslib@1.14.1: {} tslib@2.8.1: {} diff --git a/services/ai-agent/README.md b/services/ai-agent/README.md index 90c10438..02652ec2 100644 --- a/services/ai-agent/README.md +++ b/services/ai-agent/README.md @@ -1,21 +1,91 @@ -# AI Agent Service (Planned Scaffold) +# AI Agent Service -This directory is intentionally reserved for AI-assisted financial workflow orchestration. +Minimal MVP service for AI-assisted financial intent drafting on the Ancore platform. -## Why this exists +## Overview -- Keep product direction explicit without claiming full implementation. -- Provide a stable location for upcoming AI workflow integration. -- Separate AI orchestration from wallet core and settlement rails. +The AI agent parses natural-language prompts into **draft** payment or invoice intents. It never executes any financial operation autonomously — all outputs require explicit user confirmation before any on-chain action is taken. -## Planned responsibilities +## Endpoints -- Natural-language to financial action intent parsing -- Safety checks and user confirmation flows -- Draft invoice/payment request generation -- Routing to off-chain analytics/risk systems before settlement +### `GET /health` -## Current status +Returns service liveness status. -- Scaffold only (service is not implemented yet) -- Future work tracked in roadmap and issue backlog +```json +{ "status": "ok", "service": "ai-agent" } +``` + +### `POST /agent/draft-intent` + +Parses a prompt into a draft financial intent. + +**Request body:** + +```json +{ + "prompt": "Send 10 XLM to Alice", + "accountId": "GABC...", + "context": {} +} +``` + +**Response:** + +```json +{ + "status": "draft", + "requiresConfirmation": true, + "summary": "Draft payment intent parsed from: \"Send 10 XLM to Alice\"", + "intent": { + "type": "payment", + "destination": "", + "amount": "0", + "asset": "XLM", + "memo": "Send 10 XLM to Alice" + } +} +``` + +## Security Boundaries + +### What this service does + +- Parses natural-language prompts into structured draft intents +- Returns typed, human-reviewable output for user confirmation +- Enforces the no-autonomous-execution guardrail on every response + +### What this service does NOT do + +- **No on-chain execution** — the service never submits transactions to Stellar +- **No key management** — no private keys are held or accessed +- **No fund movement** — zero financial operations are performed without explicit user confirmation +- **No persistent state** — no user data or financial state is stored + +### Guardrail enforcement + +Every response from `/agent/draft-intent` is validated by `enforceNoAutonomousExecution` before being returned. This function throws if: + +- `status` is anything other than `"draft"` +- `requiresConfirmation` is not `true` + +This is a hard invariant: the agent is a **suggestion engine only**. + +## Limitations + +- The current intent parser is a stub (keyword-based). Replace with a real LLM/NLP integration before production use. +- Parsed `amount` and `destination` fields are placeholders — the real parser must extract these from the prompt. +- No authentication or rate limiting is implemented in this MVP. Add these before exposing the service externally. +- This service is classified as **Medium Risk** per the Ancore security model (`services/**`). + +## Development + +```bash +pnpm install +pnpm test +pnpm build +``` + +## Status + +MVP scaffold — not production-ready. See [issue #420](https://github.com/ancore-org/ancore/issues/420) and the [roadmap](../../README.md#roadmap) for planned work. diff --git a/services/ai-agent/package.json b/services/ai-agent/package.json new file mode 100644 index 00000000..f7c97c69 --- /dev/null +++ b/services/ai-agent/package.json @@ -0,0 +1,47 @@ +{ + "name": "@ancore/ai-agent", + "version": "0.1.0", + "description": "AI agent service for draft financial intent generation", + "main": "./dist/server.js", + "scripts": { + "build": "tsc --project tsconfig.json", + "test": "jest --coverage", + "lint": "eslint src/" + }, + "keywords": [ + "ancore", + "ai-agent", + "stellar" + ], + "license": "Apache-2.0", + "dependencies": { + "express": "^4.18.2", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/jest": "^29.5.0", + "@types/node": "^20.0.0", + "@types/supertest": "^6.0.2", + "jest": "^29.7.0", + "supertest": "^6.3.4", + "ts-jest": "^29.1.0", + "typescript": "^5.3.0" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "testMatch": [ + "**/src/**/*.test.ts", + "**/tests/**/*.test.ts" + ], + "transform": { + "^.+\\.tsx?$": [ + "ts-jest", + { + "tsconfig": "tsconfig.test.json" + } + ] + } + } +} diff --git a/services/ai-agent/src/guardrail.ts b/services/ai-agent/src/guardrail.ts new file mode 100644 index 00000000..06643833 --- /dev/null +++ b/services/ai-agent/src/guardrail.ts @@ -0,0 +1,23 @@ +import type { DraftIntentResponse } from './types'; + +/** + * GUARDRAIL: The AI agent MUST NOT execute any financial operation autonomously. + * + * All outputs are drafts that require explicit user confirmation before any + * on-chain or off-chain action is taken. This function enforces that invariant + * by asserting the response is always in "draft" status with requiresConfirmation=true. + * + * @throws {Error} if the response violates the no-autonomous-execution policy + */ +export function enforceNoAutonomousExecution(response: DraftIntentResponse): void { + if (response.status !== 'draft') { + throw new Error( + `GUARDRAIL VIOLATION: response status must be "draft", got "${response.status}"` + ); + } + if (response.requiresConfirmation !== true) { + throw new Error( + 'GUARDRAIL VIOLATION: requiresConfirmation must be true — the agent never executes autonomously' + ); + } +} diff --git a/services/ai-agent/src/server.test.ts b/services/ai-agent/src/server.test.ts new file mode 100644 index 00000000..978c5044 --- /dev/null +++ b/services/ai-agent/src/server.test.ts @@ -0,0 +1,83 @@ +import request from 'supertest'; +import { createApp } from './server'; +import { enforceNoAutonomousExecution } from './guardrail'; +import type { DraftIntentResponse } from './types'; + +const app = createApp(); + +// ── /health ─────────────────────────────────────────────────────────────────── + +describe('GET /health', () => { + it('returns 200 with status ok', async () => { + const res = await request(app).get('/health'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: 'ok', service: 'ai-agent' }); + }); +}); + +// ── /agent/draft-intent ─────────────────────────────────────────────────────── + +describe('POST /agent/draft-intent', () => { + const validBody = { prompt: 'Send 10 XLM to Alice', accountId: 'GABC123' }; + + it('returns 200 with a draft payment intent', async () => { + const res = await request(app).post('/agent/draft-intent').send(validBody); + expect(res.status).toBe(200); + expect(res.body.status).toBe('draft'); + expect(res.body.requiresConfirmation).toBe(true); + expect(res.body.intent.type).toBe('payment'); + expect(res.body.summary).toBeDefined(); + }); + + it('returns 200 with a draft invoice intent when prompt contains "invoice"', async () => { + const res = await request(app) + .post('/agent/draft-intent') + .send({ prompt: 'Create an invoice for 50 XLM', accountId: 'GABC123' }); + expect(res.status).toBe(200); + expect(res.body.status).toBe('draft'); + expect(res.body.requiresConfirmation).toBe(true); + expect(res.body.intent.type).toBe('invoice'); + }); + + it('returns 400 when prompt is missing', async () => { + const res = await request(app).post('/agent/draft-intent').send({ accountId: 'GABC123' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Invalid request'); + }); + + it('returns 400 when accountId is missing', async () => { + const res = await request(app).post('/agent/draft-intent').send({ prompt: 'Send 10 XLM' }); + expect(res.status).toBe(400); + expect(res.body.error).toBe('Invalid request'); + }); + + it('returns 400 when body is empty', async () => { + const res = await request(app).post('/agent/draft-intent').send({}); + expect(res.status).toBe(400); + }); +}); + +// ── guardrail ───────────────────────────────────────────────────────────────── + +describe('enforceNoAutonomousExecution', () => { + const validDraft: DraftIntentResponse = { + status: 'draft', + requiresConfirmation: true, + summary: 'test', + intent: { type: 'payment', destination: 'G123', amount: '10', asset: 'XLM' }, + }; + + it('does not throw for a valid draft response', () => { + expect(() => enforceNoAutonomousExecution(validDraft)).not.toThrow(); + }); + + it('throws when status is not "draft"', () => { + const bad = { ...validDraft, status: 'executed' } as unknown as DraftIntentResponse; + expect(() => enforceNoAutonomousExecution(bad)).toThrow('GUARDRAIL VIOLATION'); + }); + + it('throws when requiresConfirmation is false', () => { + const bad = { ...validDraft, requiresConfirmation: false } as unknown as DraftIntentResponse; + expect(() => enforceNoAutonomousExecution(bad)).toThrow('GUARDRAIL VIOLATION'); + }); +}); diff --git a/services/ai-agent/src/server.ts b/services/ai-agent/src/server.ts new file mode 100644 index 00000000..c15a0c32 --- /dev/null +++ b/services/ai-agent/src/server.ts @@ -0,0 +1,94 @@ +import express, { Express, Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { DraftIntentRequestSchema } from './types'; +import type { DraftIntentResponse } from './types'; +import { enforceNoAutonomousExecution } from './guardrail'; + +// ── Draft intent logic ──────────────────────────────────────────────────────── + +/** + * Parses a natural-language prompt into a draft intent. + * This is a stub — replace with real LLM/NLP integration. + * The output is always a draft; no execution occurs here. + */ +function parseDraftIntent(prompt: string, accountId: string): DraftIntentResponse { + const lower = prompt.toLowerCase(); + const isInvoice = lower.includes('invoice') || lower.includes('request'); + + const response: DraftIntentResponse = isInvoice + ? { + status: 'draft', + requiresConfirmation: true, + summary: `Draft invoice request parsed from: "${prompt}"`, + intent: { + type: 'invoice', + requestedBy: accountId, + amount: '0', + asset: 'XLM', + description: prompt, + }, + } + : { + status: 'draft', + requiresConfirmation: true, + summary: `Draft payment intent parsed from: "${prompt}"`, + intent: { + type: 'payment', + destination: '', + amount: '0', + asset: 'XLM', + memo: prompt, + }, + }; + + // Enforce guardrail before returning + enforceNoAutonomousExecution(response); + return response; +} + +// ── Validation middleware ───────────────────────────────────────────────────── + +function validateBody(schema: z.ZodTypeAny) { + return (req: Request, res: Response, next: NextFunction): void => { + const result = schema.safeParse(req.body); + if (!result.success) { + res.status(400).json({ error: 'Invalid request', details: result.error.flatten() }); + return; + } + req.body = result.data; + next(); + }; +} + +// ── App factory ─────────────────────────────────────────────────────────────── + +export function createApp(): Express { + const app = express(); + app.use(express.json()); + + app.get('/health', (_req, res) => { + res.json({ status: 'ok', service: 'ai-agent' }); + }); + + app.post( + '/agent/draft-intent', + validateBody(DraftIntentRequestSchema), + (req: Request, res: Response) => { + const { prompt, accountId } = req.body; + const draft = parseDraftIntent(prompt, accountId); + res.status(200).json(draft); + } + ); + + return app; +} + +// ── Entrypoint ──────────────────────────────────────────────────────────────── + +if (require.main === module) { + const PORT = process.env['PORT'] ?? 3001; + const app = createApp(); + app.listen(PORT, () => { + console.log(`AI agent service listening on port ${PORT}`); + }); +} diff --git a/services/ai-agent/src/types.ts b/services/ai-agent/src/types.ts new file mode 100644 index 00000000..bb2898cf --- /dev/null +++ b/services/ai-agent/src/types.ts @@ -0,0 +1,46 @@ +import { z } from 'zod'; + +// ── Request ─────────────────────────────────────────────────────────────────── + +export const DraftIntentRequestSchema = z.object({ + /** Natural-language description of the intended operation */ + prompt: z.string().min(1).max(1000), + /** Stellar account address of the initiating user */ + accountId: z.string().min(1), + /** Optional context for the intent (e.g. invoice ID, session key) */ + context: z.record(z.unknown()).optional(), +}); + +export type DraftIntentRequest = z.infer; + +// ── Response ────────────────────────────────────────────────────────────────── + +export type IntentType = 'payment' | 'invoice'; + +export interface DraftPaymentIntent { + type: 'payment'; + destination: string; + amount: string; + asset: string; + memo?: string; +} + +export interface DraftInvoiceIntent { + type: 'invoice'; + requestedBy: string; + amount: string; + asset: string; + description?: string; +} + +export type DraftIntent = DraftPaymentIntent | DraftInvoiceIntent; + +export interface DraftIntentResponse { + /** Always "draft" — this intent requires explicit user confirmation before execution */ + status: 'draft'; + intent: DraftIntent; + /** Human-readable summary for display */ + summary: string; + /** Guardrail confirmation: service never executes autonomously */ + requiresConfirmation: true; +} diff --git a/services/ai-agent/tsconfig.json b/services/ai-agent/tsconfig.json new file mode 100644 index 00000000..6271b85e --- /dev/null +++ b/services/ai-agent/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/__tests__/**", "**/*.test.ts"] +} diff --git a/services/ai-agent/tsconfig.test.json b/services/ai-agent/tsconfig.test.json new file mode 100644 index 00000000..da5a2481 --- /dev/null +++ b/services/ai-agent/tsconfig.test.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "types": ["node", "jest"] + }, + "include": ["src/**/*", "tests/**/*"] +}