From 6053d1f36e572328e9c9d61fa9dc21206ed21583 Mon Sep 17 00:00:00 2001 From: Olatope Olaleye Olajide Date: Tue, 26 May 2026 15:02:59 +0000 Subject: [PATCH] feat(ai-agent): bootstrap MVP service from scaffold (#420) - Add package.json, tsconfig.json, tsconfig.test.json - Add src/types.ts: DraftIntentRequest/Response schemas (zod) - Add src/guardrail.ts: enforceNoAutonomousExecution guardrail - Add src/server.ts: Express app with /health and /agent/draft-intent - Add src/server.test.ts: 9 tests covering all endpoints and guardrail - Update README.md with security boundaries and limitations The agent is a draft-only suggestion engine. It never executes financial operations autonomously. All responses carry status='draft' and requiresConfirmation=true, enforced by the guardrail function. Closes #420 --- pnpm-lock.yaml | 192 ++++++++++++++++++++++----- services/ai-agent/README.md | 98 ++++++++++++-- services/ai-agent/package.json | 47 +++++++ services/ai-agent/src/guardrail.ts | 23 ++++ services/ai-agent/src/server.test.ts | 83 ++++++++++++ services/ai-agent/src/server.ts | 94 +++++++++++++ services/ai-agent/src/types.ts | 46 +++++++ services/ai-agent/tsconfig.json | 20 +++ services/ai-agent/tsconfig.test.json | 8 ++ 9 files changed, 566 insertions(+), 45 deletions(-) create mode 100644 services/ai-agent/package.json create mode 100644 services/ai-agent/src/guardrail.ts create mode 100644 services/ai-agent/src/server.test.ts create mode 100644 services/ai-agent/src/server.ts create mode 100644 services/ai-agent/src/types.ts create mode 100644 services/ai-agent/tsconfig.json create mode 100644 services/ai-agent/tsconfig.test.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 102b0cd4..ec619f41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -500,10 +500,10 @@ importers: version: 9.39.4(jiti@1.21.7) jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.39) + version: 29.7.0(@types/node@25.6.0) ts-jest: specifier: ^29.2.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))(esbuild@0.21.5)(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.39))(typescript@5.9.3) + 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))(esbuild@0.21.5)(jest-util@30.3.0)(jest@29.7.0(@types/node@25.6.0))(typescript@5.9.3) tsup: specifier: ^8.0.0 version: 8.5.1(jiti@1.21.7)(postcss@8.5.12)(typescript@5.9.3)(yaml@2.8.3) @@ -684,6 +684,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: @@ -716,7 +750,7 @@ importers: 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))(esbuild@0.21.5)(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.39))(typescript@5.9.3) + 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 @@ -8508,7 +8542,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 @@ -8530,14 +8564,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 @@ -8599,7 +8633,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.39 + '@types/node': 25.6.0 jest-mock: 29.7.0 '@jest/environment@30.3.0': @@ -8635,7 +8669,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.19.39 + '@types/node': 25.6.0 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -8682,7 +8716,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 @@ -8829,7 +8863,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 @@ -10136,7 +10170,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: @@ -10145,13 +10179,13 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 20.19.39 + '@types/node': 25.6.0 '@types/cookiejar@2.1.5': {} '@types/cross-spawn@6.0.6': dependencies: - '@types/node': 20.19.39 + '@types/node': 25.6.0 '@types/detect-port@1.3.5': {} @@ -10171,7 +10205,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 @@ -10198,7 +10232,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 20.19.39 + '@types/node': 25.6.0 '@types/har-format@1.2.16': {} @@ -10237,7 +10271,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': @@ -10278,16 +10312,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': {} @@ -10296,7 +10330,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': @@ -11240,6 +11274,21 @@ snapshots: - supports-color - ts-node + create-jest@29.7.0(@types/node@25.6.0): + dependencies: + '@jest/types': 29.6.3 + chalk: 4.1.2 + exit: 0.1.2 + graceful-fs: 4.2.11 + jest-config: 29.7.0(@types/node@25.6.0) + jest-util: 29.7.0 + prompts: 2.4.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -12566,7 +12615,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.39 + '@types/node': 25.6.0 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.2 @@ -12631,6 +12680,25 @@ snapshots: - supports-color - ts-node + jest-cli@29.7.0(@types/node@25.6.0): + dependencies: + '@jest/core': 29.7.0 + '@jest/test-result': 29.7.0 + '@jest/types': 29.6.3 + chalk: 4.1.2 + create-jest: 29.7.0(@types/node@25.6.0) + exit: 0.1.2 + import-local: 3.2.0 + jest-config: 29.7.0(@types/node@25.6.0) + jest-util: 29.7.0 + jest-validate: 29.7.0 + yargs: 17.7.2 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest-cli@30.3.0(@types/node@20.19.39)(esbuild-register@3.6.0(esbuild@0.21.5)): dependencies: '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.21.5)) @@ -12699,6 +12767,36 @@ snapshots: - babel-plugin-macros - supports-color + jest-config@29.7.0(@types/node@25.6.0): + dependencies: + '@babel/core': 7.29.0 + '@jest/test-sequencer': 29.7.0 + '@jest/types': 29.6.3 + babel-jest: 29.7.0(@babel/core@7.29.0) + chalk: 4.1.2 + ci-info: 3.9.0 + deepmerge: 4.3.1 + glob: 7.2.3 + graceful-fs: 4.2.11 + jest-circus: 29.7.0 + jest-environment-node: 29.7.0 + jest-get-type: 29.6.3 + jest-regex-util: 29.6.3 + jest-resolve: 29.7.0 + jest-runner: 29.7.0 + jest-util: 29.7.0 + jest-validate: 29.7.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 29.7.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 25.6.0 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-config@30.3.0(@types/node@20.19.39)(esbuild-register@3.6.0(esbuild@0.21.5)): dependencies: '@babel/core': 7.29.0 @@ -12806,7 +12904,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.39 + '@types/node': 25.6.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -12826,7 +12924,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 @@ -12904,12 +13002,12 @@ 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: '@jest/types': 29.6.3 - '@types/node': 20.19.39 + '@types/node': 25.6.0 jest-util: 29.7.0 jest-mock@30.3.0: @@ -12974,7 +13072,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 @@ -13029,7 +13127,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 @@ -13165,7 +13263,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 @@ -13185,7 +13283,7 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 20.19.39 + '@types/node': 25.6.0 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -13210,6 +13308,18 @@ snapshots: - supports-color - ts-node + jest@29.7.0(@types/node@25.6.0): + dependencies: + '@jest/core': 29.7.0 + '@jest/types': 29.6.3 + import-local: 3.2.0 + jest-cli: 29.7.0(@types/node@25.6.0) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - supports-color + - ts-node + jest@30.3.0(@types/node@20.19.39)(esbuild-register@3.6.0(esbuild@0.21.5)): dependencies: '@jest/core': 30.3.0(esbuild-register@3.6.0(esbuild@0.21.5)) @@ -14909,12 +15019,12 @@ snapshots: ts-interface-checker@0.1.13: {} - 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))(esbuild@0.21.5)(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.39))(typescript@5.9.3): + 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))(esbuild@0.21.5)(jest-util@30.3.0)(jest@29.7.0(@types/node@25.6.0))(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) + jest: 29.7.0(@types/node@25.6.0) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -14972,6 +15082,26 @@ snapshots: esbuild: 0.21.5 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/**/*"] +}