diff --git a/README.md b/README.md index 0692c47..49959cb 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,11 @@ async function main() { main(); ``` +## Examples + +- `examples/simple-agent` is a standalone example package containing the literal Python `simple_agent.py` port +- `examples/mastra-weather-agent` is a standalone Mastra example package with its own `package.json` + ## LangChain Tool Call Capture If your LangChain agent uses tools, the SDK can automatically annotate those diff --git a/examples/mastra-weather-agent/README.md b/examples/mastra-weather-agent/README.md new file mode 100644 index 0000000..2c4f27f --- /dev/null +++ b/examples/mastra-weather-agent/README.md @@ -0,0 +1,34 @@ +# Weather Agent Example + +Mastra-based LLP weather agent example. + +This example: + +- uses `@mastra/core` for the agent runtime +- uses `llpsdk/mastra` to annotate tool calls back to LLP +- answers weather questions for a fixed set of cities +- declines unsupported cities and non-weather questions + +## Setup + +```bash +cd /Users/gagansingh/code/llpsdk/llp-javascript/examples/mastra-weather-agent +npm install +``` + +## Environment + +Create a `.env` file in this directory: + +```bash +LLP_URL=wss://llphq.com/agent/websocket +LLP_API_KEY=your-api-key +MODEL_NAME=ollama/llama3.1 +AGENT_NAME=weather-agent-mastra +``` + +## Run + +```bash +npm start +``` diff --git a/examples/mastra-weather-agent/package.json b/examples/mastra-weather-agent/package.json new file mode 100644 index 0000000..d9ee4cc --- /dev/null +++ b/examples/mastra-weather-agent/package.json @@ -0,0 +1,20 @@ +{ + "name": "mastra-weather-agent", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "tsx weather-agent.ts" + }, + "dependencies": { + "@mastra/core": "^1.15.0", + "dotenv": "^17.2.3", + "llpsdk": "file:../..", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "tsx": "^4.19.0", + "typescript": "^5.6.0" + } +} diff --git a/examples/mastra-weather-agent/tsconfig.json b/examples/mastra-weather-agent/tsconfig.json new file mode 100644 index 0000000..f73119b --- /dev/null +++ b/examples/mastra-weather-agent/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["weather-agent.ts"] +} diff --git a/examples/mastra-weather-agent/weather-agent.ts b/examples/mastra-weather-agent/weather-agent.ts new file mode 100644 index 0000000..7a70576 --- /dev/null +++ b/examples/mastra-weather-agent/weather-agent.ts @@ -0,0 +1,175 @@ +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { Agent } from '@mastra/core/agent'; +import { RequestContext } from '@mastra/core/request-context'; +import { createTool } from '@mastra/core/tools'; +import { config } from 'dotenv'; +import { type Annotater, LLPClient, type TextMessage } from 'llpsdk'; +import { type LLPMastraContext, wrapWithLLPAnnotation } from 'llpsdk/mastra'; +import * as z from 'zod'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +config({ path: join(__dirname, '.env') }); + +const SYSTEM_PROMPT = ` +You are a helpful meteorologist that gives succinct responses regarding the weather for various American cities. + +Important rules: +1. Use the get_weather tool for every weather question about a supported city. +2. Supported cities are New York, Los Angeles, Chicago, Miami, Seattle, Denver, San Francisco, Austin, Boston, and London. +3. If the city is unsupported, decline and mention the supported cities. +4. If the question is not about weather, decline. +5. Keep summaries concise and practical. +`; + +const supportedCities = [ + 'New York', + 'Los Angeles', + 'Chicago', + 'Miami', + 'Seattle', + 'Denver', + 'San Francisco', + 'Austin', + 'Boston', + 'London', +] as const; + +const weatherData: Record< + (typeof supportedCities)[number], + { conditions: string; temperature_f: number; advisory: string } +> = { + 'New York': { + conditions: 'Partly cloudy with a steady northwest breeze', + temperature_f: 45, + advisory: 'A light jacket is a good idea.', + }, + 'Los Angeles': { + conditions: 'Sunny and mild', + temperature_f: 72, + advisory: 'Comfortable weather for being outside.', + }, + Chicago: { + conditions: 'Overcast, windy, with a chance of snow', + temperature_f: 30, + advisory: 'Dress warmly and plan for wind.', + }, + Miami: { + conditions: 'Humid and warm', + temperature_f: 82, + advisory: 'Stay hydrated if you are outdoors.', + }, + Seattle: { + conditions: 'Rainy with a cool west wind', + temperature_f: 48, + advisory: 'Bring a rain jacket or umbrella.', + }, + Denver: { + conditions: 'Clear skies with a crisp breeze', + temperature_f: 38, + advisory: 'Layers will help with the cooler air.', + }, + 'San Francisco': { + conditions: 'Foggy and breezy', + temperature_f: 58, + advisory: 'A light outer layer will be useful.', + }, + Austin: { + conditions: 'Sunny and pleasant', + temperature_f: 68, + advisory: 'Good conditions for outdoor plans.', + }, + Boston: { + conditions: 'Cold and windy', + temperature_f: 35, + advisory: 'Bundle up before heading out.', + }, + London: { + conditions: 'Light drizzle with cool air', + temperature_f: 50, + advisory: 'Carry an umbrella.', + }, +}; + +type WeatherToolResult = { + city: string; + conditions: string; + temperature_f: number; + advisory: string; +}; +type WeatherAgent = ReturnType; + +const getWeatherTool = createTool({ + id: 'get_weather', + description: 'Get the current weather for a supported city.', + inputSchema: z.object({ + city: z.enum(supportedCities), + }), + execute: wrapWithLLPAnnotation<{ city: (typeof supportedCities)[number] }, WeatherToolResult>( + 'get_weather', + async (inputData) => { + return { city: inputData.city, ...weatherData[inputData.city] }; + }, + ), +}); + +function createWeatherAgent(model: string) { + return new Agent({ + id: 'mastra-weather-agent', + name: 'mastra-weather-agent', + instructions: SYSTEM_PROMPT, + model, + tools: { get_weather: getWeatherTool }, + }); +} + +async function handleMessage( + agent: WeatherAgent, + message: TextMessage, + annotater: Annotater, +): Promise { + const requestContext = new RequestContext(); + requestContext.set('llpMessage', message); + requestContext.set('llpAnnotater', annotater); + + try { + const result = await agent.generate(message.prompt, { requestContext }); + return result.text; + } catch (error) { + console.error('Agent execution failed:', error); + return "I'm sorry, I hit an error while handling that weather request."; + } +} + +async function main(): Promise { + const agentName = process.env.AGENT_NAME ?? 'mastra-weather-agent'; + const apiKey = process.env.LLP_API_KEY ?? ''; + const model = process.env.MODEL_NAME ?? 'ollama-cloud/gpt-oss:120b'; + + if (!apiKey) { + throw new Error('LLP_API_KEY env var is not defined'); + } + + const client = new LLPClient(agentName, apiKey) + .onStart(() => createWeatherAgent(model)) + .onMessage(async (agent, msg, annotater) => { + const response = await handleMessage(agent, msg, annotater); + return msg.reply(response); + }) + .onStop(() => { + console.log('session ended'); + }); + + try { + console.log(`Mastra weather agent initialized model=${model}`); + await client.connect(); + console.log('Connected to platform'); + await new Promise(() => {}); + } catch (error) { + console.error('Fatal error:', error); + process.exit(1); + } +} + +void main(); diff --git a/examples/simple-agent.ts b/examples/simple-agent.ts deleted file mode 100644 index d9830a4..0000000 --- a/examples/simple-agent.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { config } from 'dotenv'; -import { LLPClient } from '../src/index.js'; - -async function main() { - config(); - const client = new LLPClient('simple-agent', process.env.LLP_API_KEY || ''); - - // Register handlers - client.onMessage(async (_session, msg) => { - // process msg.prompt with your agent - return msg.reply('hello from TypeScript!'); - }); - - try { - await client.connect(); - console.log(`Connected! Session: ${client.getSessionId()}`); - - // Keep running - await new Promise(() => {}); // Wait forever - } catch (err) { - console.error('Error:', err); - } finally { - await client.close(); - } -} - -main(); diff --git a/examples/simple-agent/README.md b/examples/simple-agent/README.md new file mode 100644 index 0000000..8145254 --- /dev/null +++ b/examples/simple-agent/README.md @@ -0,0 +1,17 @@ +# Simple Agent Example + +This is a literal TypeScript port of the Python `simple_agent.py` example. + +It: + +- connects to LLP +- annotates a hardcoded `get_weather` tool call for Seattle +- replies with the fixed string `this is my response` + +## Run + +```bash +LLP_URL=wss://llphq.com/agent/websocket \ +LLP_API_KEY=your-api-key \ +npm run example +``` diff --git a/examples/simple-agent/simple-agent.ts b/examples/simple-agent/simple-agent.ts new file mode 100644 index 0000000..fda55ec --- /dev/null +++ b/examples/simple-agent/simple-agent.ts @@ -0,0 +1,40 @@ +import { config } from 'dotenv'; +import { LLPClient, type LLPSession, type TextMessage } from '../../src/index.js'; + +async function main() { + config(); + + const platformUrl = process.env.LLP_URL; + const apiKey = process.env.LLP_API_KEY; + + if (!platformUrl) { + throw new Error('LLP_URL env var is not defined'); + } + + if (!apiKey) { + throw new Error('LLP_API_KEY env var is not defined'); + } + + const client = new LLPClient('simple-agent', apiKey, { url: platformUrl }); + + client.onMessage(async (session: LLPSession, msg: TextMessage) => { + const toolCall = msg.toolCall('get_weather', '{"city":"Seattle"}', 'rainy', 1_000); + await session.annotateToolCall(toolCall); + return msg.reply('this is my response'); + }); + + try { + console.log('Connecting to server...'); + await client.connect(); + console.log(`Connected! Session: ${client.getSessionId()}`); + console.log('Agent running. Press Ctrl+C to exit...'); + await new Promise(() => {}); + } catch (error) { + console.error('Error:', error); + } finally { + await client.close(); + console.log('Disconnected.'); + } +} + +main(); diff --git a/package.json b/package.json index e5c0ef3..6311dee 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,10 @@ "./langchain": { "import": "./dist/langchain.js", "types": "./dist/langchain.d.ts" + }, + "./mastra": { + "import": "./dist/mastra.js", + "types": "./dist/mastra.d.ts" } }, "peerDependencies": { @@ -56,8 +60,7 @@ "lint": "biome check .", "format": "biome format --write .", "formatcheck": "biome format .", - "typecheck": "tsc --noEmit", - "example": "tsx examples/simple-agent.ts" + "typecheck": "tsc --noEmit" }, "dependencies": { "ws": "^8.18.0", diff --git a/src/mastra.ts b/src/mastra.ts new file mode 100644 index 0000000..4ef456e --- /dev/null +++ b/src/mastra.ts @@ -0,0 +1,128 @@ +/** + * Mastra integration for the LLP SDK. + * + * Import via the sub-path export: + * import { wrapWithLLPAnnotation, type LLPMastraContext } from 'llpsdk/mastra'; + * + * Does not require @mastra/core as a dependency — uses duck typing for RequestContext. + * + * Usage (Mastra v1.x): + * + * ```ts + * import { wrapWithLLPAnnotation, type LLPMastraContext } from 'llpsdk/mastra'; + * import { RequestContext } from '@mastra/core/request-context'; + * import { createTool } from '@mastra/core/tools'; + * + * const myTool = createTool({ + * id: 'my_tool', + * inputSchema: z.object({ query: z.string() }), + * execute: wrapWithLLPAnnotation('my_tool', async (inputData) => { + * return await doSomething(inputData.query); + * }), + * }); + * + * // In handleMessage: + * const requestContext = new RequestContext(); + * requestContext.set('llpMessage', message); + * requestContext.set('llpAnnotater', annotater); + * const result = await agent.generate(prompt, { requestContext }); + * ``` + */ + +import type { Annotater } from './annotate.js'; +import type { TextMessage } from './message.js'; + +/** + * The runtime context type for LLP-connected Mastra agents. + * + * Use as the type parameter for RequestContext: + * new RequestContext() + */ +export type LLPMastraContext = { + llpMessage: TextMessage; + llpAnnotater: Annotater; +}; + +/** + * Minimal interface that RequestContext satisfies. + * Used internally so this module does not depend on @mastra/core. + */ +interface LLPMastraRequestContextLike { + get(key: 'llpMessage'): TextMessage; + get(key: 'llpAnnotater'): Annotater; +} + +/** + * Minimal duck-type for Mastra v1.x ToolExecutionContext. + * Only requires the requestContext field we actually use. + */ +interface MastraToolExecutionContextLike { + requestContext?: LLPMastraRequestContextLike; +} + +export interface LLPAnnotationOptions { + /** Override how tool input is serialized for the annotation. Default: JSON.stringify. */ + serializeInput?: (value: unknown) => string; + /** Override how tool output is serialized for the annotation. Default: JSON.stringify. */ + serializeOutput?: (value: unknown) => string; + /** Called when annotation fails. Default: console.warn. Never throws. */ + onAnnotationError?: (error: unknown) => void; +} + +function serialize(value: unknown): string { + try { + return typeof value === 'string' ? value : JSON.stringify(value); + } catch { + return String(value); + } +} + +/** + * Wraps a Mastra tool's execute function with LLP tool-call annotation. + * + * Handles timing, success annotation, exception annotation, and annotation + * error swallowing — the Mastra equivalent of createLLPToolMiddleware() for + * LangChain. + * + * Compatible with Mastra v1.x createTool execute signature: (inputData, context) => Promise + * The tool's requestContext must be a RequestContext. + */ +export function wrapWithLLPAnnotation( + toolId: string, + execute: (inputData: TInput) => Promise, + options: LLPAnnotationOptions = {}, +): (inputData: TInput, context: MastraToolExecutionContextLike) => Promise { + const serializeInput = options.serializeInput ?? serialize; + const serializeOutput = options.serializeOutput ?? serialize; + const onAnnotationError = + options.onAnnotationError ?? + ((error: unknown) => console.warn('[LLP] Failed to annotate tool call:', error)); + + return async (inputData, context) => { + const startMs = Date.now(); + const requestContext = context.requestContext; + const llpMessage = requestContext?.get('llpMessage'); + const llpAnnotater = requestContext?.get('llpAnnotater'); + const params = serializeInput(inputData); + + try { + const result = await execute(inputData); + if (llpMessage && llpAnnotater) { + llpAnnotater + .annotateToolCall( + llpMessage.toolCall(toolId, params, serializeOutput(result), Date.now() - startMs), + ) + .catch(onAnnotationError); + } + return result; + } catch (err) { + if (llpMessage && llpAnnotater) { + const e = err instanceof Error ? err : new Error(String(err)); + llpAnnotater + .annotateToolCall(llpMessage.toolCallException(toolId, params, e, Date.now() - startMs)) + .catch(onAnnotationError); + } + throw err; + } + }; +}