diff --git a/README.md b/README.md index 6311a93..4b60a24 100644 --- a/README.md +++ b/README.md @@ -652,6 +652,355 @@ const response = await toolpack.chat('How do I configure authentication?'); See the [Knowledge package README](./packages/toolpack-knowledge/README.md) for full documentation. +## AI Agents (@toolpack-sdk/agents) + +Build production-ready AI agents with channels, workflows, and event-driven architecture using the companion `@toolpack-sdk/agents` package: + +```bash +npm install @toolpack-sdk/agents +``` + +### What are Agents? + +Agents are autonomous AI systems that: +- **Listen** for events from channels (Slack, webhooks, schedules, etc.) +- **Process** messages using the Toolpack SDK +- **Execute** tasks with full tool access +- **Respond** back through the same or different channels +- **Remember** conversations using knowledge bases + +### Quick Start + +```typescript +import { Toolpack } from 'toolpack-sdk'; +import { BaseAgent, AgentRegistry, SlackChannel } from '@toolpack-sdk/agents'; + +// 1. Create a custom agent +class SupportAgent extends BaseAgent { + name = 'support-agent'; + description = 'Customer support agent that answers questions'; + mode = 'chat'; + + async invokeAgent(input) { + const result = await this.run(input.message); + await this.sendTo('slack-support', result.output); + return result; + } +} + +// 2. Set up channels +const slackChannel = new SlackChannel({ + name: 'slack-support', + token: process.env.SLACK_BOT_TOKEN, + signingSecret: process.env.SLACK_SIGNING_SECRET, +}); + +// 3. Register agent and channels +const registry = new AgentRegistry([ + { agent: SupportAgent, channels: [slackChannel] }, +]); + +// 4. Initialize Toolpack with agents +const sdk = await Toolpack.init({ + provider: 'openai', + tools: true, + agents: registry, +}); + +// Agents now listen and respond automatically! +``` + +### Built-in Agents + +The package includes 4 production-ready agents you can use directly or extend: + +#### ResearchAgent +```typescript +import { ResearchAgent } from '@toolpack-sdk/agents'; + +const agent = new ResearchAgent(sdk); +const result = await agent.invokeAgent({ + message: 'Summarize recent developments in edge AI', +}); +``` +- **Mode:** `agent` +- **Tools:** web.search, web.fetch, web.scrape +- **Use Cases:** Market research, competitive analysis, trend monitoring + +#### CodingAgent +```typescript +import { CodingAgent } from '@toolpack-sdk/agents'; + +const agent = new CodingAgent(sdk); +const result = await agent.invokeAgent({ + message: 'Refactor the auth module to use the new SDK pattern', +}); +``` +- **Mode:** `coding` +- **Tools:** fs.*, coding.*, git.*, exec.* +- **Use Cases:** Code generation, refactoring, debugging, test writing + +#### DataAgent +```typescript +import { DataAgent } from '@toolpack-sdk/agents'; + +const agent = new DataAgent(sdk); +const result = await agent.invokeAgent({ + message: 'Generate a weekly summary of signups by region', +}); +``` +- **Mode:** `agent` +- **Tools:** db.*, fs.*, http.* +- **Use Cases:** Database queries, reporting, data analysis, CSV generation + +#### BrowserAgent +```typescript +import { BrowserAgent } from '@toolpack-sdk/agents'; + +const agent = new BrowserAgent(sdk); +const result = await agent.invokeAgent({ + message: 'Extract all product prices from acme.com/products', +}); +``` +- **Mode:** `chat` +- **Tools:** web.fetch, web.screenshot, web.extract_links +- **Use Cases:** Web scraping, form filling, content extraction + +### Channels + +Channels connect agents to the outside world. The package includes 7 built-in channels: + +#### SlackChannel (Two-way) +```typescript +import { SlackChannel } from '@toolpack-sdk/agents'; + +const slack = new SlackChannel({ + name: 'slack-support', + token: process.env.SLACK_BOT_TOKEN, + signingSecret: process.env.SLACK_SIGNING_SECRET, +}); +``` +- ✅ Receives messages from Slack +- ✅ Replies in threads +- ✅ Supports `ask()` for human input + +#### TelegramChannel (Two-way) +```typescript +import { TelegramChannel } from '@toolpack-sdk/agents'; + +const telegram = new TelegramChannel({ + name: 'telegram-bot', + token: process.env.TELEGRAM_BOT_TOKEN, +}); +``` +- ✅ Receives messages from Telegram +- ✅ Replies to users +- ✅ Supports `ask()` for human input + +#### WebhookChannel (Two-way) +```typescript +import { WebhookChannel } from '@toolpack-sdk/agents'; + +const webhook = new WebhookChannel({ + name: 'github-webhook', + path: '/webhook/github', + port: 3000, + secret: process.env.WEBHOOK_SECRET, +}); +``` +- ✅ Receives HTTP POST webhooks +- ✅ Signature verification +- ✅ Supports `ask()` for human input + +#### ScheduledChannel (Trigger-only) +```typescript +import { ScheduledChannel } from '@toolpack-sdk/agents'; + +const scheduler = new ScheduledChannel({ + name: 'daily-report', + cron: '0 9 * * 1-5', // 9am weekdays + notify: 'webhook:https://hooks.example.com/daily-report', + message: 'Generate the daily sales report', +}); +// For Slack delivery, attach a named SlackChannel to the same agent and +// call `this.sendTo('', output)` from within `run()`. +``` +- ⏰ Triggers agents on cron schedules +- ✅ Full cron expression support (ranges, steps, lists, combinations) +- ❌ No `ask()` support (no human recipient) + +#### DiscordChannel (Two-way) +```typescript +import { DiscordChannel } from '@toolpack-sdk/agents'; + +const discord = new DiscordChannel({ + name: 'discord-bot', + token: process.env.DISCORD_BOT_TOKEN, + guildId: 'your-guild-id', + channelId: 'your-channel-id', +}); +``` +- ✅ Receives messages from Discord +- ✅ Replies in threads +- ✅ Supports `ask()` for human input + +#### EmailChannel (Outbound-only) +```typescript +import { EmailChannel } from '@toolpack-sdk/agents'; + +const email = new EmailChannel({ + name: 'email-alerts', + from: 'bot@acme.com', + to: 'team@acme.com', + smtp: { + host: 'smtp.gmail.com', + port: 587, + auth: { user: 'bot@acme.com', pass: process.env.SMTP_PASSWORD }, + }, +}); +``` +- 📧 Sends emails via SMTP +- ❌ No `ask()` support (outbound-only) + +#### SMSChannel (Configurable) +```typescript +import { SMSChannel } from '@toolpack-sdk/agents'; + +// Two-way with webhook +const sms = new SMSChannel({ + name: 'sms-alerts', + accountSid: process.env.TWILIO_ACCOUNT_SID, + authToken: process.env.TWILIO_AUTH_TOKEN, + from: '+1234567890', + webhookPath: '/sms/webhook', // Enables two-way + port: 3000, +}); + +// Outbound-only +const smsOutbound = new SMSChannel({ + name: 'sms-notifications', + accountSid: process.env.TWILIO_ACCOUNT_SID, + authToken: process.env.TWILIO_AUTH_TOKEN, + from: '+1234567890', + to: '+0987654321', // Fixed recipient +}); +``` +- 📱 Twilio SMS integration +- ✅ Two-way when `webhookPath` is set +- ❌ Outbound-only without webhook + +### Agent Lifecycle & Events + +Agents emit events at each stage of execution: + +```typescript +const agent = new MyAgent(sdk); + +agent.on('agent:start', (input) => { + console.log('Agent started:', input.message); +}); + +agent.on('agent:complete', (result) => { + console.log('Agent completed:', result.output); +}); + +agent.on('agent:error', (error) => { + console.error('Agent error:', error); +}); +``` + +### Knowledge Integration + +Agents can use knowledge bases for conversation memory and RAG: + +```typescript +import { Knowledge, MemoryProvider, OllamaEmbedder } from '@toolpack-sdk/knowledge'; +import { BaseAgent } from '@toolpack-sdk/agents'; + +class SmartAgent extends BaseAgent { + name = 'smart-agent'; + description = 'Agent with memory'; + mode = 'chat'; + + constructor(toolpack) { + super(toolpack); + // Set up knowledge base + this.knowledge = await Knowledge.create({ + provider: new MemoryProvider(), + embedder: new OllamaEmbedder({ model: 'nomic-embed-text' }), + }); + } + + async invokeAgent(input) { + // Conversation history is automatically loaded from knowledge + const result = await this.run(input.message); + return result; + } +} +``` + +### Multi-Channel Routing + +Agents can send output to different channels: + +```typescript +class MultiChannelAgent extends BaseAgent { + name = 'multi-agent'; + description = 'Routes to multiple channels'; + mode = 'agent'; + + async invokeAgent(input) { + const result = await this.run(input.message); + + // Send to multiple channels + await this.sendTo('slack:#general', result.output); + await this.sendTo('email-team', result.output); + await this.sendTo('sms-alerts', 'Task completed!'); + + return result; + } +} +``` + +### Extending Built-in Agents + +```typescript +import { ResearchAgent } from '@toolpack-sdk/agents'; + +class FintechResearchAgent extends ResearchAgent { + systemPrompt = `You are a research agent focused on fintech. + Always cite sources and flag regulatory implications.`; + provider = 'anthropic'; + model = 'claude-sonnet-4-20250514'; + + async onComplete(result) { + // Store research in knowledge base + if (this.knowledge) { + await this.knowledge.add(result.output, { + category: 'research', + topic: 'fintech', + }); + } + + // Send to Slack + await this.sendTo('slack-research', result.output); + } +} +``` + +### Features + +- ✅ **7 Built-in Channels** — Slack, Telegram, Discord, Email, SMS, Webhook, Scheduled +- ✅ **4 Built-in Agents** — Research, Coding, Data, Browser +- ✅ **Event-Driven** — Full lifecycle events for monitoring +- ✅ **Knowledge Integration** — Conversation memory and RAG +- ✅ **Multi-Channel Routing** — Send to any registered channel +- ✅ **Human-in-the-Loop** — `ask()` support for two-way channels +- ✅ **Type-Safe** — Full TypeScript support +- ✅ **199 Tests Passing** — Production-ready + +See the [Agents package README](./packages/toolpack-agents/README.md) for full documentation. + ## Multimodal Support The SDK supports multimodal inputs (text + images) across all vision-capable providers. Images can be provided in three formats: diff --git a/package-lock.json b/package-lock.json index 3185463..719d263 100644 --- a/package-lock.json +++ b/package-lock.json @@ -192,6 +192,165 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.1", + "@discordjs/util": "^1.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@discordjs/rest/node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@discordjs/collection": "^2.1.0", + "@discordjs/rest": "^2.5.1", + "@discordjs/util": "^1.1.0", + "@sapphire/async-queue": "^1.5.2", + "@types/ws": "^8.5.10", + "@vladfrangu/async_event_emitter": "^2.2.4", + "discord-api-types": "^0.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -1396,6 +1555,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1409,6 +1569,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1422,6 +1583,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1435,6 +1597,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1448,6 +1611,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1461,6 +1625,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1474,6 +1639,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1487,6 +1653,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1500,6 +1667,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1513,6 +1681,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1526,6 +1695,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1539,6 +1709,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1552,6 +1723,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1565,6 +1737,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1578,6 +1751,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1591,6 +1765,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1604,6 +1779,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1617,6 +1793,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1630,6 +1807,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1643,6 +1821,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1656,6 +1835,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1669,6 +1849,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1682,6 +1863,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1695,6 +1877,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1708,12 +1891,49 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1721,6 +1941,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@toolpack-sdk/agents": { + "resolved": "packages/toolpack-agents", + "link": true + }, "node_modules/@toolpack-sdk/knowledge": { "resolved": "packages/toolpack-knowledge", "link": true @@ -1854,7 +2078,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "devOptional": true, + "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -1878,11 +2102,22 @@ "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.18.0" } }, + "node_modules/@types/nodemailer": { + "version": "6.4.23", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.23.tgz", + "integrity": "sha512-aFV3/NsYFLSx9mbb5gtirBSXJnAlrusoKNuPbxsASWc7vrKLmIrTQRpdcxNcSFL3VW2A2XpeLEavwb2qMi6nlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/pg": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", @@ -1895,6 +2130,16 @@ "pg-types": "^2.2.0" } }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/yauzl": { "version": "2.10.3", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", @@ -2331,6 +2576,17 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@vladfrangu/async_event_emitter": { + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -2478,6 +2734,13 @@ "dev": true, "license": "MIT" }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/aws-ssl-profiles": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", @@ -2487,6 +2750,28 @@ "node": ">= 6.0.0" } }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/b4a": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz", @@ -2724,6 +3009,13 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/bundle-require": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz", @@ -2750,6 +3042,37 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2895,6 +3218,19 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2969,6 +3305,18 @@ "dev": true, "license": "MIT" }, + "node_modules/cron-parser": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.5.0.tgz", + "integrity": "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==", + "license": "MIT", + "dependencies": { + "luxon": "^3.7.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3021,6 +3369,13 @@ "node": ">= 14" } }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3083,6 +3438,16 @@ "node": ">= 14" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -3116,6 +3481,54 @@ "node": ">=0.3.1" } }, + "node_modules/discord-api-types": { + "version": "0.38.45", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.45.tgz", + "integrity": "sha512-DiI01i00FPv6n+hXcFkFxK8Y/rFRpKs6U6aP32N4T73nTbj37Eua3H/95TBpLktLWB6xnLXhYDGvyLq6zzYY2w==", + "dev": true, + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.26.2", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.2.tgz", + "integrity": "sha512-feShi+gULJ6R2MAA4/KkCFnkJcuVrROJrKk4czplzq8gE1oqhqgOy9K0Scu44B8oGeWKe04egquzf+ia6VtXAw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.1", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", + "@sapphire/snowflake": "3.5.3", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "3.1.3", + "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/discord.js/node_modules/undici": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -3171,6 +3584,21 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -3178,6 +3606,16 @@ "dev": true, "license": "MIT" }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -3236,6 +3674,26 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -3243,6 +3701,35 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", @@ -3723,6 +4210,27 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3740,6 +4248,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fs-constants": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", @@ -3750,6 +4275,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3760,6 +4286,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -3778,6 +4314,45 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", @@ -3887,6 +4462,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3897,6 +4485,48 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -4226,22 +4856,68 @@ "ts-algebra": "^2.0.0" }, "engines": { - "node": ">=16" + "node": ">=16" + } + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dev": true, + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" } }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } }, "node_modules/keyv": { "version": "4.5.4", @@ -4573,6 +5249,55 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4580,6 +5305,20 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.snakecase": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "dev": true, + "license": "MIT" + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -4610,6 +5349,22 @@ "url": "https://github.com/sponsors/wellwelwel" } }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/magic-bytes.js": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", + "dev": true, + "license": "MIT" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4655,6 +5410,16 @@ "dev": true, "license": "ISC" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -4689,6 +5454,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-response": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", @@ -18249,6 +19037,19 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -18886,6 +19687,22 @@ "node": ">=18" } }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -19040,7 +19857,7 @@ "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -19158,6 +19975,14 @@ "node": ">=11.0.0" } }, + "node_modules/scmp": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/scmp/-/scmp-2.1.0.tgz", + "integrity": "sha512-o/mRQGk9Rcer/jEEw/yw4mwo3EU/NvYvp577/Btqrym9Qy5/MdWGBqipbALgd2lrdWTJ5/gqDusxfnQBxOxT2Q==", + "deprecated": "Just use Node.js's crypto.timingSafeEqual()", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -19193,6 +20018,82 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -19669,6 +20570,13 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "dev": true, + "license": "MIT" + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -19821,6 +20729,62 @@ "node": "*" } }, + "node_modules/twilio": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/twilio/-/twilio-5.13.1.tgz", + "integrity": "sha512-sT+PkhptF4Mf7t8eXFFvPQx4w5VHnBIPXbltGPMFRe+R2GxfRdMuFbuNA/cEm0aQR6LFQOn33+fhClg+TjRVqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.13.5", + "dayjs": "^1.11.9", + "https-proxy-agent": "^5.0.0", + "jsonwebtoken": "^9.0.3", + "qs": "^6.14.1", + "scmp": "^2.1.0", + "xmlbuilder": "^13.0.2" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/twilio/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/twilio/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/twilio/node_modules/xmlbuilder": { + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-13.0.2.tgz", + "integrity": "sha512-Eux0i2QdDYKbdbA6AM6xE4m6ZTZr4G4xF9kahI2ukSEMCzwce2eX9WlTI5J3s+NU7hpasFsr8hWIONae7LluAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -19844,7 +20808,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -19898,6 +20862,7 @@ "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true, "license": "MIT" }, "node_modules/uri-js": { @@ -20332,6 +21297,50 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "packages/toolpack-agents": { + "name": "@toolpack-sdk/agents", + "version": "1.4.0", + "license": "Apache-2.0", + "dependencies": { + "cron-parser": "^5.5.0" + }, + "devDependencies": { + "@types/node": "^25.3.2", + "@types/nodemailer": "^6.4.23", + "discord.js": "^14.26.2", + "twilio": "^5.13.1", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@toolpack-sdk/knowledge": "^1.4.0", + "better-sqlite3": "^11.x", + "discord.js": "^14.x", + "nodemailer": "^6.x", + "toolpack-sdk": "^1.4.0", + "twilio": "^5.x" + }, + "peerDependenciesMeta": { + "@toolpack-sdk/knowledge": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "discord.js": { + "optional": true + }, + "nodemailer": { + "optional": true + }, + "twilio": { + "optional": true + } + } + }, "packages/toolpack-knowledge": { "name": "@toolpack-sdk/knowledge", "version": "1.4.0", diff --git a/package.json b/package.json index 5afdf41..38b0c1e 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "packages/*" ], "scripts": { - "build": "npm run build -w packages/toolpack-knowledge && npm run build -w packages/toolpack-sdk", - "test": "npm run test -w packages/toolpack-knowledge && npm run test -w packages/toolpack-sdk", + "build": "npm run build -w packages/toolpack-knowledge && npm run build -w packages/toolpack-sdk && npm run build -w packages/toolpack-agents", + "test": "npm run test -w packages/toolpack-knowledge && npm run test -w packages/toolpack-sdk && npm run test -w packages/toolpack-agents", "lint": "eslint packages/*/src/**", "version": "node scripts/update-version.js" }, diff --git a/packages/toolpack-agents/README.md b/packages/toolpack-agents/README.md new file mode 100644 index 0000000..2efc9e1 --- /dev/null +++ b/packages/toolpack-agents/README.md @@ -0,0 +1,825 @@ +# @toolpack-sdk/agents + +Build production-ready AI agents with channels, workflows, and event-driven architecture. + +[![npm version](https://img.shields.io/npm/v/@toolpack-sdk/agents.svg)](https://www.npmjs.com/package/@toolpack-sdk/agents) +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) + +## Features + +- **4 Built-in Agents** — Research, Coding, Data, Browser +- **7 Channel Types** — Slack, Telegram, Discord, Email, SMS, Webhook, Scheduled +- **Event-Driven** — Full lifecycle hooks and events +- **Human-in-the-Loop** — `ask()` support for two-way channels +- **Knowledge Integration** — Built-in RAG support with knowledge bases +- **Type-Safe** — Full TypeScript support +- **Production-Ready** — 573 tests passing + +## Installation + +```bash +npm install @toolpack-sdk/agents +``` + +## Stable API (Phase 4) + +The following APIs are stable and follow semantic versioning. Breaking changes will require a major version bump: + +- `BaseAgent` — Abstract base class for all agents +- `BaseChannel` — Abstract base class for all channels +- `AgentRegistry` — Registry for agents and channels +- `AgentInput`, `AgentResult`, `AgentOutput` — Core data structures +- `AgentTransport`, `LocalTransport`, `JsonRpcTransport` — Transport layer +- `AgentJsonRpcServer` — JSON-RPC server for hosting agents +- `AgentError` — Error class for agent failures + +### Version Policy + +- **Major (X.y.z)** — Breaking API changes +- **Minor (x.Y.z)** — New features, backward compatible +- **Patch (x.y.Z)** — Bug fixes, backward compatible + +## Quick Start + +```typescript +import { BaseAgent, AgentRegistry, SlackChannel } from '@toolpack-sdk/agents'; + +// 1. Create a channel +const slack = new SlackChannel({ + name: 'slack', + token: process.env.SLACK_BOT_TOKEN, + signingSecret: process.env.SLACK_SIGNING_SECRET, + channel: '#support', +}); + +// 2. Create an agent (channels live on the agent) +class SupportAgent extends BaseAgent { + name = 'support-agent'; + description = 'Customer support agent'; + mode = 'chat'; + channels = [slack]; + + async invokeAgent(input) { + const result = await this.run(input.message); + return result; + } +} + +// 3. Single-agent: start directly +const agent = new SupportAgent({ apiKey: process.env.ANTHROPIC_API_KEY }); +await agent.start(); + +// OR multi-agent: use AgentRegistry +// const registry = new AgentRegistry([agent]); +// await registry.start(); +``` + +## Built-in Agents + +### ResearchAgent +Web research for summarization, fact-finding, and trend monitoring. + +```typescript +import { ResearchAgent } from '@toolpack-sdk/agents'; + +const agent = new ResearchAgent({ apiKey: process.env.ANTHROPIC_API_KEY }); +const result = await agent.invokeAgent({ + message: 'Summarize recent AI developments', +}); +``` + +**Mode:** `agent` | **Tools:** `web.search`, `web.fetch`, `web.scrape` + +### CodingAgent +Code generation, refactoring, debugging, and test writing. + +```typescript +import { CodingAgent } from '@toolpack-sdk/agents'; + +const agent = new CodingAgent({ apiKey: process.env.ANTHROPIC_API_KEY }); +const result = await agent.invokeAgent({ + message: 'Refactor the auth module', +}); +``` + +**Mode:** `coding` | **Tools:** `fs.*`, `coding.*`, `git.*`, `exec.*` + +### DataAgent +Database queries, reporting, data analysis, and CSV generation. + +```typescript +import { DataAgent } from '@toolpack-sdk/agents'; + +const agent = new DataAgent({ apiKey: process.env.ANTHROPIC_API_KEY }); +const result = await agent.invokeAgent({ + message: 'Generate weekly signups report', +}); +``` + +**Mode:** `agent` | **Tools:** `db.*`, `fs.*`, `http.*` + +### BrowserAgent +Web browsing, form interaction, and content extraction. + +```typescript +import { BrowserAgent } from '@toolpack-sdk/agents'; + +const agent = new BrowserAgent({ apiKey: process.env.ANTHROPIC_API_KEY }); +const result = await agent.invokeAgent({ + message: 'Extract prices from acme.com/products', +}); +``` + +**Mode:** `chat` | **Tools:** `web.fetch`, `web.screenshot`, `web.extract_links` + +## Channels + +Channels connect agents to external services. They can be **two-way** (receive messages, support `ask()`) or **trigger-only** (send only, no `ask()` support). + +### SlackChannel (Two-way) + +```typescript +const slack = new SlackChannel({ + name: 'slack-support', + token: process.env.SLACK_BOT_TOKEN, + signingSecret: process.env.SLACK_SIGNING_SECRET, + channel: '#support', + port: 3000, +}); +``` + +### TelegramChannel (Two-way) + +```typescript +const telegram = new TelegramChannel({ + name: 'telegram-bot', + token: process.env.TELEGRAM_BOT_TOKEN, +}); +``` + +### WebhookChannel (Two-way) + +```typescript +const webhook = new WebhookChannel({ + name: 'github-webhook', + path: '/webhook/github', + port: 3000, +}); +``` + +### ScheduledChannel (Trigger-only) + +Runs agents on cron schedules. Supports full cron expressions. + +```typescript +const scheduler = new ScheduledChannel({ + name: 'daily-report', + cron: '0 9 * * 1-5', // 9am weekdays + notify: 'webhook:https://hooks.example.com/daily-report', + message: 'Generate daily report', +}); +// For Slack delivery, attach a named SlackChannel to the same agent and +// call `this.sendTo('', output)` from within `run()`. +``` + +### DiscordChannel (Two-way) + +```typescript +const discord = new DiscordChannel({ + name: 'discord-bot', + token: process.env.DISCORD_BOT_TOKEN, + guildId: 'your-guild-id', + channelId: 'your-channel-id', +}); +``` + +### EmailChannel (Outbound-only) + +```typescript +const email = new EmailChannel({ + name: 'email-alerts', + from: 'bot@acme.com', + to: 'team@acme.com', + smtp: { + host: 'smtp.gmail.com', + port: 587, + auth: { user: 'bot@acme.com', pass: process.env.SMTP_PASSWORD }, + }, +}); +``` + +### SMSChannel (Configurable) + +Two-way when `webhookPath` is set, outbound-only otherwise. + +```typescript +// Two-way +const sms = new SMSChannel({ + name: 'sms-alerts', + accountSid: process.env.TWILIO_ACCOUNT_SID, + authToken: process.env.TWILIO_AUTH_TOKEN, + from: '+1234567890', + webhookPath: '/sms/webhook', + port: 3000, +}); + +// Outbound-only +const smsOutbound = new SMSChannel({ + name: 'sms-notifications', + accountSid: process.env.TWILIO_ACCOUNT_SID, + authToken: process.env.TWILIO_AUTH_TOKEN, + from: '+1234567890', + to: '+0987654321', +}); +``` + +## Creating Custom Agents + +Extend `BaseAgent` to create custom agents: + +```typescript +import { BaseAgent } from '@toolpack-sdk/agents'; + +class MyAgent extends BaseAgent { + name = 'my-agent'; + description = 'My custom agent'; + mode = 'agent'; + + async invokeAgent(input) { + // Process the message + const result = await this.run(input.message); + + // Send to a channel + await this.sendTo('slack', result.output); + + return result; + } +} +``` + +## Human-in-the-Loop + +Use `ask()` to pause execution and request human input (two-way channels only). `ask()` sends the question and returns immediately — the user's answer arrives on the **next** invocation, where you check `getPendingAsk()`. + +```typescript +class ApprovalAgent extends BaseAgent { + name = 'approval-agent'; + mode = 'agent'; + + async invokeAgent(input) { + // Turn 2: check if we are waiting for an answer + const pending = this.getPendingAsk(input.conversationId); + if (pending && input.message) { + return this.handlePendingAsk( + pending, + input.message, + async (answer) => { + if (answer.toLowerCase() === 'yes') { + await this.sendTo('slack', 'Draft approved!'); + return { output: 'Draft approved and sent.' }; + } + return { output: 'Draft discarded.' }; + }, + ); + } + + // Turn 1: do some work, then ask for approval + const draft = await this.run(`Draft a response to: ${input.message}`); + return this.ask(`Here is my draft:\n\n${draft.output}\n\nApprove? (yes/no)`); + } +} +``` + +**Note:** `ask()` throws if called from trigger-only channels (ScheduledChannel, EmailChannel). It requires a registry — use `AgentRegistry`, not standalone `agent.start()`. + +## Conversation History + +Store conversation history separately from domain knowledge: + +```typescript +import { InMemoryConversationStore } from '@toolpack-sdk/agents'; + +class SupportAgent extends BaseAgent { + // In-memory store (development/single-process) + conversationHistory = new InMemoryConversationStore(); + + async invokeAgent(input) { + // History is automatically loaded before AI call + // and stored after response + const result = await this.run(input.message); + return result; + } +} +``` + +**Features:** +- Auto-assembles conversation history before each AI call (up to 3 000-token budget by default) +- Auto-stores user and assistant messages via the capture interceptor +- Auto-trims to `maxMessagesPerConversation` limit (default: 500) +- Zero-config in-memory mode for development +- `conversation_search` tool is automatically provided as a request-scoped tool whenever a `conversationId` is active + +**Memory model:** +Agent memory is per-conversation by default. The `conversation_search` tool is bound at invocation time to the current conversation — the LLM cannot override this scope, and turns from other conversations are structurally unreachable. Use `knowledge_add` to promote durable facts that should persist across conversations; knowledge is the only cross-conversation bridge. + +## Knowledge Integration + +Integrate knowledge bases for RAG (domain knowledge, not conversation history). +Knowledge is configured at the SDK level and automatically available to all agents: + +```typescript +import { Toolpack } from 'toolpack-sdk'; +import { Knowledge, MemoryProvider } from '@toolpack-sdk/knowledge'; + +// Configure knowledge at SDK level +const knowledge = await Knowledge.create({ + provider: new MemoryProvider(), +}); + +const toolpack = await Toolpack.init({ + provider: 'openai', + knowledge, // Available to all agents using this toolpack +}); + +class SmartAgent extends BaseAgent { + async invokeAgent(input) { + // Both `knowledge_search` and `knowledge_add` tools are + // automatically available as request-scoped tools. + // The AI can use them to retrieve or store information. + const result = await this.run(input.message); + return result; + } +} +``` + +**Available Tools:** +- `knowledge_search` — Search the knowledge base for relevant information +- `knowledge_add` — Add new information to the knowledge base at runtime + +The SDK automatically injects usage guidance into the system prompt when these tools are available. + +**Knowledge as the cross-conversation bridge:** + +`knowledge_add` is the *only* path by which information crosses conversation boundaries. Conversation history is scoped to the current conversation and inaccessible elsewhere; anything promoted via `knowledge_add` becomes available in all future conversations for that agent. + +Promote when: +- A task surfaces a fact useful beyond the current conversation +- A user states a durable preference +- A decision is made that future conversations should respect + +Do **not** promote: +- Routine task outputs (e.g., "answered a weather question") +- Context that is specific to this conversation only +- Confidential information whose visibility should remain inside the current conversation + +Because every promotion is an explicit agent action visible in traces, the knowledge base stays auditable and intentional. If you need per-entry visibility controls (e.g., scoping a knowledge entry to a subset of channels), that is a future extension — for now, apply developer discipline: only promote what every future conversation is permitted to see. + +## Multi-Channel Routing + +Send output to multiple channels: + +```typescript +class MultiChannelAgent extends BaseAgent { + async invokeAgent(input) { + const result = await this.run(input.message); + + await this.sendTo('slack', result.output); + await this.sendTo('email-team', result.output); + await this.sendTo('sms-alerts', 'Task done!'); + + return result; + } +} +``` + +## Agent Events + +Listen to agent lifecycle events: + +```typescript +const agent = new MyAgent(sdk); + +agent.on('agent:start', (input) => { + console.log('Agent started:', input.message); +}); + +agent.on('agent:complete', (result) => { + console.log('Agent completed:', result.output); +}); + +agent.on('agent:error', (error) => { + console.error('Agent error:', error); +}); +``` + +## Extending Built-in Agents + +Customize built-in agents with your own prompts and logic: + +```typescript +import { ResearchAgent } from '@toolpack-sdk/agents'; +import { AGENT_MODE } from 'toolpack-sdk'; + +class FintechResearchAgent extends ResearchAgent { + mode = { + ...AGENT_MODE, + systemPrompt: 'You are a fintech research specialist. Always cite sources and flag regulatory implications.', + }; + + async onComplete(result) { + // Notify team + await this.sendTo('slack-research', result.output); + } +} + +// Knowledge is configured at SDK level, not on the agent. +// The AI can use `knowledge_add` to store information during execution. +const toolpack = await Toolpack.init({ + provider: 'openai', + knowledge: await Knowledge.create({ provider: new MemoryProvider() }), +}); +``` + +## Peer Dependencies + +The following are optional peer dependencies. Install only what you need: + +```bash +# For DiscordChannel +npm install discord.js + +# For EmailChannel +npm install nodemailer + +# For SMSChannel +npm install twilio +``` + +## API Reference + +### BaseAgent + +```typescript +abstract class BaseAgent { + abstract name: string; + abstract description: string; + abstract mode: ModeConfig | string; + + // Core method to implement + abstract invokeAgent(input: AgentInput): Promise; + + // Built-in methods + protected run(message: string, options?: AgentRunOptions, context?: { conversationId?: string }): Promise; + protected sendTo(channelName: string, message: string): Promise; + protected ask(question: string, options?: { context?: Record; maxRetries?: number; expiresIn?: number }): Promise; + protected getPendingAsk(conversationId?: string): PendingAsk | null; +} +``` + +### AgentRegistry + +```typescript +class AgentRegistry { + constructor(agents: BaseAgent[]); + start(): Promise; + stop(): Promise; + sendTo(channelName: string, output: AgentOutput): Promise; + getAgent(name: string): AgentInstance | undefined; + getChannel(name: string): ChannelInterface | undefined; + invoke(agentName: string, input: AgentInput): Promise; +} +``` + +### Channels + +All channels extend `BaseChannel`: + +```typescript +abstract class BaseChannel { + abstract readonly isTriggerChannel: boolean; + name?: string; + + abstract listen(): void; + abstract send(output: AgentOutput): Promise; + abstract normalize(incoming: unknown): AgentInput; + onMessage(handler: (input: AgentInput) => Promise): void; +} +``` + +## Agent-to-Agent Messaging + +Agents can delegate tasks to other agents without tight coupling. + +### Local Delegation (Same Process) + +```typescript +import { AgentRegistry, BaseAgent } from '@toolpack-sdk/agents'; +import type { AgentInput, AgentResult } from '@toolpack-sdk/agents'; + +class EmailAgent extends BaseAgent { + name = 'email-agent'; + description = 'Sends email reports'; + mode = 'chat'; + channels = [slack]; // channels are class properties, not constructor args + + async invokeAgent(input: AgentInput): Promise { + // Delegate to DataAgent and wait for result + const report = await this.delegateAndWait('data-agent', { + message: 'Generate weekly leads report', + intent: 'generate_report', + }); + + return { + output: `Email sent with report: ${report.output}`, + }; + } +} + +const emailAgent = new EmailAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +const dataAgent = new DataAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +const registry = new AgentRegistry([emailAgent, dataAgent]); +await registry.start(); +``` + +### Cross-Process Delegation (JSON-RPC) + +**Server (Host Agents):** +```typescript +import { AgentJsonRpcServer } from '@toolpack-sdk/agents'; + +const server = new AgentJsonRpcServer({ port: 3000 }); +server.registerAgent('data-agent', new DataAgent({ apiKey: process.env.ANTHROPIC_API_KEY! })); +server.registerAgent('research-agent', new ResearchAgent({ apiKey: process.env.ANTHROPIC_API_KEY! })); +server.listen(); +``` + +**Client (Call Remote Agents):** +```typescript +import { AgentRegistry, JsonRpcTransport, BaseAgent } from '@toolpack-sdk/agents'; +import type { AgentInput, AgentResult } from '@toolpack-sdk/agents'; + +const emailAgent = new EmailAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +const registry = new AgentRegistry([emailAgent], { + transport: new JsonRpcTransport({ + agents: { + 'data-agent': 'http://localhost:3000', + 'research-agent': 'http://remote-server:3000', + } + }) +}); + +// Inside EmailAgent +class EmailAgent extends BaseAgent { + async invokeAgent(input: AgentInput): Promise { + // Can now delegate to remote agents + const report = await this.delegateAndWait('data-agent', { + message: 'Generate report' + }); + return { output: `Email sent with: ${report.output}` }; + } +} +``` + +### Delegation Methods + +- **`delegate(agentName, input)`** - Fire-and-forget, returns immediately +- **`delegateAndWait(agentName, input)`** - Waits for result, returns `AgentResult` + +## Registry + +Discover and publish community-built agents. + +### Finding Agents + +```typescript +import { searchRegistry } from '@toolpack-sdk/agents/registry'; + +// Search all agents +const results = await searchRegistry(); + +// Search by keyword +const results = await searchRegistry({ keyword: 'fintech' }); + +// Filter by category +const results = await searchRegistry({ category: 'research' }); + +// Display results +for (const agent of results.agents) { + console.log(`${agent.name}: ${agent.toolpack?.description || agent.description}`); + console.log(` Install: npm install ${agent.name}`); +} +``` + +### Publishing an Agent + +Add the `toolpack` metadata to your `package.json`: + +```json +{ + "name": "toolpack-agent-fintech-research", + "version": "1.0.0", + "keywords": ["toolpack-agent"], + "toolpack": { + "agent": true, + "category": "research", + "description": "Research agent focused on fintech news and regulatory updates", + "tags": ["fintech", "news", "research"] + } +} +``` + +Requirements: +- Must include `"toolpack-agent"` in `keywords` +- Must have `"toolpack": { "agent": true }` in package.json +- Agent class must extend `BaseAgent` + +## Error Handling + +### Error Types + +| Error | Cause | Resolution | +|-------|-------|------------| +| `AgentError` | Generic agent failure | Check error message for details | +| `AgentError` (delegate) | Agent not registered | Ensure agent is registered with `AgentRegistry` | +| `AgentError` (transport) | Transport misconfiguration | Verify transport config and agent URLs | +| `RegistryError` | NPM registry failure | Check network connection and registry URL | + +### Handling Errors + +```typescript +import { AgentError } from '@toolpack-sdk/agents'; + +try { + const result = await agent.invokeAgent({ message: 'Hello' }); +} catch (error) { + if (error instanceof AgentError) { + // Agent-specific error + console.error('Agent failed:', error.message); + } else { + // Unknown error + console.error('Unexpected error:', error); + } +} +``` + +### Common Issues + +**Agent not found during delegation** +``` +Agent "data-agent" not found in registry. Available agents: email-agent, browser-agent +``` +→ Ensure the target agent is registered in `AgentRegistry`. + +**Transport configuration error** +``` +No transport configured for delegation +``` +→ Use `AgentRegistry` with `LocalTransport` (default) or configure `JsonRpcTransport` for cross-process communication. + +**JSON-RPC connection failure** +``` +Failed to invoke agent "data-agent" at http://localhost:3000: fetch failed +``` +→ Verify the JSON-RPC server is running and the URL/port is correct. + +## Interceptors + +Interceptors are composable middleware that run before `invokeAgent`. They can filter, enrich, classify, or short-circuit incoming messages. All built-ins are opt-in — none run unless you explicitly list them. + +Import from the dedicated subpath: + +```typescript +import { + createNoiseFilterInterceptor, + createRateLimitInterceptor, + createSelfFilterInterceptor, + // ... +} from '@toolpack-sdk/agents/interceptors'; +``` + +### Writing a Custom Interceptor + +```typescript +import type { Interceptor } from '@toolpack-sdk/agents/interceptors'; + +const myInterceptor: Interceptor = async (input, ctx, next) => { + if (shouldIgnore(input)) { + return ctx.skip(); // End the chain silently — no reply sent + } + const result = await next(); // Continue to next interceptor or agent + return result; +}; + +class MyAgent extends BaseAgent { + interceptors = [myInterceptor]; +} +``` + +### Registering Interceptors + +```typescript +import { + createNoiseFilterInterceptor, + createRateLimitInterceptor, +} from '@toolpack-sdk/agents/interceptors'; + +class MyAgent extends BaseAgent { + name = 'my-agent'; + description = 'My agent'; + mode = 'chat'; + + interceptors = [ + createNoiseFilterInterceptor({ denySubtypes: ['message_changed', 'message_deleted'] }), + createRateLimitInterceptor({ + getKey: (input) => input.participant?.id ?? 'anon', + tokensPerInterval: 5, + interval: 60000, // 5 messages per minute per user + }), + ]; + + async invokeAgent(input) { + return this.run(input.message); + } +} +``` + +### Built-in Interceptors + +| Interceptor | Purpose | +|---|---| +| `createNoiseFilterInterceptor` | Drop messages by subtype (edits, deletes, bot messages) | +| `createEventDedupInterceptor` | Drop duplicate events (Slack retries, webhook redeliveries) | +| `createSelfFilterInterceptor` | Drop the agent's own messages (infinite loop guard) | +| `createRateLimitInterceptor` | Token-bucket rate limiting per user or conversation | +| `createAddressCheckInterceptor` | Rule-based address detection (@mention, vocative, direct message) | +| `createIntentClassifierInterceptor` | LLM-based intent classification for ambiguous address checks | +| `createParticipantResolverInterceptor` | Resolve participant identity from platform user ID | +| `createCaptureInterceptor` | Persist inbound and outbound messages to conversation history (auto-registered) | +| `createDepthGuardInterceptor` | Reject delegation chains that exceed a configured depth | +| `createTracerInterceptor` | Structured logging of each chain hop for debugging | + +## Capabilities + +Capability agents are headless agents with no channels. They are invoked by interceptors or other agents for specific cross-cutting concerns. + +Import from the dedicated subpath: + +```typescript +import { IntentClassifierAgent, SummarizerAgent } from '@toolpack-sdk/agents/capabilities'; +``` + +### IntentClassifierAgent + +Classifies whether a message is directly addressing the target agent. Used by `createIntentClassifierInterceptor` to resolve ambiguous cases that rules alone cannot determine. + +```typescript +import { IntentClassifierAgent } from '@toolpack-sdk/agents/capabilities'; +import type { IntentClassifierInput } from '@toolpack-sdk/agents/capabilities'; + +const classifier = new IntentClassifierAgent({ apiKey: process.env.ANTHROPIC_API_KEY }); +const result = await classifier.invokeAgent({ + message: 'classify', + data: { + message: 'Hey @assistant can you help?', + agentName: 'assistant', + agentId: 'U123', + senderName: 'alice', + channelName: 'general', + } as IntentClassifierInput, +}); +// result.output === 'direct' | 'indirect' | 'passive' | 'ignore' +``` + +### SummarizerAgent + +Compresses older conversation history turns into a compact summary. Used by the prompt assembler when conversation history exceeds the token budget. + +```typescript +import { SummarizerAgent } from '@toolpack-sdk/agents/capabilities'; +import type { SummarizerInput, SummarizerOutput } from '@toolpack-sdk/agents/capabilities'; + +const summarizer = new SummarizerAgent({ apiKey: process.env.ANTHROPIC_API_KEY }); +const result = await summarizer.invokeAgent({ + message: 'summarize', + data: { + turns: olderTurns, + agentName: 'support-agent', + agentId: 'U123', + maxTokens: 500, + extractDecisions: true, + } as SummarizerInput, +}); +const summary = JSON.parse(result.output) as SummarizerOutput; +``` + +## Testing + +```bash +npm test +``` + +**Test Coverage:** 573 tests passing across 29 test files. + +## License + +Apache 2.0 © Toolpack SDK diff --git a/packages/toolpack-agents/docs/README.md b/packages/toolpack-agents/docs/README.md new file mode 100644 index 0000000..ff41172 --- /dev/null +++ b/packages/toolpack-agents/docs/README.md @@ -0,0 +1,127 @@ +# toolpack-agents — Complete Guide + +`toolpack-agents` is the agent layer of the Toolpack SDK. It provides a consistent, extensible pattern for building, composing, and deploying AI agents that communicate through real-world channels (Slack, Discord, Telegram, webhooks, SMS, scheduled jobs) and collaborate with each other. + +## Package + +``` +@toolpack-sdk/agents (imported from '@toolpack-sdk/agents' in the monorepo) +``` + +--- + +## Architecture at a glance + +``` +┌──────────────────────────────────────────────────────┐ +│ AgentRegistry │ +│ Coordinates lifecycle, routing, and delegation │ +│ │ +│ ┌─────────────────────────────────────────────────┐ │ +│ │ BaseAgent (your custom agent) │ │ +│ │ ├─ name, description, mode │ │ +│ │ ├─ systemPrompt, model, provider │ │ +│ │ ├─ channels[] ──► Channel integrations │ │ +│ │ ├─ interceptors[] ─► Middleware chain │ │ +│ │ ├─ conversationHistory ─► ConversationStore │ │ +│ │ └─ invokeAgent() ─► your business logic │ │ +│ └─────────────────────────────────────────────────┘ │ +│ │ │ +│ LocalTransport ◄─┤─► delegate / delegateAndWait │ +└──────────────────────────────────────────────────────┘ + │ │ + External channels Capability agents + (Slack, Discord, (Summarizer, IntentClassifier) + Telegram, etc.) +``` + +**Key concepts** + +| Concept | Purpose | +|---|---| +| `BaseAgent` | Abstract base for every agent. Extend it to add business logic. | +| `AgentRegistry` | Coordinator for multi-agent deployments. Not needed for a single agent. | +| `ChannelInterface` | Normalises external events → `AgentInput`; delivers `AgentOutput` back. | +| `Interceptor` | Composable middleware (dedup, noise filter, rate limit, history capture…). | +| `ConversationStore` | Persists message history; `assemblePrompt()` reads it to build LLM context. | +| `AgentTransport` | Routes cross-agent invocations (default: `LocalTransport`, in-process). | + +--- + +## Documentation index + +| File | What it covers | +|---|---| +| [agents.md](agents.md) | Creating agents — `BaseAgent` API, built-in agents, lifecycle | +| [registry.md](registry.md) | `AgentRegistry` — multi-agent coordination | +| [channels.md](channels.md) | All 7 channel integrations (Slack, Discord, Telegram, Webhook, Scheduled, Email, SMS) | +| [conversation-history.md](conversation-history.md) | Conversation storage, `assemblePrompt`, addressed-only mode | +| [interceptors.md](interceptors.md) | Interceptor system — all 10 built-in interceptors and custom interceptors | +| [transport.md](transport.md) | Transport layer — `LocalTransport`, `JsonRpcTransport`, delegation | +| [human-in-the-loop.md](human-in-the-loop.md) | `ask()` / `handlePendingAsk()` — pausing agents for human input | +| [capabilities.md](capabilities.md) | `IntentClassifierAgent` and `SummarizerAgent` | +| [testing.md](testing.md) | `createTestAgent`, `MockChannel`, `captureEvents` | +| [examples.md](examples.md) | Full end-to-end examples | + +--- + +## Quick install + +```bash +npm install @toolpack-sdk/agents toolpack-sdk +``` + +Peer dependencies are optional — install only what you need: + +```bash +# Slack (SlackChannel uses a built-in HTTP server, but @slack/web-api is needed for auth.test) +npm install @slack/web-api + +# Discord +npm install discord.js + +# Telegram +npm install node-telegram-bot-api + +# Email +npm install nodemailer + +# SMS +npm install twilio + +# Persistent store +npm install better-sqlite3 +``` + +--- + +## Thirty-second example + +```typescript +import { BaseAgent, AgentInput, AgentResult, AgentRegistry, SlackChannel } from '@toolpack-sdk/agents'; + +class GreetingAgent extends BaseAgent { + name = 'greeting-agent'; + description = 'Greets users warmly'; + mode = 'chat'; + + async invokeAgent(input: AgentInput): Promise { + return this.run(input.message ?? ''); + } +} + +const slack = new SlackChannel({ + name: 'main-slack', + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, + channel: '#general', +}); + +const agent = new GreetingAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +agent.channels = [slack]; + +const registry = new AgentRegistry([agent]); +await registry.start(); +``` + +For a full walkthrough see [examples.md](examples.md). diff --git a/packages/toolpack-agents/docs/agents.md b/packages/toolpack-agents/docs/agents.md new file mode 100644 index 0000000..6adbcda --- /dev/null +++ b/packages/toolpack-agents/docs/agents.md @@ -0,0 +1,449 @@ +# Agents — Creating and Running Agents + +## Contents + +- [BaseAgent — the foundation](#baseagent--the-foundation) +- [Required properties](#required-properties) +- [Optional properties](#optional-properties) +- [Constructor options](#constructor-options) +- [invokeAgent — your business logic](#invokeagent--your-business-logic) +- [run() — calling the LLM](#run--calling-the-llm) +- [Lifecycle hooks](#lifecycle-hooks) +- [Events](#events) +- [Single-agent deployment](#single-agent-deployment) +- [Built-in concrete agents](#built-in-concrete-agents) + +--- + +## BaseAgent — the foundation + +Every agent extends `BaseAgent`. It is an abstract class that handles channel binding, interceptor composition, conversation history assembly, LLM invocation, and cross-agent communication. + +```typescript +import { BaseAgent, AgentInput, AgentResult } from '@toolpack-sdk/agents'; + +class MyAgent extends BaseAgent { + name = 'my-agent'; + description = 'Does something useful'; + mode = 'chat'; // toolpack-sdk mode + + async invokeAgent(input: AgentInput): Promise { + return this.run(input.message ?? 'Hello'); + } +} +``` + +--- + +## Required properties + +These three abstract properties must be set on every agent. + +| Property | Type | Purpose | +|---|---|---| +| `name` | `string` | Unique identifier. Used by `AgentRegistry`, delegation, and history. | +| `description` | `string` | Human-readable summary. Surfaced in registry search results. | +| `mode` | `ModeConfig \| string` | Toolpack SDK mode: `'chat'`, `'agent'`, `'coding'`, etc. Controls which tools the LLM has access to. Pass a `ModeConfig` object to customise the system prompt. | + +--- + +## Optional properties + +```typescript +import { CHAT_MODE } from 'toolpack-sdk'; + +class MyAgent extends BaseAgent { + name = 'my-agent'; + description = '...'; + + // Pass a ModeConfig to set a custom system prompt + mode = { + ...CHAT_MODE, + systemPrompt: 'You are a helpful support assistant. Always be concise.', + }; + + // Override provider and model for this agent only + provider = 'anthropic'; + model = 'claude-opus-4-7'; + + // Workflow config merged on top of mode config + workflow = { maxSteps: 5 }; + + // History store — defaults to InMemoryConversationStore + // Replace with a DB-backed implementation for production + conversationHistory = new InMemoryConversationStore({ maxMessagesPerConversation: 500 }); + + // Options forwarded to assemblePrompt() on every run() + assemblerOptions = { + tokenBudget: 4000, + addressedOnlyMode: true, + rollingSummaryThreshold: 30, + }; + + // Channels this agent listens on (can also be set after construction) + channels = [slackChannel, scheduledChannel]; + + // Interceptors applied before invokeAgent is called + interceptors = [ + createEventDedupInterceptor({ maxCacheSize: 500 }), + createRateLimitInterceptor({ + getKey: (input) => input.participant?.id ?? 'global', + tokensPerInterval: 10, + interval: 60000, + }), + ]; +} +``` + +### `mode` values + +The `mode` property accepts either a string shorthand or a full `ModeConfig` object. + +**String shorthand** — uses the built-in mode with its default system prompt: + +| Mode string | Typical use | +|---|---| +| `'chat'` | Conversational Q&A, no heavy tool use | +| `'agent'` | Research, data, or general agentic tasks with tools | +| `'coding'` | Code generation, refactoring, review | + +**`ModeConfig` object** — spread a built-in mode and override `systemPrompt` (or any other field): + +```typescript +import { CHAT_MODE, AGENT_MODE, CODING_MODE } from 'toolpack-sdk'; + +class MyAgent extends BaseAgent { + mode = { + ...CHAT_MODE, + systemPrompt: 'You are a specialist in semiconductor industry research.', + }; +} +``` + +The mode determines which tools (web.*, db.*, fs.*, etc.) are available to the LLM. + +--- + +## Constructor options + +Two ways to construct an agent: + +### Option A — agent owns its Toolpack instance + +```typescript +const agent = new MyAgent({ + apiKey: process.env.ANTHROPIC_API_KEY!, + provider: 'anthropic', // optional, defaults to 'anthropic' + model: 'claude-sonnet-4-6', // optional, uses provider default +}); +``` + +The Toolpack instance is created lazily when `agent.start()` is called. + +### Option B — share a Toolpack instance + +```typescript +import { Toolpack } from 'toolpack-sdk'; + +const toolpack = await Toolpack.init({ + provider: 'anthropic', + apiKey: process.env.ANTHROPIC_API_KEY!, +}); + +const agentA = new AgentA({ toolpack }); +const agentB = new AgentB({ toolpack }); +``` + +Useful when multiple agents share the same API client configuration. `AgentRegistry` uses this pattern internally. + +--- + +## invokeAgent — your business logic + +`invokeAgent` is the single required method to implement. The agent framework calls it after the interceptor chain approves a message. + +```typescript +async invokeAgent(input: AgentInput): Promise +``` + +### `AgentInput` + +```typescript +interface AgentInput { + intent?: TIntent; // typed routing hint (e.g. 'billing', 'refund') + message?: string; // natural language from the user + data?: unknown; // structured payload from the channel + context?: Record; // extra context (delegatedBy, threadId, etc.) + conversationId?: string; // session/thread identifier for history + participant?: Participant; // who sent the message +} +``` + +### `AgentResult` + +```typescript +interface AgentResult { + output: string; // the agent's text response + steps?: WorkflowStep[]; // execution plan steps (populated by run()) + metadata?: Record; // hints for routing or post-processing +} +``` + +### Routing by intent + +Use TypeScript generics to get compile-time intent safety: + +```typescript +type SupportIntent = 'billing' | 'refund' | 'technical' | 'general'; + +class SupportAgent extends BaseAgent { + name = 'support-agent'; + description = 'Customer support assistant'; + mode = 'chat'; + + async invokeAgent(input: AgentInput): Promise { + switch (input.intent) { + case 'billing': + return this.run(`Handle billing query: ${input.message}`); + case 'refund': + return this.handleRefund(input); + default: + return this.run(input.message ?? ''); + } + } +} +``` + +### Handling pending asks + +When using `ask()` for human-in-the-loop, check for pending asks at the start of `invokeAgent`: + +```typescript +async invokeAgent(input: AgentInput): Promise { + const pending = this.getPendingAsk(input.conversationId); + if (pending && input.message) { + return this.handlePendingAsk( + pending, + input.message, + (answer) => this.continueWithAnswer(answer), + ); + } + // Normal flow... + return this.run(input.message ?? ''); +} +``` + +--- + +## run() — calling the LLM + +`run()` is the protected helper that drives LLM invocation. It handles: + +1. Switching the Toolpack mode to `this.mode` +2. Loading conversation history via `assemblePrompt()` +3. Adding a `conversation_search` tool so the LLM can retrieve specific past turns +4. Calling `toolpack.generate()` with the assembled messages +5. Emitting lifecycle events + +The system prompt comes from `this.mode.systemPrompt` (when `mode` is a `ModeConfig`) and is injected by the Toolpack client — not set as a class-level property. + +```typescript +protected async run( + message: string, + options?: AgentRunOptions, + context?: { conversationId?: string }, +): Promise +``` + +### Passing a conversationId explicitly + +When an agent handles multiple concurrent conversations it is safest to pass `conversationId` explicitly via the third argument to avoid a race on the instance-level `_conversationId` field: + +```typescript +async invokeAgent(input: AgentInput): Promise { + return this.run( + input.message ?? '', + undefined, + { conversationId: input.conversationId }, + ); +} +``` + +### conversation_search tool + +`run()` automatically exposes a `conversation_search` tool to the LLM whenever a `conversationId` is active. The LLM can call it to retrieve specific past turns beyond the assembled context window. + +**Security invariant**: the tool uses a closure-captured `conversationId` and never accepts one from LLM arguments, preventing prompt injection that could reach other conversations. + +--- + +## Lifecycle hooks + +Override these no-op hooks in your agent to react to execution stages: + +```typescript +// Called before run() starts — use to validate input or log +async onBeforeRun(input: AgentInput): Promise {} + +// Called after each workflow step completes +async onStepComplete(step: WorkflowStep): Promise {} + +// Called when run() finishes successfully +async onComplete(result: AgentResult): Promise {} + +// Called when run() throws — re-throw to propagate +async onError(error: Error): Promise {} +``` + +Example — logging step progress: + +```typescript +async onStepComplete(step: WorkflowStep): Promise { + console.log(`[${this.name}] Step ${step.number}: ${step.description} → ${step.status}`); +} +``` + +--- + +## Events + +`BaseAgent` extends `EventEmitter`. Typed events: + +| Event | Payload | When | +|---|---|---| +| `agent:start` | `{ message: string }` | Before LLM call | +| `agent:complete` | `AgentResult` | After successful completion | +| `agent:error` | `Error` | On any error | + +> **Note**: `AgentEvents` also declares `'agent:step'` (payload: `WorkflowStep`) but the built-in `run()` does not currently emit it. If you need per-step callbacks, use the `onStepComplete` lifecycle hook instead. + +```typescript +agent.on('agent:complete', (result) => { + metrics.track('agent.complete', { output_length: result.output.length }); +}); + +agent.on('agent:error', (err) => { + alerting.notify('Agent error', err.message); +}); +``` + +--- + +## Single-agent deployment + +For a single agent you do not need `AgentRegistry`. Just call `agent.start()` directly. + +```typescript +const agent = new MyAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); + +agent.channels = [ + new SlackChannel({ name: 'slack', token: '...', signingSecret: '...', channel: '#general' }), +]; + +await agent.start(); + +// When shutting down: +await agent.stop(); +``` + +**Note**: Without a registry, `sendTo()`, `ask()`, and `delegate()` will throw because they require `_registry` to be set. To use those features you need `AgentRegistry`. + +--- + +## Built-in concrete agents + +Four ready-made agents cover common use cases. Use them directly or extend them. + +### ResearchAgent + +```typescript +import { ResearchAgent } from '@toolpack-sdk/agents'; + +const agent = new ResearchAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +// name: 'research-agent' +// mode: 'agent' +// Equipped with web.search, web.fetch, web.scrape tools +``` + +Best for: web research, fact-finding, summarisation of online sources. + +### CodingAgent + +```typescript +import { CodingAgent } from '@toolpack-sdk/agents'; + +const agent = new CodingAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +// name: 'coding-agent' +// mode: 'coding' +// Equipped with coding.*, fs.*, git.* tools +``` + +Best for: code generation, refactoring, testing, code review. + +### DataAgent + +```typescript +import { DataAgent } from '@toolpack-sdk/agents'; + +const agent = new DataAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +// name: 'data-agent' +// mode: 'agent' +// Equipped with db.*, fs.*, http.* tools +``` + +Best for: database queries, CSV analysis, reporting, data aggregation. + +### BrowserAgent + +```typescript +import { BrowserAgent } from '@toolpack-sdk/agents'; + +const agent = new BrowserAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +// name: 'browser-agent' +// mode: 'chat' +// Equipped with web.fetch, web.screenshot, web.extract_links tools +``` + +Best for: web interaction, form filling, page content extraction, link following. + +### Extending a built-in agent + +```typescript +import { AGENT_MODE } from 'toolpack-sdk'; + +class MyResearcher extends ResearchAgent { + name = 'my-researcher'; + description = 'Specialized research agent for our domain'; + mode = { + ...AGENT_MODE, + systemPrompt: 'You are a specialist in semiconductor industry research...', + }; + + async invokeAgent(input: AgentInput): Promise { + // Add pre-processing + const enrichedMessage = `[Domain: semiconductors] ${input.message}`; + return this.run(enrichedMessage); + } +} +``` + +--- + +## WorkflowStep shape + +When Toolpack returns a structured plan, `run()` extracts steps and includes them in `AgentResult.steps`: + +```typescript +interface WorkflowStep { + number: number; + description: string; + status: 'pending' | 'in_progress' | 'completed' | 'failed' | 'skipped'; + result?: { + success: boolean; + output?: string; + error?: string; + toolsUsed?: string[]; + duration?: number; + }; +} +``` diff --git a/packages/toolpack-agents/docs/capabilities.md b/packages/toolpack-agents/docs/capabilities.md new file mode 100644 index 0000000..18bb3df --- /dev/null +++ b/packages/toolpack-agents/docs/capabilities.md @@ -0,0 +1,214 @@ +# Capability Agents — IntentClassifier and Summarizer + +Capability agents are specialised `BaseAgent` subclasses used internally by interceptors and history assembly. They are not channel-facing — register them without `channels` to use them as pure compute workers. + +## Contents + +- [IntentClassifierAgent](#intentclassifieragent) +- [SummarizerAgent](#summarizerAgent) +- [Using capabilities as standalone agents](#using-capabilities-as-standalone-agents) + +--- + +## IntentClassifierAgent + +Classifies whether a message is addressed to a specific agent. Used internally by `createIntentClassifierInterceptor` and `createAddressCheckInterceptor` to handle ambiguous mentions. + +### Types + +```typescript +import { IntentClassifierAgent, IntentClassifierInput, IntentClassification } from '@toolpack-sdk/agents'; + +type IntentClassification = 'direct' | 'indirect' | 'passive' | 'ignore'; + +interface IntentClassifierInput { + message: string; // the message to classify + agentName: string; // agent's display name + agentId: string; // agent's stable identifier + senderName: string; // who sent the message + channelName: string; // channel the message came from + isDirectMessage?: boolean; // true for DMs (lower bar for 'direct') + recentContext?: Array<{ // last few turns for context + sender: string; + content: string; + }>; + includeExamples?: boolean; // include few-shot examples in the prompt +} +``` + +### Classification meanings + +| Classification | Meaning | +|---|---| +| `'direct'` | The agent is the explicit intended recipient | +| `'indirect'` | The agent is mentioned but not the primary target | +| `'passive'` | The agent is referenced but not being communicated with | +| `'ignore'` | The message is clearly not meant for this agent | + +### Usage + +The classifier is typically invoked automatically by interceptors. For manual use: + +```typescript +const classifier = new IntentClassifierAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +await classifier._ensureToolpack(); + +const result = await classifier.invokeAgent({ + data: { + message: 'Hey @support can you help me with my order?', + agentName: 'support-agent', + agentId: 'support-agent', + senderName: 'Alice', + channelName: 'general', + isDirectMessage: false, + recentContext: [{ sender: 'Bob', content: 'Good morning everyone' }], + } satisfies IntentClassifierInput, + conversationId: 'classify-001', +}); + +// result.output is 'direct' | 'indirect' | 'passive' | 'ignore' +const classification = result.output as IntentClassification; +``` + +--- + +## SummarizerAgent + +Compresses conversation history into a compact summary. Used by `assemblePrompt()` when the conversation exceeds `rollingSummaryThreshold` turns. + +### Types + +```typescript +import { SummarizerAgent, SummarizerInput, SummarizerOutput, HistoryTurn } from '@toolpack-sdk/agents'; + +interface HistoryTurn { + id: string; + participant: Participant; + content: string; + timestamp: string; + metadata?: { + isToolCall?: boolean; + toolName?: string; + toolResult?: unknown; + }; +} + +interface SummarizerInput { + turns: HistoryTurn[]; // turns to summarise + agentName: string; // agent's name (for pronoun resolution) + agentId: string; // agent's identifier + maxTokens?: number; // target summary length (default: 500 tokens) + extractDecisions?: boolean; // include key decisions in output +} + +interface SummarizerOutput { + summary: string; // compressed narrative + turnsSummarized: number; // how many turns were compressed + hasDecisions: boolean; // whether decisions were extracted + estimatedTokens: number; // approximate token count of summary +} +``` + +### Usage + +The summarizer is typically invoked automatically by `assemblePrompt()`. For manual use: + +```typescript +import { SummarizerAgent, SummarizerInput } from '@toolpack-sdk/agents'; + +const summarizer = new SummarizerAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +await summarizer._ensureToolpack(); + +const result = await summarizer.invokeAgent({ + data: { + turns: olderTurns, // HistoryTurn[] to compress + agentName: 'support-agent', + agentId: 'support-agent', + maxTokens: 500, + extractDecisions: true, + } satisfies SummarizerInput, + conversationId: 'summarize-001', +}); + +const output = JSON.parse(result.output) as SummarizerOutput; +console.log(output.summary); +console.log(`Compressed ${output.turnsSummarized} turns`); +``` + +### Wiring into assemblePrompt() + +```typescript +import { assemblePrompt, SummarizerAgent } from '@toolpack-sdk/agents'; + +// Create summarizer once +const summarizer = new SummarizerAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +await summarizer._ensureToolpack(); + +// In your custom history loading logic +const assembled = await assemblePrompt( + store, + conversationId, + 'my-agent', + 'my-agent', + { + rollingSummaryThreshold: 30, // compress when turns > 30 + tokenBudget: 3000, + }, + summarizer, // ← pass the summarizer here +); +``` + +`assemblePrompt()` calls `SummarizerAgent` automatically when the history slice exceeds `rollingSummaryThreshold`. The resulting summary is inserted as a `system` message before the recent turns in the assembled context. + +--- + +## Using capabilities as standalone agents + +Register capability agents without channels. They operate purely as compute workers: + +```typescript +const classifier = new IntentClassifierAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +const summarizer = new SummarizerAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); + +const registry = new AgentRegistry([ + myMainAgent, + classifier, // no channels — pure compute worker + summarizer, // no channels — pure compute worker +]); + +await registry.start(); + +// Main agent can delegate to them +// (Usually this happens via interceptors, not direct delegation) +``` + +Because they extend `BaseAgent`, they get conversation history, lifecycle hooks, and events — but since they have no channels, they can only be invoked via delegation or the registry. + +--- + +## IntentClassifier vs AddressCheck interceptors + +| Feature | `createAddressCheckInterceptor` | `createIntentClassifierInterceptor` | +|---|---|---| +| Method | Pattern matching (regex, heuristics) | LLM call | +| Speed | Fast (no API call) | Slower (API call) | +| Best for | Clear @-mentions, DMs | Ambiguous natural language | +| Usage | First-pass filter | Disambiguation of ambiguous cases | + +Recommended: chain `createAddressCheckInterceptor` (cheap, pattern-based) immediately before `createIntentClassifierInterceptor` (LLM-based). The intent classifier reads `_addressCheck` from context and only makes an LLM call for `'ambiguous'`/`'indirect'` cases: + +```typescript +agent.interceptors = [ + createAddressCheckInterceptor({ + agentName: agent.name, + getMessageText: (input) => input.message ?? '', + }), + createIntentClassifierInterceptor({ + agentName: agent.name, + agentId: agent.name, + getMessageText: (input) => input.message ?? '', + getSenderName: (input) => input.participant?.displayName ?? 'Unknown', + getChannelName: (input) => input.context?.channelName as string ?? 'general', + }), +]; +``` diff --git a/packages/toolpack-agents/docs/channels.md b/packages/toolpack-agents/docs/channels.md new file mode 100644 index 0000000..f30e088 --- /dev/null +++ b/packages/toolpack-agents/docs/channels.md @@ -0,0 +1,380 @@ +# Channels — Connecting Agents to External Systems + +Channels normalise incoming events into `AgentInput` and deliver `AgentOutput` back to the external system. Each channel implements the `ChannelInterface`. + +## Contents + +- [ChannelInterface](#channelinterface) +- [Trigger vs. conversation channels](#trigger-vs-conversation-channels) +- [SlackChannel](#slackchannel) +- [DiscordChannel](#discordchannel) +- [TelegramChannel](#telegramchannel) +- [WebhookChannel](#webhookchannel) +- [ScheduledChannel](#scheduledchannel) +- [EmailChannel](#emailchannel) +- [SMSChannel](#smschannel) +- [Custom channels](#custom-channels) + +--- + +## ChannelInterface + +```typescript +interface ChannelInterface { + name?: string; // required for sendTo() routing + isTriggerChannel: boolean; // see below + + listen(): void; // start accepting messages + send(output: AgentOutput): Promise; + normalize(incoming: unknown): AgentInput; + onMessage(handler: (input: AgentInput) => Promise): void; + + // Optional: resolve richer Participant info (display name, etc.) + resolveParticipant?(input: AgentInput): Promise | Participant | undefined; +} +``` + +You do not normally call these methods yourself — `BaseAgent._bindChannel()` and `AgentRegistry` manage the lifecycle. + +--- + +## Trigger vs. conversation channels + +| `isTriggerChannel` | Examples | Can use `ask()`? | Has human recipient? | +|---|---|---|---| +| `false` | Slack, Discord, Telegram, Webhook | Yes | Yes | +| `true` | Scheduled, Email, SMS (outbound) | **No** | No | + +**Trigger channels** fire the agent on a schedule or external event but have no interactive human on the other end. Calling `ask()` from a trigger channel throws: + +``` +AgentError: this.ask() called from a trigger channel (ScheduledChannel). +Trigger channels have no human recipient — use a conversation channel instead. +``` + +--- + +## SlackChannel + +Connects your agent to Slack workspaces via the Events API. + +### Install + +```bash +npm install @slack/web-api +``` + +### Configuration + +```typescript +import { SlackChannel } from '@toolpack-sdk/agents'; + +const slack = new SlackChannel({ + name: 'support-slack', // required for sendTo() routing + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, + + // Listen on one channel, multiple channels, or omit to listen to all + channel: '#support', // single channel (or pass channel ID 'C12345') + // channel: ['#support', '#escalations'], // multiple channels + // channel: null, // listen to every channel the bot is in + + port: 3000, // port for Slack events webhook (default: 3000) + + // Optional allow/block lists for bot users (matched against bot_id B... or user id U...) + allowedBotIds: ['U123ABC'], + blockedBotIds: ['U456DEF'], +}); +``` + +### What it does + +- Starts a plain HTTP server to receive Slack Events API callbacks (built-in, no `@slack/bolt` dependency). +- On startup, runs `auth.test` to determine `botUserId`. This ID is added as an agent alias so `assemblePrompt` can recognise messages addressed to the bot even when mentioned by its platform ID. +- Caches `resolveParticipant()` results and invalidates on `user_change` events. +- Supports thread replies — messages in threads use the thread timestamp as `conversationId`. + +### Slack app setup + +1. Create a Slack app at https://api.slack.com/apps +2. Enable **Event Subscriptions** → set Request URL to `https:///slack/events` +3. Subscribe to bot events: `message.channels`, `message.groups`, `app_mention` +4. Install the app to your workspace +5. Copy **Bot User OAuth Token** → `SLACK_BOT_TOKEN` +6. Copy **Signing Secret** → `SLACK_SIGNING_SECRET` + +--- + +## DiscordChannel + +Connects your agent to Discord servers via the Gateway (WebSocket) API. + +### Install + +```bash +npm install discord.js +``` + +### Configuration + +```typescript +import { DiscordChannel } from '@toolpack-sdk/agents'; + +const discord = new DiscordChannel({ + name: 'discord', + token: process.env.DISCORD_BOT_TOKEN!, + guildId: process.env.DISCORD_GUILD_ID!, + channelId: process.env.DISCORD_CHANNEL_ID!, +}); +``` + +### What it does + +- Uses `discord.js` client with `GatewayIntentBits.Guilds`, `GuildMessages`, `MessageContent`, and `DirectMessages`. +- Normalises Discord messages → `AgentInput` with thread support. +- Sends responses back to the originating channel. + +### Discord bot setup + +1. Create an application at https://discord.com/developers/applications +2. Under **Bot**, generate a token → `DISCORD_BOT_TOKEN` +3. Enable **Message Content Intent** under Privileged Gateway Intents +4. Invite the bot to your server with `bot` + `applications.commands` scopes and `Send Messages` permission +5. Copy the **Server ID** → `DISCORD_GUILD_ID` (right-click server → Copy ID with Developer Mode on) +6. Copy the **Channel ID** → `DISCORD_CHANNEL_ID` + +--- + +## TelegramChannel + +Connects your agent to Telegram via bot polling or webhooks. + +### Install + +```bash +npm install node-telegram-bot-api +``` + +### Configuration + +```typescript +import { TelegramChannel } from '@toolpack-sdk/agents'; + +const telegram = new TelegramChannel({ + name: 'telegram', + token: process.env.TELEGRAM_BOT_TOKEN!, + + // Optional: use webhook instead of polling + // webhookUrl: 'https://your-server.com/telegram/webhook', +}); +``` + +### What it does + +- On startup, calls `getMe` to populate `botUserId` and `botUsername`. +- Supports both polling (development) and webhook (production) modes. +- Sends text messages via the Telegram Bot API. + +### Telegram bot setup + +1. Message `@BotFather` on Telegram +2. Run `/newbot` and follow the prompts +3. Copy the token → `TELEGRAM_BOT_TOKEN` + +--- + +## WebhookChannel + +Exposes an HTTP endpoint. Any HTTP POST to the endpoint triggers the agent. + +### Configuration + +```typescript +import { WebhookChannel } from '@toolpack-sdk/agents'; + +const webhook = new WebhookChannel({ + name: 'api-webhook', + path: '/api/agent', // HTTP path + port: 4000, // HTTP port (default: 3000) +}); +``` + +### Request format + +Send a POST request with JSON body: + +```json +{ + "message": "Summarise the quarterly report", + "conversationId": "session-abc", + "context": { "userId": "user-123" } +} +``` + +The channel responds synchronously — the HTTP response body is the agent's output. + +### Response format + +```json +{ + "output": "The quarterly report shows...", + "metadata": { "conversationId": "session-abc" } +} +``` + +--- + +## ScheduledChannel + +Triggers an agent on a cron schedule. + +### Configuration + +```typescript +import { ScheduledChannel } from '@toolpack-sdk/agents'; + +const daily = new ScheduledChannel({ + name: 'daily-report', + cron: '0 9 * * 1-5', // 9am Monday–Friday + message: 'Generate the daily standup summary', + intent: 'daily_summary', // optional intent hint + notify: 'webhook:https://hooks.example.com/daily', +}); +``` + +`cron` accepts standard 5-field cron syntax. The expression is validated on construction — an invalid expression throws immediately. + +### `notify` targets + +| Prefix | Behaviour | +|---|---| +| `webhook:` | POSTs `{ output, metadata, timestamp }` as JSON to the URL | + +For routing output to a Slack or other channel, attach both channels to the same agent and use `sendTo()` from `invokeAgent()`: + +```typescript +agent.channels = [ + new ScheduledChannel({ name: 'daily', cron: '0 9 * * 1-5', notify: 'webhook:...' }), + new SlackChannel({ name: 'team-slack', channel: '#standups', token, signingSecret }), +]; + +async invokeAgent(input: AgentInput): Promise { + const report = await this.buildReport(); + await this.sendTo('team-slack', report); // route to Slack + return { output: report }; +} +``` + +This keeps all Slack credentials and thread routing in `SlackChannel` rather than duplicated inside `ScheduledChannel`. + +### isTriggerChannel + +`ScheduledChannel.isTriggerChannel` is `true`. Calling `ask()` from within a scheduled invocation throws because there is no human to answer. + +--- + +## EmailChannel + +Outbound-only email delivery. + +### Install + +```bash +npm install nodemailer +``` + +### Configuration + +```typescript +import { EmailChannel } from '@toolpack-sdk/agents'; + +const email = new EmailChannel({ + name: 'email-alerts', + from: 'agent@example.com', + to: 'team@example.com', + smtp: { + host: 'smtp.example.com', + port: 587, + auth: { + user: process.env.SMTP_USER!, + pass: process.env.SMTP_PASS!, + }, + }, +}); +``` + +`isTriggerChannel = true`. Use this for sending outbound email notifications from your agent. + +For inbound email, set up an email parsing service and deliver the payload to a `WebhookChannel`. + +--- + +## SMSChannel + +Bidirectional SMS via Twilio. + +### Install + +```bash +npm install twilio +``` + +### Configuration + +```typescript +import { SMSChannel } from '@toolpack-sdk/agents'; + +const sms = new SMSChannel({ + name: 'sms', + accountSid: process.env.TWILIO_ACCOUNT_SID!, + authToken: process.env.TWILIO_AUTH_TOKEN!, + from: process.env.TWILIO_FROM_NUMBER!, + + // Optional: recipient number for outbound-only SMS + // to: '+15551234567', + + // Optional: HTTP path to receive inbound SMS (makes channel bidirectional) + // webhookPath: '/sms/webhook', + // port: 3000, // default: 3000 +}); +``` + +`isTriggerChannel` is **dynamic**: `true` when `webhookPath` is not set (outbound-only), `false` when `webhookPath` is set (bidirectional). Sends SMS via the Twilio REST API. + +--- + +## Custom channels + +Implement `ChannelInterface` (or extend `BaseChannel`) to connect any data source: + +```typescript +import { BaseChannel, AgentInput, AgentOutput } from '@toolpack-sdk/agents'; + +class KafkaChannel extends BaseChannel { + readonly isTriggerChannel = false; + + constructor(private config: { topic: string; brokers: string[] }) { + super(); + this.name = 'kafka'; + } + + listen(): void { + // Subscribe to Kafka topic, call this._messageHandler(this.normalize(msg)) + } + + async send(output: AgentOutput): Promise { + // Produce to Kafka response topic + } + + normalize(incoming: unknown): AgentInput { + const msg = incoming as KafkaMessage; + return { + message: msg.value.toString(), + conversationId: msg.key?.toString() ?? `kafka-${Date.now()}`, + participant: { kind: 'user', id: msg.headers?.userId ?? 'unknown' }, + }; + } +} +``` + +`BaseChannel` provides the `onMessage()` registration and `_messageHandler` field — call `this._messageHandler(input)` when a message arrives. diff --git a/packages/toolpack-agents/docs/conversation-history.md b/packages/toolpack-agents/docs/conversation-history.md new file mode 100644 index 0000000..4df3bb5 --- /dev/null +++ b/packages/toolpack-agents/docs/conversation-history.md @@ -0,0 +1,379 @@ +# Conversation History + +`toolpack-agents` provides a built-in conversation history system. Every agent gets an `InMemoryConversationStore` by default. History is written automatically by the capture interceptor and read by `assemblePrompt()` before each LLM call. + +## Contents + +- [How history flows](#how-history-flows) +- [ConversationStore interface](#conversationstore-interface) +- [InMemoryConversationStore](#inmemoryconversationstore) +- [StoredMessage shape](#storedmessage-shape) +- [assemblePrompt()](#assembleprompt) +- [AssemblerOptions reference](#assembleroptions-reference) +- [Addressed-only mode](#addressed-only-mode) +- [Rolling summarisation](#rolling-summarisation) +- [conversation_search tool](#conversation_search-tool) +- [Replacing with a persistent store](#replacing-with-a-persistent-store) + +--- + +## How history flows + +``` +Inbound message (from channel) + │ + ▼ + CaptureInterceptor ────────────────► ConversationStore.append() + (auto-prepended) (inbound turn recorded) + │ + ▼ + invokeAgent() → run() + │ + ├── assemblePrompt() ──────────► ConversationStore.get() + │ (builds LLM context) (loads recent history) + │ + ▼ + toolpack.generate() + │ + ▼ + AgentResult.output + │ + ▼ + CaptureInterceptor ────────────────► ConversationStore.append() + (after agent returns) (outbound turn recorded) + │ + ▼ + channel.send() +``` + +The capture interceptor is **automatically prepended** to the interceptor chain. You do not need to configure it manually. History writes are non-fatal — a failed `append()` never crashes the agent. + +--- + +## ConversationStore interface + +```typescript +interface ConversationStore { + append(message: StoredMessage): Promise; + get(conversationId: string, opts?: GetOptions): Promise; + search(conversationId: string, query: string, opts?: SearchOptions): Promise; + deleteMessages(conversationId: string, ids: string[]): Promise; +} + +interface GetOptions { + scope?: ConversationScope; // 'channel' | 'dm' | 'thread' + sinceTimestamp?: string; // ISO 8601 — only return messages after this timestamp + limit?: number; + participantIds?: string[]; // filter to messages from these participant IDs +} + +interface SearchOptions { + limit?: number; // default: 10 + tokenCap?: number; // max tokens across results (default: 2000) +} +``` + +--- + +## InMemoryConversationStore + +The default store. Keeps all messages in process memory. + +```typescript +import { InMemoryConversationStore } from '@toolpack-sdk/agents'; + +const store = new InMemoryConversationStore({ + maxConversations: 500, // max distinct conversations kept (default: 500) + maxMessagesPerConversation: 500, // max messages per conversation (default: 500) +}); +``` + +Assign it explicitly to control the capacity: + +```typescript +class MyAgent extends BaseAgent { + name = 'my-agent'; + description = '...'; + mode = 'chat'; + + conversationHistory = new InMemoryConversationStore({ maxMessagesPerConversation: 200 }); +} +``` + +**For production deployments** replace with a database-backed implementation. See [Replacing with a persistent store](#replacing-with-a-persistent-store). + +--- + +## StoredMessage shape + +```typescript +interface StoredMessage { + id: string; // UUID + conversationId: string; // thread/session identifier + participant: Participant; // who sent this + content: string; // message text + timestamp: string; // ISO 8601 + scope: ConversationScope; // 'channel' | 'dm' | 'thread' + metadata?: { + channelType?: string; // channel platform (e.g. 'slack', 'discord') + channelName?: string; // channel name or identifier + channelId?: string; // channel platform ID + threadId?: string; // thread/parent message ID + messageId?: string; // platform-specific message ID + mentions?: string[]; // agent IDs mentioned in this message + isSummary?: boolean; // true for rolling-summary placeholder turns + }; +} + +// Participant shape (from toolpack-sdk) +interface Participant { + kind: 'user' | 'agent' | 'system'; + id: string; + displayName?: string; +} + +type ConversationScope = 'channel' | 'dm' | 'thread'; +``` + +### Participant kinds + +| Kind | Who writes it | LLM role in assembled prompt | +|---|---|---| +| `'user'` | Human end-users | `user` (prefixed with display name) | +| `'agent'` | This agent | `assistant` | +| `'agent'` (other) | Peer agents | `user` (prefixed with agent name + `(agent)`) | +| `'system'` | System messages | `system` | + +--- + +## assemblePrompt() + +`assemblePrompt()` is called inside `run()` to build the message array sent to the LLM. It applies filtering, projection, token budgeting, and optional rolling summarisation. + +```typescript +import { assemblePrompt } from '@toolpack-sdk/agents'; + +const assembled = await assemblePrompt( + store, // ConversationStore + conversationId, // string + agentId, // agent's stable name/id (e.g. 'support-agent') + agentName, // display name for the LLM (usually same as agentId) + options, // AssemblerOptions (see below) + summarizer, // optional SummarizerAgent for rolling compression +); + +// assembled.messages is Array<{ role: 'system'|'user'|'assistant', content: string }> +// Pass assembled.messages directly to toolpack.generate() +``` + +### What assemblePrompt does step-by-step + +1. **Load history slice** — calls `store.get(conversationId, { scope, before, after, limit })`. +2. **Filter to relevant turns** (when `addressedOnlyMode = true`) — keeps only turns where: + - The agent authored the turn (`participant.id === agentId`), OR + - The agent was mentioned (`metadata.mentions` contains `agentId` or any of `agentAliases`) +3. **Project messages** — converts `StoredMessage` → `PromptMessage` from the agent's perspective (see table above). +4. **Rolling summarisation** — if turn count exceeds `rollingSummaryThreshold` and a `SummarizerAgent` is provided, older turns are compressed into a summary message. +5. **Token budget** — fills messages from most-recent to oldest until `tokenBudget` is exceeded. Token count is estimated as `characters / 4`. +6. **Return** `AssembledPrompt` with `messages[]` ready to spread into the LLM call. + +--- + +## AssemblerOptions reference + +```typescript +interface AssemblerOptions { + scope?: ConversationScope; // filter by scope (default: all) + tokenBudget?: number; // max tokens for history (default: 3000) + addressedOnlyMode?: boolean; // filter to relevant turns (default: true) + rollingSummaryThreshold?: number; // compress when turns exceed this (default: 40) + timeWindowMinutes?: number; // ignore turns older than N minutes + maxTurnsToLoad?: number; // max turns to fetch from store (default: 100) + agentAliases?: string[]; // platform bot IDs (e.g. Slack botUserId) +} +``` + +### Agent aliases + +Slack and Telegram use platform-specific user IDs for bot mentions (e.g. `U123BOT`) which differ from the agent's `name` string. Set `agentAliases` (or let `BaseAgent` auto-populate from attached channels) so `assemblePrompt` recognises those mentions: + +```typescript +agent.assemblerOptions = { + agentAliases: ['U123BOT', 'telegram-bot-456'], +}; +``` + +`BaseAgent._resolveAssemblerOptions()` auto-collects `botUserId` from channels that expose it (SlackChannel, TelegramChannel) and merges them with any manually specified aliases. + +--- + +## Addressed-only mode + +When `addressedOnlyMode: true` (the default), the assembler keeps only history turns where the agent was directly involved. This: + +- Prevents loading irrelevant multi-party chatter into the context window +- Saves tokens in busy group channels +- Keeps the LLM focused on the relevant conversation thread + +Turn `addressedOnlyMode` off only when you need full channel history — for example, a monitoring agent that analyses all traffic: + +```typescript +agent.assemblerOptions = { addressedOnlyMode: false }; +``` + +--- + +## Rolling summarisation + +When history is long, the assembler can compress older turns into a summary rather than truncating them. Provide a `SummarizerAgent` to enable this: + +```typescript +import { SummarizerAgent } from '@toolpack-sdk/agents'; + +// Create a dedicated summarizer +const summarizer = new SummarizerAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +await summarizer._ensureToolpack(); + +// Pass it to assemblePrompt (run() does not support this yet — call assemblePrompt manually) +const assembled = await assemblePrompt( + store, conversationId, agent.name, agent.name, + { rollingSummaryThreshold: 30, tokenBudget: 3000 }, + summarizer, +); +``` + +When `rollingSummaryThreshold` is exceeded, `SummarizerAgent` receives the oldest turns and returns a compact summary. The summary is inserted as a `system` message before the recent turns. + +See [capabilities.md](capabilities.md) for the full `SummarizerAgent` API. + +--- + +## conversation_search tool + +`run()` automatically exposes a `conversation_search` tool to the LLM when a `conversationId` is active. The LLM can invoke it to retrieve specific past turns beyond the assembled context window. + +The tool is defined as: + +``` +name: conversation_search +parameters: + query: string (keywords or phrases to search for) + limit: number (max results, default 5) +``` + +**Security note**: The tool uses a closure-captured `conversationId`. The LLM cannot supply or override the conversation ID, which prevents adversarial prompts from accessing other users' history. + +--- + +## Replacing with a persistent store + +For production, replace `InMemoryConversationStore` with a database-backed implementation. Implement the `ConversationStore` interface: + +```typescript +import { ConversationStore, StoredMessage, GetOptions, SearchOptions } from '@toolpack-sdk/agents'; +import Database from 'better-sqlite3'; + +class SQLiteConversationStore implements ConversationStore { + private db: Database.Database; + + constructor(path: string) { + this.db = new Database(path); + this.db.exec(` + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL, + participant_kind TEXT NOT NULL, + participant_id TEXT NOT NULL, + participant_display_name TEXT, + content TEXT NOT NULL, + timestamp TEXT NOT NULL, + scope TEXT NOT NULL, + metadata TEXT + ); + CREATE INDEX IF NOT EXISTS idx_conv ON messages(conversation_id); + `); + } + + async append(message: StoredMessage): Promise { + this.db.prepare(` + INSERT OR IGNORE INTO messages VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + message.id, + message.conversationId, + message.participant.kind, + message.participant.id, + message.participant.displayName ?? null, + message.content, + message.timestamp, + message.scope, + message.metadata ? JSON.stringify(message.metadata) : null, + ); + } + + async get(conversationId: string, opts: GetOptions = {}): Promise { + const rows = this.db.prepare( + `SELECT * FROM messages WHERE conversation_id = ? + ${opts.sinceTimestamp ? 'AND timestamp > ?' : ''} + ORDER BY timestamp ASC LIMIT ?` + ).all( + ...[conversationId, opts.sinceTimestamp, opts.limit ?? 100].filter(Boolean), + ); + return rows.map(this.toStoredMessage); + } + + async search(conversationId: string, query: string, opts: SearchOptions = {}): Promise { + const rows = this.db.prepare( + `SELECT * FROM messages WHERE conversation_id = ? AND content LIKE ? LIMIT ?` + ).all(conversationId, `%${query}%`, opts.limit ?? 10); + return rows.map(this.toStoredMessage); + } + + async deleteMessages(conversationId: string, ids: string[]): Promise { + const placeholders = ids.map(() => '?').join(', '); + this.db.prepare( + `DELETE FROM messages WHERE conversation_id = ? AND id IN (${placeholders})` + ).run(conversationId, ...ids); + } + + private toStoredMessage(row: Record): StoredMessage { + return { + id: row.id as string, + conversationId: row.conversation_id as string, + participant: { + kind: row.participant_kind as 'user' | 'agent' | 'system', + id: row.participant_id as string, + displayName: row.participant_display_name as string | undefined, + }, + content: row.content as string, + timestamp: row.timestamp as string, + scope: row.scope as 'channel' | 'dm' | 'thread', + metadata: row.metadata ? JSON.parse(row.metadata as string) : undefined, + }; + } +} +``` + +Then assign it to your agent: + +```typescript +class MyAgent extends BaseAgent { + name = 'my-agent'; + description = '...'; + mode = 'chat'; + + conversationHistory = new SQLiteConversationStore('./conversations.db'); +} +``` + +### Sharing a store across agents + +Multiple agents can share the same store. History is scoped by `conversationId`, so agents in the same conversation see each other's messages: + +```typescript +const store = new SQLiteConversationStore('./shared.db'); + +agentA.conversationHistory = store; +agentB.conversationHistory = store; +``` + +This is the foundation for multi-agent conversation continuity. diff --git a/packages/toolpack-agents/docs/examples.md b/packages/toolpack-agents/docs/examples.md new file mode 100644 index 0000000..2fc3f62 --- /dev/null +++ b/packages/toolpack-agents/docs/examples.md @@ -0,0 +1,536 @@ +# Examples — End-to-End Agent Patterns + +## Contents + +- [1. Single agent on Slack](#1-single-agent-on-slack) +- [2. Multi-agent system with delegation](#2-multi-agent-system-with-delegation) +- [3. Scheduled digest with Slack delivery](#3-scheduled-digest-with-slack-delivery) +- [4. Support agent with human-in-the-loop](#4-support-agent-with-human-in-the-loop) +- [5. Research + coding pipeline](#5-research--coding-pipeline) +- [6. Webhook-driven API agent](#6-webhook-driven-api-agent) +- [7. Multi-channel agent (Slack + Telegram)](#7-multi-channel-agent-slack--telegram) + +--- + +## 1. Single agent on Slack + +The simplest deployment: one agent, one channel, no registry. + +```typescript +import { + BaseAgent, AgentInput, AgentResult, + SlackChannel, + createEventDedupInterceptor, + createSelfFilterInterceptor, + createNoiseFilterInterceptor, +} from '@toolpack-sdk/agents'; +import { CHAT_MODE } from 'toolpack-sdk'; + +class AssistantAgent extends BaseAgent { + name = 'assistant'; + description = 'General-purpose assistant'; + mode = { ...CHAT_MODE, systemPrompt: 'You are a helpful assistant. Be concise and clear.' }; + + async invokeAgent(input: AgentInput): Promise { + return this.run(input.message ?? ''); + } +} + +const slack = new SlackChannel({ + name: 'main', + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, + channel: '#general', + port: 3000, +}); + +const agent = new AssistantAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +agent.channels = [slack]; +agent.interceptors = [ + createEventDedupInterceptor(), + createNoiseFilterInterceptor({ denySubtypes: ['bot_message', 'message_changed'] }), + createSelfFilterInterceptor({ + agentId: 'assistant', + getSenderId: (input) => input.context?.userId as string, + }), +]; + +await agent.start(); +console.log('Assistant is listening on Slack #general'); + +// Graceful shutdown +process.on('SIGTERM', () => agent.stop()); +``` + +--- + +## 2. Multi-agent system with delegation + +An orchestrator that delegates specialised tasks to a research agent and a data agent. + +```typescript +import { + BaseAgent, AgentInput, AgentResult, + AgentRegistry, + ResearchAgent, DataAgent, + SlackChannel, + createEventDedupInterceptor, + createSelfFilterInterceptor, + createAddressCheckInterceptor, +} from '@toolpack-sdk/agents'; + +class OrchestratorAgent extends BaseAgent { + name = 'orchestrator'; + description = 'Routes tasks to the right specialist agent'; + mode = 'chat'; + + async invokeAgent(input: AgentInput): Promise { + const message = input.message ?? ''; + + // Classify intent + if (/research|find|search|what is/i.test(message)) { + const result = await this.delegateAndWait('research-agent', { + message, + conversationId: input.conversationId, + }); + return result; + } + + if (/data|analyse|report|csv|database/i.test(message)) { + // Fire-and-forget for long-running analysis + await this.delegate('data-agent', { + message, + conversationId: input.conversationId, + }); + return { output: 'Data analysis started. I will post the results shortly.' }; + } + + return this.run(message); + } +} + +// Shared Slack channel +const slack = new SlackChannel({ + name: 'work-slack', + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, + channel: '#work', +}); + +const commonInterceptors = [ + createEventDedupInterceptor(), + createSelfFilterInterceptor({ + agentId: 'orchestrator', + getSenderId: (input) => input.context?.userId as string, + }), +]; + +// Orchestrator listens on Slack +const orchestrator = new OrchestratorAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +orchestrator.channels = [slack]; +orchestrator.interceptors = [ + ...commonInterceptors, + createAddressCheckInterceptor({ + agentName: 'orchestrator', + getMessageText: (input) => input.message ?? '', + }), +]; + +// Specialist agents — no channels, invoked via delegation only +const researcher = new ResearchAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +const dataAgent = new DataAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); + +const registry = new AgentRegistry([orchestrator, researcher, dataAgent]); +await registry.start(); +``` + +--- + +## 3. Scheduled digest with Slack delivery + +A daily digest that runs on a cron schedule and posts to Slack. + +```typescript +import { + BaseAgent, AgentInput, AgentResult, + AgentRegistry, + ScheduledChannel, SlackChannel, + ResearchAgent, +} from '@toolpack-sdk/agents'; +import { AGENT_MODE } from 'toolpack-sdk'; + +class DigestAgent extends BaseAgent { + name = 'digest-agent'; + description = 'Generates and posts daily digests'; + mode = { ...AGENT_MODE, systemPrompt: 'You compile concise, informative daily news digests.' }; + + async invokeAgent(input: AgentInput): Promise { + // Research today's top news + const news = await this.delegateAndWait('research-agent', { + message: 'Find the top 5 technology news stories from the past 24 hours. Be concise.', + }); + + // Format the digest + const digest = await this.run( + `Format this news into a clean Slack digest:\n\n${news.output}` + ); + + // Post to Slack + await this.sendTo('digest-slack', digest.output); + + return digest; + } +} + +const scheduledChannel = new ScheduledChannel({ + name: 'daily-trigger', + cron: '0 8 * * 1-5', // 8am Monday–Friday + message: 'Generate the daily tech digest', + notify: 'webhook:https://hooks.example.com/ack', // acknowledge trigger +}); + +const slackDelivery = new SlackChannel({ + name: 'digest-slack', + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, + channel: '#daily-digest', +}); + +const digestAgent = new DigestAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +digestAgent.channels = [scheduledChannel, slackDelivery]; + +const researcher = new ResearchAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); + +const registry = new AgentRegistry([digestAgent, researcher]); +await registry.start(); +``` + +--- + +## 4. Support agent with human-in-the-loop + +A customer support agent that asks for confirmation before processing sensitive actions. + +```typescript +import { + BaseAgent, AgentInput, AgentResult, + AgentRegistry, + SlackChannel, + createEventDedupInterceptor, + createRateLimitInterceptor, +} from '@toolpack-sdk/agents'; +import { CHAT_MODE } from 'toolpack-sdk'; + +type SupportIntent = 'refund' | 'cancel' | 'general'; + +class SupportAgent extends BaseAgent { + name = 'support-agent'; + description = 'Customer support with approval workflows'; + mode = { + ...CHAT_MODE, + systemPrompt: 'You are a customer support agent. You help customers with orders, refunds, and issues. Always be empathetic and professional.', + }; + + async invokeAgent(input: AgentInput): Promise { + // Check for pending ask replies first + const pending = this.getPendingAsk(input.conversationId); + if (pending && input.message) { + return this.handlePendingAsk( + pending, + input.message, + async (orderNumber) => { + const action = pending.context.action as string; + if (action === 'refund') { + // Process refund + await this.run(`Process refund for order number: ${orderNumber}`); + return { output: `✅ Refund for order ${orderNumber} has been submitted. You'll receive a confirmation email within 24 hours.` }; + } + if (action === 'cancel') { + await this.run(`Cancel order: ${orderNumber}`); + return { output: `✅ Order ${orderNumber} has been cancelled.` }; + } + return { output: 'Action completed.' }; + }, + async () => ({ + output: '❌ I was unable to complete the action without a valid order number. Please try again and provide your order number.', + }), + ); + } + + // Route by intent + switch (input.intent) { + case 'refund': + return this.ask('To process your refund, I need your order number. What is it?', { + context: { action: 'refund', requestedAt: new Date().toISOString() }, + maxRetries: 3, + expiresIn: 30 * 60 * 1000, // 30 minutes + }); + + case 'cancel': + return this.ask('I can cancel that order. What is the order number you would like to cancel?', { + context: { action: 'cancel' }, + maxRetries: 2, + }); + + default: + return this.run(input.message ?? ''); + } + } +} + +const slack = new SlackChannel({ + name: 'support', + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, + channel: ['#support', '#customer-help'], +}); + +const agent = new SupportAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +agent.channels = [slack]; +agent.interceptors = [ + createEventDedupInterceptor(), + createRateLimitInterceptor({ + getKey: (input) => input.participant?.id ?? input.conversationId ?? 'global', + tokensPerInterval: 30, + interval: 60000, + }), +]; + +const registry = new AgentRegistry([agent]); +await registry.start(); +``` + +--- + +## 5. Research + coding pipeline + +An agent that researches a topic, then generates code based on the findings. + +```typescript +import { + BaseAgent, AgentInput, AgentResult, + AgentRegistry, + ResearchAgent, CodingAgent, + WebhookChannel, +} from '@toolpack-sdk/agents'; + +class ProjectAgent extends BaseAgent { + name = 'project-agent'; + description = 'Researches topics and generates implementation code'; + mode = 'chat'; + + async invokeAgent(input: AgentInput): Promise { + const task = input.message ?? ''; + + // Step 1: Research + const research = await this.delegateAndWait('research-agent', { + message: `Research best practices and common patterns for: ${task}`, + conversationId: input.conversationId, + }); + + // Step 2: Generate implementation + const implementation = await this.delegateAndWait('coding-agent', { + message: `Based on this research, implement the following in TypeScript:\n\n${task}\n\nResearch context:\n${research.output}`, + conversationId: input.conversationId, + }); + + // Step 3: Summarise + const summary = await this.run( + `Summarise what was built:\n\n${implementation.output}`, + undefined, + { conversationId: input.conversationId }, + ); + + return { + output: `## Research\n${research.output}\n\n## Implementation\n${implementation.output}\n\n## Summary\n${summary.output}`, + metadata: { + steps: ['research', 'implementation', 'summary'], + }, + }; + } +} + +const webhook = new WebhookChannel({ + name: 'api', + path: '/api/project', + port: 4000, +}); + +const projectAgent = new ProjectAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +projectAgent.channels = [webhook]; + +const researcher = new ResearchAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +const coder = new CodingAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); + +const registry = new AgentRegistry([projectAgent, researcher, coder]); +await registry.start(); + +// POST http://localhost:4000/api/project +// { "message": "Build a simple in-memory key-value store with TTL support" } +``` + +--- + +## 6. Webhook-driven API agent + +Expose an agent as a stateless HTTP API endpoint. + +```typescript +import { + BaseAgent, AgentInput, AgentResult, + WebhookChannel, + DataAgent, +} from '@toolpack-sdk/agents'; + +class APIAgent extends BaseAgent { + name = 'api-agent'; + description = 'Processes API requests and returns structured responses'; + mode = 'agent'; + + async invokeAgent(input: AgentInput): Promise { + const payload = input.data as { + action: 'query' | 'summarise' | 'analyse'; + content: string; + }; + + switch (payload?.action) { + case 'query': + return this.run(`Answer this query precisely: ${payload.content}`); + + case 'summarise': + return this.run(`Summarise the following text in 3 bullet points:\n\n${payload.content}`); + + case 'analyse': + return this.delegateAndWait('data-agent', { + message: `Analyse this data and provide insights:\n\n${payload.content}`, + conversationId: input.conversationId, + }); + + default: + return this.run(input.message ?? 'Hello'); + } + } +} + +const webhook = new WebhookChannel({ + name: 'api', + path: '/api/v1/agent', + port: 8080, +}); + +const apiAgent = new APIAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +apiAgent.channels = [webhook]; + +const dataAgent = new DataAgent({ apiKey: process.env.ANTHROPIC_API_KEY! }); +// data-agent has no channels — invoked only via delegation + +await new AgentRegistry([apiAgent, dataAgent]).start(); +``` + +Calling the API: + +```bash +curl -X POST http://localhost:8080/api/v1/agent \ + -H "Content-Type: application/json" \ + -d '{ "action": "summarise", "content": "Long text to summarise..." }' +``` + +--- + +## 7. Multi-channel agent (Slack + Telegram) + +One agent listening on multiple channels simultaneously. + +```typescript +import { + BaseAgent, AgentInput, AgentResult, + SlackChannel, TelegramChannel, + createEventDedupInterceptor, + createSelfFilterInterceptor, + createParticipantResolverInterceptor, +} from '@toolpack-sdk/agents'; +import { CHAT_MODE } from 'toolpack-sdk'; + +class MultiChannelAssistant extends BaseAgent { + name = 'multi-assistant'; + description = 'Assistant available on Slack and Telegram'; + mode = { ...CHAT_MODE, systemPrompt: 'You are a helpful assistant available across multiple platforms.' }; + + async invokeAgent(input: AgentInput): Promise { + // input.participant.displayName is resolved for both Slack and Telegram + const userName = input.participant?.displayName ?? 'there'; + const channel = input.context?.channel ?? 'unknown'; + + const result = await this.run( + input.message ?? '', + undefined, + { conversationId: input.conversationId }, + ); + + return { + output: result.output, + metadata: { respondedTo: userName, via: channel }, + }; + } +} + +const slack = new SlackChannel({ + name: 'slack', + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, + channel: '#general', +}); + +const telegram = new TelegramChannel({ + name: 'telegram', + token: process.env.TELEGRAM_BOT_TOKEN!, +}); + +const agent = new MultiChannelAssistant({ apiKey: process.env.ANTHROPIC_API_KEY! }); +agent.channels = [slack, telegram]; +agent.interceptors = [ + createEventDedupInterceptor(), + createSelfFilterInterceptor({ + agentId: 'multi-assistant', + getSenderId: (input) => input.context?.userId as string, + }), + createParticipantResolverInterceptor(), // resolves display names for both channels +]; + +// Single start() — agent listens on both platforms simultaneously +await agent.start(); +console.log('Assistant listening on Slack and Telegram'); +``` + +--- + +## Environment variable reference + +Most examples rely on these environment variables: + +```bash +# Anthropic API +ANTHROPIC_API_KEY=sk-ant-... + +# Slack +SLACK_BOT_TOKEN=xoxb-... +SLACK_SIGNING_SECRET=... + +# Discord +DISCORD_BOT_TOKEN=... +DISCORD_GUILD_ID=... +DISCORD_CHANNEL_ID=... + +# Telegram +TELEGRAM_BOT_TOKEN=... + +# Twilio SMS +TWILIO_ACCOUNT_SID=AC... +TWILIO_AUTH_TOKEN=... +TWILIO_FROM_NUMBER=+1... + +# Email +SMTP_HOST=smtp.example.com +SMTP_USER=agent@example.com +SMTP_PASS=... +``` diff --git a/packages/toolpack-agents/docs/human-in-the-loop.md b/packages/toolpack-agents/docs/human-in-the-loop.md new file mode 100644 index 0000000..c21bd5e --- /dev/null +++ b/packages/toolpack-agents/docs/human-in-the-loop.md @@ -0,0 +1,292 @@ +# Human-in-the-Loop — ask() and Pending Asks + +The `ask()` pattern lets an agent pause mid-execution, send a question to a human over a channel, and resume when the human replies. This is useful for confirmation steps, clarification requests, or approval gates. + +## Contents + +- [How it works](#how-it-works) +- [ask()](#ask) +- [PendingAsk shape](#pendingask-shape) +- [getPendingAsk()](#getpendingask) +- [handlePendingAsk()](#handlependingask) +- [evaluateAnswer()](#evaluateanswer) +- [resolvePendingAsk()](#resolvependingask) +- [Constraints](#constraints) +- [Full example](#full-example) + +--- + +## How it works + +``` +User message arrives + │ + ▼ + invokeAgent() + │ + ├─ check for pending ask ─────────────────────────────┐ + │ (no pending ask) │ + │ (pending ask exists) + ▼ │ + do some work... ▼ + │ handlePendingAsk() + ▼ │ + this.ask('What is your order number?') ├─ evaluateAnswer() + │ │ │ + ▼ │ (sufficient) + registry.addPendingAsk(...) │ │ + this.sendTo(channelName, question) │ ▼ + │ │ continue with answer + ▼ │ + returns { metadata: { waitingForHuman: true } } │ (insufficient + retries left) + │ │ + │ ▼ +Human replies (new message in same conversation) │ ask again with clarification + │ │ + ▼ (retries exhausted) + invokeAgent() │ + │ ▼ + ├─ getPendingAsk() ─────────────► skip step with message + │ (pending ask found) + ▼ + handlePendingAsk(pending, reply, onSufficient) +``` + +--- + +## ask() + +`ask()` is a protected method on `BaseAgent`. It: + +1. Creates a `PendingAsk` record in the registry. +2. Sends the question to the triggering channel via `this.sendTo()`. +3. Returns immediately with `{ metadata: { waitingForHuman: true, askId } }`. + +The agent is **not** suspended in a literal async sense — execution continues and the current invocation returns. When the human replies, it arrives as a new message to `invokeAgent()`. + +```typescript +protected async ask( + question: string, + options?: { + context?: Record; // developer state to persist alongside the ask + maxRetries?: number; // max re-ask attempts (default: 2) + expiresIn?: number; // ms until ask expires (default: never) + }, +): Promise +``` + +```typescript +// Inside invokeAgent(): +const result = await this.ask('What is your order number?', { + context: { intent: 'refund', productId: '123' }, + maxRetries: 2, + expiresIn: 10 * 60 * 1000, // 10 minutes +}); +// result.metadata.waitingForHuman === true +return result; +``` + +--- + +## PendingAsk shape + +```typescript +interface PendingAsk { + id: string; // UUID + conversationId: string; // ties ask to the thread + agentName: string; // agent that created the ask + question: string; // the question sent to the human + context: Record; // developer-stored state + status: 'pending' | 'answered' | 'expired'; + answer?: string; // human's answer (when answered) + retries: number; // current retry count + maxRetries: number; + askedAt: Date; + expiresAt?: Date; + channelName: string; // channel for sending follow-up questions +} +``` + +--- + +## getPendingAsk() + +Check whether a conversation has an outstanding ask. Call this at the **start** of `invokeAgent()` to detect incoming replies. + +```typescript +protected getPendingAsk(conversationId?: string): PendingAsk | null +``` + +```typescript +async invokeAgent(input: AgentInput): Promise { + // Check for pending ask first + const pending = this.getPendingAsk(input.conversationId); + if (pending && input.message) { + return this.handlePendingAsk( + pending, + input.message, + (answer) => this.processWithAnswer(answer, pending.context), + ); + } + + // Normal flow + return this.run(input.message ?? ''); +} +``` + +--- + +## handlePendingAsk() + +`handlePendingAsk()` handles the complete retry/resolution lifecycle for a pending ask. + +```typescript +protected async handlePendingAsk( + pending: PendingAsk, + reply: string, + onSufficient: (answer: string) => Promise | AgentResult, + onInsufficient?: () => Promise | AgentResult, +): Promise +``` + +**What it does:** + +1. Calls `evaluateAnswer(pending.question, reply)` to check if the reply is sufficient. +2. **Sufficient** — calls `resolvePendingAsk(pending.id, reply)` and then calls `onSufficient(reply)`. +3. **Insufficient, retries left** — increments retry count, calls `ask()` again with a clarification prompt. +4. **Insufficient, retries exhausted** — resolves with `'__insufficient__'`, sends "skipping" message, calls `onInsufficient()` if provided; otherwise returns `{ output: 'Step skipped due to insufficient input.' }`. + +```typescript +return this.handlePendingAsk( + pending, + input.message!, + async (answer) => { + // Happy path — process the confirmed order number + const order = await this.lookupOrder(answer); + return { output: `Order ${answer} found: ${order.status}` }; + }, + async () => { + // Give up gracefully + return { output: 'Unable to process without an order number. Please start over.' }; + }, +); +``` + +--- + +## evaluateAnswer() + +Validates whether a reply sufficiently addresses a question. Used internally by `handlePendingAsk()`. + +```typescript +protected async evaluateAnswer( + question: string, + answer: string, + options?: { + simpleValidation?: (answer: string) => boolean; + }, +): Promise +``` + +- If `simpleValidation` is provided, uses it directly (no LLM call). +- Otherwise, uses `this.run()` to ask the LLM: `"Is this answer sufficient? Reply ONLY 'yes' or 'no'."`. + +For most cases, `simpleValidation` is preferable to avoid the overhead of an extra LLM call: + +```typescript +await this.evaluateAnswer('What is your order number?', reply, { + simpleValidation: (a) => /^\d{5,10}$/.test(a.trim()), +}); +``` + +--- + +## resolvePendingAsk() + +Mark a pending ask as answered with the human's reply. + +```typescript +protected async resolvePendingAsk(id: string, answer: string): Promise +``` + +Call this when you decide to accept the answer (even if not using `handlePendingAsk`): + +```typescript +await this.resolvePendingAsk(pending.id, reply); +``` + +--- + +## Constraints + +**Cannot use `ask()` from trigger channels** + +`ScheduledChannel` and `EmailChannel` have `isTriggerChannel = true`. Calling `ask()` inside a scheduled trigger throws: + +``` +AgentError: this.ask() called from a trigger channel (ScheduledChannel). +Trigger channels have no human recipient. +``` + +**Requires AgentRegistry** + +`ask()` uses `this._registry` to store the pending ask and `this._triggeringChannel` to route the question. Both are set by `AgentRegistry.start()`. Calling `ask()` on a standalone agent (not in a registry) throws: + +``` +AgentError: Agent not registered - cannot use ask() +``` + +**Conversation ID required** + +`ask()` requires a `conversationId` so it can route the human's reply back to the correct pending ask. Messages without a `conversationId` are rejected before reaching `invokeAgent()`. + +--- + +## Full example + +```typescript +type SupportIntent = 'refund' | 'general'; + +class SupportAgent extends BaseAgent { + name = 'support-agent'; + description = 'Customer support with confirmation flow'; + mode = 'chat'; + systemPrompt = 'You are a helpful customer support agent.'; + + async invokeAgent(input: AgentInput): Promise { + // 1. Handle replies to pending asks + const pending = this.getPendingAsk(input.conversationId); + if (pending && input.message) { + return this.handlePendingAsk( + pending, + input.message, + async (orderNumber) => { + const refundResult = await this.processRefund(orderNumber, pending.context); + return { output: `Refund for order ${orderNumber} has been processed: ${refundResult}` }; + }, + async () => ({ + output: 'Unable to process the refund without a valid order number.', + }), + ); + } + + // 2. Route by intent + if (input.intent === 'refund') { + // Ask for confirmation before proceeding + return this.ask('Please provide your order number to process the refund.', { + context: { intent: 'refund', userId: input.participant?.id }, + maxRetries: 3, + expiresIn: 15 * 60 * 1000, // 15 minutes + }); + } + + // 3. General queries + return this.run(input.message ?? ''); + } + + private async processRefund(orderNumber: string, context: Record): Promise { + // ... refund logic + return 'approved'; + } +} +``` diff --git a/packages/toolpack-agents/docs/interceptors.md b/packages/toolpack-agents/docs/interceptors.md new file mode 100644 index 0000000..960c599 --- /dev/null +++ b/packages/toolpack-agents/docs/interceptors.md @@ -0,0 +1,541 @@ +# Interceptors — Composable Middleware + +Interceptors are middleware functions that run before `invokeAgent()` is called. They can modify the input, skip processing entirely, delegate to another agent, or short-circuit with a response. The system is inspired by Koa-style middleware with a `next()` function. + +## Contents + +- [Interceptor type](#interceptor-type) +- [InterceptorContext](#interceptorcontext) +- [SKIP_SENTINEL](#skip_sentinel) +- [Composing and executing chains](#composing-and-executing-chains) +- [Automatic capture interceptor](#automatic-capture-interceptor) +- [Built-in interceptors](#built-in-interceptors) + - [createEventDedupInterceptor](#createeventdedupinterceptor) + - [createNoiseFilterInterceptor](#createnoisefilterinterceptor) + - [createSelfFilterInterceptor](#createselffilterinterceptor) + - [createRateLimitInterceptor](#createratelimitinterceptor) + - [createParticipantResolverInterceptor](#createparticipantresolverinterceptor) + - [createCaptureInterceptor](#createcaptureinterceptor) + - [createAddressCheckInterceptor](#createaddresscheckinterceptor) + - [createIntentClassifierInterceptor](#createintentclassifierinterceptor) + - [createDepthGuardInterceptor](#createdepthguardinterceptor) + - [createTracerInterceptor](#createtracerinterceptor) +- [Writing a custom interceptor](#writing-a-custom-interceptor) + +--- + +## Interceptor type + +```typescript +type Interceptor = ( + input: AgentInput, + ctx: InterceptorContext, + next: NextFunction, +) => Promise; + +type NextFunction = (input?: AgentInput) => Promise; +type InterceptorResult = AgentResult | typeof SKIP_SENTINEL; +``` + +An interceptor either: +- **Calls `next(input?)`** to pass control to the next interceptor (or ultimately `invokeAgent`). +- **Returns `ctx.skip()`** (`SKIP_SENTINEL`) to drop the message entirely — no response sent. +- **Returns an `AgentResult`** directly to short-circuit `invokeAgent` and send that result as the response. + +--- + +## InterceptorContext + +```typescript +interface InterceptorContext { + agent: AgentInstance; + channel: ChannelInterface; + registry: IAgentRegistry | null; + invocationDepth: number; + + // Delegate to another agent and wait for result + delegateAndWait(agentName: string, input: Partial): Promise; + + // Return this to skip processing + skip(): typeof SKIP_SENTINEL; + + // Structured logger (provided by chain infrastructure, not always present) + logger?: { + debug(msg: string, meta?: Record): void; + info(msg: string, meta?: Record): void; + warn(msg: string, meta?: Record): void; + error(msg: string, meta?: Record): void; + }; +} +``` + +--- + +## SKIP_SENTINEL + +`SKIP_SENTINEL` is a unique symbol. When an interceptor returns it, the framework: +1. Does not call `invokeAgent()`. +2. Does not send anything to the channel. +3. The message is silently dropped. + +Use it to filter out noise, duplicates, or messages not addressed to this agent. + +```typescript +import { isSkipSentinel, skip } from '@toolpack-sdk/agents'; + +const myInterceptor: Interceptor = async (input, ctx, next) => { + if (shouldIgnore(input)) { + return ctx.skip(); // or return skip() + } + return next(input); +}; +``` + +--- + +## Composing and executing chains + +`BaseAgent` handles chain composition internally. If you need to test or invoke a chain manually: + +```typescript +import { composeChain, executeChain } from '@toolpack-sdk/agents'; + +const chain = composeChain( + interceptors, // Interceptor[] + agent, // AgentInstance + channel, // ChannelInterface + registry, // IAgentRegistry | null + { maxInvocationDepth: 5 }, +); + +const result = await executeChain(chain, input); +// result is null when SKIP_SENTINEL, otherwise AgentResult +``` + +--- + +## Automatic capture interceptor + +`BaseAgent._getEffectiveInterceptors()` **always prepends** a `createCaptureInterceptor` to the chain, unless one is already present (detected via `CAPTURE_INTERCEPTOR_MARKER`). This means: + +- You do **not** need to add `createCaptureInterceptor` manually. +- Every inbound message and every agent reply is recorded automatically. +- If you want custom capture behaviour, add your own `createCaptureInterceptor` — the auto-prepend will see the marker and skip adding a second one. + +--- + +## Built-in interceptors + +### createEventDedupInterceptor + +Drops duplicate events based on an event ID extracted from `input.context?.eventId`. Prevents Slack/Telegram delivery retries from triggering the agent multiple times. + +```typescript +import { createEventDedupInterceptor } from '@toolpack-sdk/agents'; + +export interface EventDedupConfig { + maxCacheSize?: number; // LRU cache size (default: 1000) + getEventId?: (input: AgentInput) => string | undefined; // custom ID extractor + onDuplicate?: (eventId: string, input: AgentInput) => void; // callback on duplicate +} + +agent.interceptors = [ + createEventDedupInterceptor({ + maxCacheSize: 500, + getEventId: (input) => input.context?.slackEventId as string, + }), +]; +``` + +The default `getEventId` reads `input.context?.eventId`. If your channel stores the platform event ID elsewhere, supply a custom extractor. + +--- + +### createNoiseFilterInterceptor + +Drops messages by subtype. Useful for silently ignoring message edits, deletions, and other noise events. + +```typescript +import { createNoiseFilterInterceptor } from '@toolpack-sdk/agents'; + +export interface NoiseFilterConfig { + denySubtypes: string[]; // required — list of subtypes to drop + getSubtype?: (input: AgentInput) => string | undefined; // custom subtype extractor + onFiltered?: (subtype: string, input: AgentInput) => void; // callback when filtered +} + +agent.interceptors = [ + createNoiseFilterInterceptor({ + denySubtypes: ['message_changed', 'message_deleted', 'bot_message'], + }), +]; +``` + +The default `getSubtype` reads `input.context?.subtype`. `denySubtypes` is **required** (no default). + +--- + +### createSelfFilterInterceptor + +Prevents the agent from responding to its own messages — stops feedback loops. + +```typescript +import { createSelfFilterInterceptor } from '@toolpack-sdk/agents'; + +export interface SelfFilterConfig { + agentId?: string; // optional, defaults to ctx.agent.name + getSenderId: (input: AgentInput) => string | undefined; // required — extract sender ID + onSelfMessage?: (senderId: string, input: AgentInput) => void; +} + +agent.interceptors = [ + createSelfFilterInterceptor({ + agentId: 'U123BOT', // Slack botUserId + getSenderId: (input) => input.context?.senderId as string, + }), +]; +``` + +`getSenderId` is **required** — you must tell the interceptor how to extract the sender from your channel's context. `agentId` is optional and defaults to `ctx.agent.name` (the agent's `name` string). + +--- + +### createRateLimitInterceptor + +Token-bucket rate limiter per entity. Each key gets its own bucket; `getKey` is **required**. + +```typescript +import { createRateLimitInterceptor } from '@toolpack-sdk/agents'; + +export interface RateLimitConfig { + getKey: (input: AgentInput) => string; // required — bucket key (e.g. user ID) + tokensPerInterval?: number; // bucket refill & capacity (default: 10) + interval?: number; // refill interval in ms (default: 60000) + maxBuckets?: number; // LRU cache size (default: 1000) + onExceeded?: 'skip' | 'reject'; // 'skip' silently drops; 'reject' throws (default: 'skip') + onRateLimited?: (key: string, input: AgentInput) => void; +} + +agent.interceptors = [ + createRateLimitInterceptor({ + getKey: (input) => input.participant?.id ?? input.conversationId ?? 'global', + tokensPerInterval: 5, // 5 messages per minute per user + interval: 60000, + }), +]; +``` + +Note: there is no `requestsPerMinute` shorthand — use `tokensPerInterval` + `interval` together. + +--- + +### createParticipantResolverInterceptor + +Enriches `input.participant` by calling the channel's `resolveParticipant()` or a custom resolver function. + +```typescript +import { createParticipantResolverInterceptor } from '@toolpack-sdk/agents'; + +export interface ParticipantResolverConfig { + // Optional: explicit resolver; if omitted uses channel.resolveParticipant() + resolveParticipant?: (input: AgentInput) => Participant | undefined | Promise; + // Called after successful resolution (for logging/metrics) + onResolved?: (input: AgentInput, participant: Participant) => void; +} + +agent.interceptors = [ + createParticipantResolverInterceptor(), // auto-uses channel.resolveParticipant() + + // or with a custom resolver: + createParticipantResolverInterceptor({ + resolveParticipant: async (input) => ({ + kind: 'user', + id: input.context?.userId as string, + displayName: await fetchDisplayName(input.context?.userId as string), + }), + }), +]; +``` + +Resolution order: (1) `config.resolveParticipant` if provided, (2) `ctx.channel.resolveParticipant()` if the channel implements it, (3) whatever `channel.normalize()` already placed on `input.participant`. Failures in the resolver are non-fatal — the pipeline continues unchanged. + +--- + +### createCaptureInterceptor + +Records inbound messages and outbound replies to the `ConversationStore`. **Auto-prepended** by `BaseAgent` — you rarely need to add this manually. + +```typescript +import { createCaptureInterceptor } from '@toolpack-sdk/agents'; + +export interface CaptureHistoryConfig { + store: ConversationStore; // required + getScope?: (input: AgentInput) => ConversationScope; // default: infers from context.channelType / context.threadId + getMessageId?: (input: AgentInput) => string; // default: context.messageId ?? context.eventId ?? randomUUID() + getMentions?: (input: AgentInput) => string[]; // default: context.mentions ?? [] + onCaptured?: (message: StoredMessage) => void; // callback after write + captureAgentReplies?: boolean; // also write agent replies (default: true) +} + +// Manual usage (usually not needed): +agent.interceptors = [ + createCaptureInterceptor({ + store: agent.conversationHistory, + getScope: (input) => input.context?.channelType === 'im' ? 'dm' : 'channel', + }), +]; +``` + +The interceptor writes the inbound message **before** calling `next()`, and writes the agent's reply **after** `next()` returns. Both writes are non-fatal. Marked with `CAPTURE_INTERCEPTOR_MARKER` to prevent double-registration. + +**Default scope inference**: reads `input.context?.channelType` — `'im'`/`'private'`/`'dm'` → `'dm'`; presence of `context.threadId` → `'thread'`; otherwise → `'channel'`. + +--- + +### createAddressCheckInterceptor + +Classifies whether a message is addressed to this agent using heuristic pattern matching. **Important**: this interceptor enriches the input and always calls `next()`. It does NOT skip on its own — it stores the classification in `input.context._addressCheck` for the `createIntentClassifierInterceptor` to act on. + +```typescript +import { createAddressCheckInterceptor } from '@toolpack-sdk/agents'; + +export type AddressCheckResult = 'direct' | 'indirect' | 'passive' | 'ignore' | 'ambiguous'; + +export interface AddressCheckConfig { + agentName: string; // required — agent's display name + agentId?: string; // optional — platform user/bot ID + getMessageText: (input: AgentInput) => string | undefined; // required — extract message text + isDirectMessage?: (input: AgentInput) => boolean; // DMs are always classified 'direct' + getMentions?: (input: AgentInput) => string[]; // extract @mention IDs + onClassified?: (result: AddressCheckResult, input: AgentInput) => void; +} + +agent.interceptors = [ + createAddressCheckInterceptor({ + agentName: 'support-agent', + agentId: 'U123BOT', + getMessageText: (input) => input.message ?? '', + isDirectMessage: (input) => input.context?.channelType === 'im', + getMentions: (input) => input.context?.mentions as string[] ?? [], + }), +]; +``` + +### Classification heuristics + +| Rule checked | Classification | +|---|---| +| `isDirectMessage(input)` returns true | `'direct'` | +| Message starts with `@agentName` or `@agentId` | `'direct'` | +| Message contains `the/my/our agentName` pattern | `'ambiguous'` | +| Agent name appears only inside code blocks | `'ignore'` | +| Message is a bare URL | `'ignore'` | +| Agent is mentioned alongside other agents | `'indirect'` | +| Agent name is mentioned somewhere | `'ambiguous'` | +| No agent mention found | `'passive'` | + +The classification is written to `input.context._addressCheck`. Pair with `createIntentClassifierInterceptor` (see next) to act on it. + +--- + +### createIntentClassifierInterceptor + +Reads the `_addressCheck` classification set by `createAddressCheckInterceptor` and decides whether to skip or proceed. For `'ambiguous'` and `'indirect'` cases it delegates to an `IntentClassifierAgent` for LLM-based disambiguation. + +```typescript +import { createIntentClassifierInterceptor } from '@toolpack-sdk/agents'; + +export interface IntentClassifierInterceptorConfig { + agentName: string; // required + agentId: string; // required + getMessageText: (input: AgentInput) => string | undefined; // required + getSenderName: (input: AgentInput) => string; // required + getChannelName: (input: AgentInput) => string; // required + classifierAgentName?: string; // default: 'intent-classifier' + isDirectMessage?: (input: AgentInput) => boolean; + getRecentContext?: (input: AgentInput) => Array<{ sender: string; content: string }>; + onClassified?: (classification: IntentClassification, input: AgentInput) => void; +} + +agent.interceptors = [ + // Must come first — writes _addressCheck to context + createAddressCheckInterceptor({ + agentName: 'support-agent', + agentId: 'U123BOT', + getMessageText: (input) => input.message ?? '', + }), + // Reads _addressCheck; skips passive/ignore; calls LLM for ambiguous/indirect + createIntentClassifierInterceptor({ + agentName: 'support-agent', + agentId: 'U123BOT', + getMessageText: (input) => input.message ?? '', + getSenderName: (input) => input.participant?.displayName ?? 'Unknown', + getChannelName: (input) => input.context?.channelName as string ?? 'general', + }), +]; +``` + +### Behaviour table + +| `_addressCheck` value | Action | +|---|---| +| `'direct'` | Proceed immediately (no LLM call) | +| `'ignore'` | Skip | +| `'passive'` | Skip | +| `'ambiguous'` | Call `IntentClassifierAgent` → proceed if `'direct'`, skip otherwise | +| `'indirect'` | Call `IntentClassifierAgent` → proceed if `'direct'`, skip otherwise | +| *(not set / no prior address-check)* | Call `IntentClassifierAgent` | + +If the classifier call fails, the interceptor falls back to allowing the message. + +--- + +### createDepthGuardInterceptor + +Prevents runaway recursion in agent delegation chains. + +```typescript +import { createDepthGuardInterceptor } from '@toolpack-sdk/agents'; + +export interface DepthGuardConfig { + maxDepth?: number; // default: 5 + onDepthExceeded?: (currentDepth: number, maxDepth: number, input: AgentInput) => void; +} + +agent.interceptors = [ + createDepthGuardInterceptor({ maxDepth: 5 }), +]; +``` + +When `invocationDepth > maxDepth`, throws `DepthExceededError`. The actual depth protection primarily lives inside the chain composer's `delegateAndWait` — this interceptor is belt-and-suspenders for future scenarios where delegated calls route through the full interceptor chain. + +--- + +### createTracerInterceptor + +Structured logging of each chain hop for debugging. Uses `ctx.logger` (from chain context) — no custom logger config. + +```typescript +import { createTracerInterceptor } from '@toolpack-sdk/agents'; + +export interface TracerConfig { + level?: 'debug' | 'info'; // log level (default: 'debug') + includeInputData?: boolean; // log full input (default: false) + includeResultOutput?: boolean; // log full result (default: false) + shouldTrace?: (input: AgentInput) => boolean; // filter which inputs to trace +} + +agent.interceptors = [ + createTracerInterceptor({ + level: 'debug', + includeInputData: true, + }), +]; +``` + +Logs entry (before `next()`) and exit (after `next()`) with agent name, channel, depth, conversationId, and duration. To see these logs, wire a logger into the chain context via `composeChain` options. + +--- + +## Writing a custom interceptor + +An interceptor is any async function matching the `Interceptor` type: + +```typescript +import type { Interceptor } from '@toolpack-sdk/agents'; + +const auditInterceptor: Interceptor = async (input, ctx, next) => { + const start = Date.now(); + + auditLog.write({ event: 'message_received', conversationId: input.conversationId }); + + const result = await next(input); + + if (result !== null) { + auditLog.write({ event: 'message_handled', duration: Date.now() - start }); + } + + return result; +}; + +agent.interceptors = [auditInterceptor]; +``` + +### Modifying the input + +Pass a modified `AgentInput` to `next()` to transform it before reaching `invokeAgent`: + +```typescript +const enrichmentInterceptor: Interceptor = async (input, ctx, next) => { + const enriched: AgentInput = { + ...input, + context: { + ...input.context, + userTier: await lookupUserTier(input.participant?.id), + }, + }; + return next(enriched); +}; +``` + +### Short-circuiting + +Return an `AgentResult` directly to bypass `invokeAgent` entirely: + +```typescript +const maintenanceModeInterceptor: Interceptor = async (input, ctx, next) => { + if (maintenanceMode.isActive()) { + return { + output: 'The service is currently undergoing maintenance. Please try again later.', + metadata: { maintenance: true }, + }; + } + return next(input); +}; +``` + +### Recommended interceptor order + +```typescript +agent.interceptors = [ + // 1. Noise/dedup first — cheapest filters, drop junk early + createEventDedupInterceptor(), + createNoiseFilterInterceptor({ denySubtypes: ['message_changed', 'message_deleted'] }), + createSelfFilterInterceptor({ + agentId: 'U123BOT', + getSenderId: (input) => input.context?.userId as string, + }), + + // 2. Rate limiting + createRateLimitInterceptor({ + getKey: (input) => input.participant?.id ?? 'global', + tokensPerInterval: 20, + interval: 60000, + }), + + // 3. Enrichment + createParticipantResolverInterceptor(), + + // 4. Address check (pattern matching — cheap) + createAddressCheckInterceptor({ + agentName: agent.name, + getMessageText: (input) => input.message ?? '', + }), + + // 5. Intent classification (LLM call only for ambiguous cases) + createIntentClassifierInterceptor({ + agentName: agent.name, + agentId: agent.name, + getMessageText: (input) => input.message ?? '', + getSenderName: (input) => input.participant?.displayName ?? 'Unknown', + getChannelName: (input) => input.context?.channelName as string ?? 'general', + }), + + // 6. Safety guard + createDepthGuardInterceptor(), + + // 7. Debug (development only) + // createTracerInterceptor({ level: 'debug' }), +]; +// Note: createCaptureInterceptor is auto-prepended before all of these +``` diff --git a/packages/toolpack-agents/docs/registry.md b/packages/toolpack-agents/docs/registry.md new file mode 100644 index 0000000..90fa71e --- /dev/null +++ b/packages/toolpack-agents/docs/registry.md @@ -0,0 +1,202 @@ +# AgentRegistry — Multi-Agent Coordination + +`AgentRegistry` is the optional coordinator for multi-agent deployments. It wires agents together, manages the channel routing table, and provides the shared transport layer for cross-agent delegation. + +**You do not need `AgentRegistry` for a single-agent deployment** — just call `agent.start()` directly. + +## Contents + +- [When to use AgentRegistry](#when-to-use-agentregistry) +- [Construction](#construction) +- [start() and stop()](#start-and-stop) +- [Channel routing](#channel-routing) +- [Agent lookup](#agent-lookup) +- [Invoking agents programmatically](#invoking-agents-programmatically) +- [Pending asks store](#pending-asks-store) +- [Custom transport](#custom-transport) +- [What start() does internally](#what-start-does-internally) + +--- + +## When to use AgentRegistry + +Use `AgentRegistry` when: + +- You have multiple agents that need to delegate tasks to each other +- You need `sendTo()` across agents — routing output to a channel owned by a different agent +- You want centralised `ask()` / pending-ask resolution +- You want a single `start()` / `stop()` call that manages all agents + +--- + +## Construction + +```typescript +import { AgentRegistry } from '@toolpack-sdk/agents'; + +const registry = new AgentRegistry( + [agentA, agentB, agentC], // array of BaseAgent instances + { + transport: customTransport, // optional: override LocalTransport + }, +); +``` + +Each agent already has its own `channels` and `interceptors` configured. The registry does not own those — it just coordinates lifecycle and routing. + +--- + +## start() and stop() + +```typescript +await registry.start(); + +// ... your application runs ... + +// Graceful shutdown — stops all channels and releases Toolpack instances +// (Not yet implemented as a single method on registry; call agent.stop() per agent) +for (const agent of registry.getAllAgents()) { + await (agent as BaseAgent).stop(); +} +``` + +`registry.start()` performs these steps for each agent in order: + +1. **Initialise Toolpack** — calls `agent._ensureToolpack()` so the API client is ready before channels start. +2. **Wire registry reference** — sets `agent._registry = this` so `sendTo()`, `ask()`, and `delegate()` work. +3. **Register named channels** — scans each agent's `channels` array and adds named channels to the routing table for `sendTo()`. +4. **Start agent** — calls `agent.start()` which binds message handlers to each channel and calls `channel.listen()`. + +--- + +## Channel routing + +Any channel with a `name` property is registered in the routing table and can be targeted by `sendTo()`: + +```typescript +// SlackChannel named 'alerts' +const slackAlerts = new SlackChannel({ + name: 'alerts', // ← this name is registered + token: process.env.SLACK_BOT_TOKEN!, + signingSecret: process.env.SLACK_SIGNING_SECRET!, + channel: '#alerts', +}); + +// From inside any agent in the registry +await this.sendTo('alerts', 'Deployment completed successfully'); +``` + +`sendTo()` on `AgentRegistry` directly: + +```typescript +await registry.sendTo('alerts', { output: 'Server is down', metadata: { severity: 'critical' } }); +``` + +If no channel with that name is registered, `sendTo()` throws. + +--- + +## Agent lookup + +```typescript +// Get a specific agent +const agent = registry.getAgent('research-agent'); + +// Get all agents +const allAgents = registry.getAllAgents(); + +// Get a channel by name +const channel = registry.getChannel('alerts'); +``` + +--- + +## Invoking agents programmatically + +`registry.invoke()` calls an agent's `invokeAgent()` through the transport layer. Used internally by `agent.delegateAndWait()`. + +```typescript +const result = await registry.invoke('research-agent', { + message: 'What is the latest on TSMC?', + conversationId: 'conv-123', +}); + +console.log(result.output); +``` + +--- + +## Pending asks store + +The registry holds an in-memory store for human-in-the-loop questions (`PendingAsk`). Agents interact with this through `ask()`, `getPendingAsk()`, `handlePendingAsk()` — see [human-in-the-loop.md](human-in-the-loop.md). + +Direct registry methods (primarily used internally): + +```typescript +// Add a pending ask +const ask = registry.addPendingAsk({ + conversationId: 'conv-123', + agentName: 'support-agent', + question: 'Can you confirm your order number?', + context: {}, + maxRetries: 2, + channelName: 'support-slack', +}); + +// Check for pending asks +const hasPending = registry.hasPendingAsks('conv-123'); + +// Resolve with answer +await registry.resolvePendingAsk(ask.id, '12345'); + +// Get pending ask for conversation +const pending = registry.getPendingAsk('conv-123'); + +// Increment retries +const newCount = registry.incrementRetries(ask.id); + +// Clean up expired asks (call periodically) +const cleaned = registry.cleanupExpiredAsks(); +``` + +--- + +## Custom transport + +By default the registry uses `LocalTransport` which routes delegation calls in-process. Override with `JsonRpcTransport` for cross-process or network deployments: + +```typescript +import { AgentRegistry, JsonRpcTransport } from '@toolpack-sdk/agents'; + +const registry = new AgentRegistry([agent], { + transport: new JsonRpcTransport({ endpoint: 'http://agent-server:8080' }), +}); +``` + +See [transport.md](transport.md) for details. + +--- + +## What start() does internally + +Sequence diagram for `registry.start()`: + +``` +registry.start() + │ + ├─ for each agent: + │ ├─ agent._ensureToolpack() // init Toolpack client + │ ├─ agent._registry = registry // wire cross-agent features + │ ├─ instances.set(agent.name, agent) + │ └─ for each channel with name: + │ channels.set(channel.name, channel) + │ + └─ for each agent: + └─ agent.start() + ├─ for each channel: + │ ├─ _bindChannel(channel) // attach interceptor chain + │ └─ channel.listen() // begin accepting messages + └─ ... +``` + +The two-pass loop (first wire all registries, then start all agents) ensures that when `agent.start()` triggers the first message, all peer agents and channels are already registered and discoverable via `sendTo()`. diff --git a/packages/toolpack-agents/docs/testing.md b/packages/toolpack-agents/docs/testing.md new file mode 100644 index 0000000..8850a73 --- /dev/null +++ b/packages/toolpack-agents/docs/testing.md @@ -0,0 +1,470 @@ +# Testing Agents + +`@toolpack-sdk/agents` ships testing utilities that let you unit-test agents in complete isolation — no API keys, no live channels, no network calls. + +## Import path + +```typescript +import { createTestAgent, MockChannel, captureEvents, createMockKnowledge } from '@toolpack-sdk/agents/testing'; +``` + +The testing utilities live in the `./testing` sub-path export, not in the main package root. + +## Contents + +- [createTestAgent()](#createtestagent) +- [MockChannel](#mockchannel) +- [MockResponse matching](#mockresponse-matching) +- [captureEvents()](#captureevents) +- [createMockKnowledge()](#createmockknowledge) +- [Testing patterns](#testing-patterns) + +--- + +## createTestAgent() + +The primary testing factory. Creates an agent instance wired to a `MockChannel` and a mock Toolpack that returns scripted responses. + +```typescript +import { createTestAgent } from '@toolpack-sdk/agents/testing'; + +function createTestAgent( + AgentClass: new (options: BaseAgentOptions) => TAgent, + options?: CreateTestAgentOptions, +): TestAgentResult +``` + +### Options + +```typescript +interface CreateTestAgentOptions { + mockResponses?: MockResponse[]; // scripted LLM responses + defaultResponse?: string; // fallback when no trigger matches (default: 'Mock AI response') + provider?: string; // mock provider name + model?: string; // mock model name +} + +interface MockResponse { + trigger: string | RegExp; // matched against user message + response: string; // what the mock LLM returns + usage?: { + prompt_tokens: number; + completion_tokens: number; + total_tokens: number; + }; +} +``` + +### Return value + +```typescript +interface TestAgentResult { + agent: TAgent; // the agent instance + channel: MockChannel; // mock channel wired to the agent + toolpack: Toolpack; // mock toolpack instance + addMockResponse: (response: MockResponse) => void; // add responses after creation +} +``` + +### Example + +```typescript +import { describe, it, expect } from 'vitest'; +import { createTestAgent } from '@toolpack-sdk/agents/testing'; +import { SupportAgent } from './support-agent.js'; + +describe('SupportAgent', () => { + it('handles a refund request', async () => { + const { agent, channel } = createTestAgent(SupportAgent, { + mockResponses: [ + { trigger: 'refund', response: 'Your refund has been approved.' }, + ], + }); + + const result = await agent.invokeAgent({ + message: 'I need a refund for order #12345', + conversationId: 'test-conv-1', + participant: { kind: 'user', id: 'user-1', displayName: 'Alice' }, + }); + + expect(result.output).toBe('Your refund has been approved.'); + }); + + it('returns default response for unmatched messages', async () => { + const { agent } = createTestAgent(SupportAgent, { + defaultResponse: 'How can I help you today?', + }); + + const result = await agent.invokeAgent({ + message: 'Hello', + conversationId: 'test-conv-2', + }); + + expect(result.output).toBe('How can I help you today?'); + }); +}); +``` + +--- + +## MockChannel + +`MockChannel` implements `ChannelInterface` and records all inputs and outputs. Wired automatically by `createTestAgent`, but can also be used standalone. + +```typescript +import { MockChannel } from '@toolpack-sdk/agents/testing'; + +const channel = new MockChannel(); +``` + +### Properties + +```typescript +class MockChannel implements ChannelInterface { + name = 'mock-channel'; + isTriggerChannel = false; + + // Inspection + get inputs(): AgentInput[]; // normalized messages received (inbound) + get outputs(): AgentOutput[]; // messages sent (outbound) + get lastInput(): AgentInput | undefined; + get lastOutput(): AgentOutput | undefined; + get receivedCount(): number; + get sentCount(): number; + get isListening(): boolean; + + // Simulation + async receive(incoming: unknown): Promise; // normalize + invoke handler + async receiveMessage( + message: string, + conversationId?: string, + intent?: string, + context?: Record, + ): Promise; + async send(output: AgentOutput): Promise; // record outbound message + clear(): void; // reset captured inputs/outputs + + // Assertion helpers + assertOutputContains(text: string): void; + assertLastOutput(expected: string): void; + + // ChannelInterface compliance + listen(): void; + stop(): void; + normalize(incoming: unknown): AgentInput; + onMessage(handler: (input: AgentInput) => Promise): void; +} +``` + +### Simulating messages + +`receive()` accepts `unknown` and runs it through `normalize()` — it does **not** take an `AgentInput` directly. Use `receiveMessage()` for the most common case: + +```typescript +// Simple text message +await channel.receiveMessage( + 'What is my account balance?', + 'conv-abc', // conversationId (default: 'test-conversation-1') + 'balance_inquiry', // intent (optional) + { userId: 'user-42' }, // context (optional) +); + +expect(channel.lastOutput?.output).toContain('balance'); +expect(channel.sentCount).toBe(1); +``` + +Or use `receive()` with a raw object (normalized via `normalize()`): + +```typescript +await channel.receive({ + message: 'Check order #789', + conversationId: 'conv-1', + intent: 'order_lookup', +}); +``` + +### Full channel-driven test + +```typescript +it('processes message through full interceptor chain', async () => { + const { agent, channel } = createTestAgent(MyAgent, { + mockResponses: [{ trigger: /order/i, response: 'Order found.' }], + }); + + await agent.start(); // binds channel handlers + interceptor chain + + await channel.receive({ message: 'Check order #789', conversationId: 'conv-1' }); + + expect(channel.outputs).toHaveLength(1); + expect(channel.lastOutput?.output).toBe('Order found.'); + + await agent.stop(); +}); +``` + +### Built-in assertions + +```typescript +channel.assertOutputContains('approved'); // throws if no output contains this text +channel.assertLastOutput('Exact match'); // throws if last output !== expected +``` + +--- + +## MockResponse matching + +The mock toolpack checks responses in order. First match wins. + +- **String trigger**: checks if the user message **contains** the trigger string (case-sensitive). +- **RegExp trigger**: tests the user message against the regex. +- **`defaultResponse`**: returned when no trigger matches. + +```typescript +const { agent } = createTestAgent(MyAgent, { + mockResponses: [ + { trigger: /cancel.*order/i, response: 'Order cancellation initiated.' }, + { trigger: 'cancel', response: 'What would you like to cancel?' }, + { trigger: /refund/i, response: 'Refund request received.' }, + ], + defaultResponse: 'I can help with that.', +}); +``` + +Add responses dynamically: + +```typescript +const { agent, addMockResponse } = createTestAgent(MyAgent); +addMockResponse({ trigger: 'shipping', response: 'Your package ships in 2 days.' }); +``` + +--- + +## captureEvents() + +Captures agent lifecycle events emitted during a test run. Returns a rich `EventCapture` object with assertion helpers. + +```typescript +import { captureEvents } from '@toolpack-sdk/agents/testing'; + +const events = captureEvents(agent); // no options argument + +// ... run agent ... + +events.stop(); // detach listeners +``` + +### EventCapture API + +```typescript +type AgentEventName = 'agent:start' | 'agent:complete' | 'agent:error'; +// Note: 'agent:step' is NOT an event name — only the three above are captured. + +interface CapturedEvent { + name: AgentEventName; + data: unknown; // event payload + timestamp: number; // Date.now() value (number, not Date) +} + +interface EventCapture { + readonly events: CapturedEvent[]; + readonly count: number; + + clear(): void; + stop(): void; // remove listeners + + hasEvent(name: AgentEventName): boolean; + getEvents(name: AgentEventName): CapturedEvent[]; + getFirstEvent(name: AgentEventName): CapturedEvent | undefined; + getLastEvent(name: AgentEventName): CapturedEvent | undefined; + assertEvent(name: AgentEventName): void; // throws if event not found + assertNoEvent(name: AgentEventName): void; // throws if event was found +} +``` + +### Example + +```typescript +it('emits start and complete events', async () => { + const { agent } = createTestAgent(MyAgent, { defaultResponse: 'Done.' }); + const events = captureEvents(agent); + + await agent.invokeAgent({ message: 'Hello', conversationId: 'c1' }); + + events.assertEvent('agent:start'); + events.assertEvent('agent:complete'); + events.assertNoEvent('agent:error'); + + events.stop(); +}); +``` + +### Custom Vitest/Jest matchers + +```typescript +import { registerEventMatchers } from '@toolpack-sdk/agents/testing'; +import { expect } from 'vitest'; + +// In your test setup file: +registerEventMatchers(expect); + +// Then in tests: +expect(events).toContainEvent('agent:start'); +expect(events).not.toContainEvent('agent:error'); +expect(events).toContainEventTimes('agent:complete', 1); +``` + +--- + +## createMockKnowledge() + +Provides an in-memory `Knowledge` instance pre-populated with test data. Useful for testing agents that query a knowledge base without needing a real embedder or vector store. + +```typescript +import { createMockKnowledge, createMockKnowledgeSync } from '@toolpack-sdk/agents/testing'; +``` + +### createMockKnowledge (async) + +Returns a real `Knowledge` instance from `@toolpack-sdk/knowledge` backed by a `MemoryProvider` and a deterministic mock embedder. + +```typescript +interface MockKnowledgeOptions { + initialChunks?: Array<{ + content: string; + metadata?: Record; + }>; + dimensions?: number; // embedding dimensions (default: 384) + description?: string; // tool description exposed to LLM +} + +const knowledge = await createMockKnowledge({ + initialChunks: [ + { content: 'Lead: Acme Corp, score: 85', metadata: { source: 'crm' } }, + { content: 'Lead: TechStart, score: 70', metadata: { source: 'crm' } }, + ], +}); +``` + +### createMockKnowledgeSync (sync) + +Returns a `MockKnowledge` class instance — not a full `Knowledge` object, but suitable for testing agents that use knowledge queries. Supports `query()`, `add()`, `getAllChunks()`, `clear()`, and `toTool()`. + +```typescript +const knowledge = createMockKnowledgeSync({ + initialChunks: [ + { content: 'Refund policy: 30-day no-questions-asked return' }, + ], +}); + +// Use knowledge.toTool() to wire it as a tool into a mock Toolpack +const tool = knowledge.toTool(); // returns a RequestToolDefinition +``` + +Uses simple keyword matching (not semantic similarity) for queries, which is sufficient for most test assertions. + +--- + +## Testing patterns + +### Testing intent routing + +```typescript +it('routes billing intent correctly', async () => { + const { agent } = createTestAgent(SupportAgent, { + mockResponses: [ + { trigger: 'billing', response: 'Here is your billing summary.' }, + ], + }); + + const result = await agent.invokeAgent({ + intent: 'billing', + message: 'Show me my bills', + conversationId: 'c1', + }); + + expect(result.output).toBe('Here is your billing summary.'); +}); +``` + +### Testing delegation + +```typescript +import { AgentRegistry } from '@toolpack-sdk/agents'; +import { createTestAgent } from '@toolpack-sdk/agents/testing'; + +it('delegates to data agent', async () => { + const { agent: mainAgent } = createTestAgent(OrchestratorAgent); + const { agent: dataAgent } = createTestAgent(DataAgent, { + defaultResponse: 'Data analysis complete.', + }); + + const registry = new AgentRegistry([mainAgent, dataAgent]); + await registry.start(); + + const result = await registry.invoke('orchestrator-agent', { + message: 'Analyse sales', + conversationId: 'c1', + }); + + expect(result.output).toContain('complete'); +}); +``` + +### Testing conversation history + +```typescript +it('remembers previous messages', async () => { + const { agent } = createTestAgent(MyAgent, { + mockResponses: [ + { trigger: 'name is Bob', response: 'Nice to meet you, Bob.' }, + { trigger: 'remember', response: 'You told me your name is Bob.' }, + ], + }); + + await agent.invokeAgent({ message: 'My name is Bob', conversationId: 'conv-1' }); + + const result = await agent.invokeAgent({ + message: 'Do you remember my name?', + conversationId: 'conv-1', // same conversation + }); + + expect(result.output).toContain('Bob'); +}); +``` + +### Testing lifecycle hooks + +```typescript +it('calls onComplete after successful run', async () => { + const { agent } = createTestAgent(MyAgent, { defaultResponse: 'Done.' }); + + let completedWith: AgentResult | null = null; + agent.onComplete = async (result) => { completedWith = result; }; + + await agent.invokeAgent({ message: 'test', conversationId: 'c1' }); + + expect(completedWith?.output).toBe('Done.'); +}); +``` + +### Testing error handling + +```typescript +it('emits agent:error on failure', async () => { + const { agent } = createTestAgent(MyAgent); + const events = captureEvents(agent); + + // Override invokeAgent to force an error + const original = agent.invokeAgent.bind(agent); + agent.invokeAgent = async () => { throw new Error('boom'); }; + + try { + await agent.invokeAgent({ message: 'test', conversationId: 'c1' }); + } catch { + // expected + } + + events.assertEvent('agent:error'); + events.stop(); +}); +``` diff --git a/packages/toolpack-agents/docs/transport.md b/packages/toolpack-agents/docs/transport.md new file mode 100644 index 0000000..ea74791 --- /dev/null +++ b/packages/toolpack-agents/docs/transport.md @@ -0,0 +1,203 @@ +# Transport & Delegation + +The transport layer routes agent-to-agent invocations. It sits between `AgentRegistry` and the individual agents, providing a pluggable mechanism for cross-agent communication. + +## Contents + +- [AgentTransport interface](#agenttransport-interface) +- [LocalTransport](#localtransport) +- [JsonRpcTransport](#jsonrpctransport) +- [delegate() — fire-and-forget](#delegate--fire-and-forget) +- [delegateAndWait() — synchronous delegation](#delegateandwait--synchronous-delegation) +- [How delegation preserves history](#how-delegation-preserves-history) +- [Delegation depth guard](#delegation-depth-guard) + +--- + +## AgentTransport interface + +```typescript +interface AgentTransport { + invoke(agentName: string, input: AgentInput): Promise; +} +``` + +The registry uses the transport to route `invoke()` calls. The default transport is `LocalTransport`. + +--- + +## LocalTransport + +In-process delegation. Used automatically when you create an `AgentRegistry` without a transport override. + +```typescript +import { LocalTransport } from '@toolpack-sdk/agents'; + +// Created automatically by AgentRegistry: +const registry = new AgentRegistry([agentA, agentB]); +// registry._transport is a LocalTransport(registry) + +// Or create explicitly: +const transport = new LocalTransport(registry); +``` + +### What it does + +When `transport.invoke(agentName, input)` is called: + +1. Resolves the target agent from the registry by name. +2. Writes the inbound message to the **target agent's** `ConversationStore` as a `kind: 'agent'` participant (the delegating agent's name). +3. Calls `target.invokeAgent(input)` directly (in-process). +4. Writes the target agent's reply to the target's store as the target agent's own turn. +5. Returns the `AgentResult`. + +This means the **target agent has full history** of the delegation exchange, enabling it to use `assemblePrompt()` to understand the conversation context. + +--- + +## JsonRpcTransport + +For distributed deployments where agents run in separate processes or on separate servers. + +```typescript +import { JsonRpcTransport, AgentJsonRpcServer } from '@toolpack-sdk/agents'; + +// Client side (calling agent's process) +const transport = new JsonRpcTransport({ + endpoint: 'http://agent-server:8080/rpc', +}); + +const registry = new AgentRegistry([callerAgent], { transport }); + +// Server side (target agent's process) +const server = new AgentJsonRpcServer({ + registry: targetRegistry, + port: 8080, + path: '/rpc', +}); +await server.start(); +``` + +The JSON-RPC protocol transmits `AgentInput` and returns `AgentResult` over HTTP. + +--- + +## delegate() — fire-and-forget + +`delegate()` is a protected method on `BaseAgent`. It invokes another agent and **does not wait** for the result. Useful for spawning background work. + +```typescript +protected async delegate(agentName: string, input: Partial): Promise +``` + +```typescript +// Inside your agent's invokeAgent(): +async invokeAgent(input: AgentInput): Promise { + // Kick off background analysis — don't wait + await this.delegate('data-agent', { + message: `Analyse sales data for ${input.context?.region}`, + context: { requestedBy: this.name }, + }); + + return { output: 'Analysis started. Results will be available shortly.' }; +} +``` + +**What gets set automatically:** + +- `context.delegatedBy` is set to `this.name` +- `conversationId` defaults to the current conversation's ID (or a new `delegation-` ID if none) + +Errors from the delegated agent are caught and logged but do not propagate to the caller. + +--- + +## delegateAndWait() — synchronous delegation + +`delegateAndWait()` invokes another agent and **waits for the result** before continuing. + +```typescript +protected async delegateAndWait(agentName: string, input: Partial): Promise +``` + +```typescript +async invokeAgent(input: AgentInput): Promise { + // First, get research results + const research = await this.delegateAndWait('research-agent', { + message: `Find the latest news on ${input.message}`, + }); + + // Then, use them to generate a report + const report = await this.run( + `Based on this research: ${research.output}\n\nWrite a concise report.` + ); + + return report; +} +``` + +Both `delegate()` and `delegateAndWait()` require the agent to be registered with an `AgentRegistry`. Calling them on a standalone agent (without a registry) throws: + +``` +AgentError: Agent not registered - cannot use delegate() +``` + +--- + +## How delegation preserves history + +When agent A delegates to agent B: + +``` +Agent A Agent B + │ │ + ├─ delegateAndWait('agent-b') │ + │ │ + │ LocalTransport.invoke() │ + │ ├─ store.append({ │ + │ │ participant: { kind: 'agent', id: 'agent-a' }, + │ │ content: + │ │ }) → written to Agent B's store + │ │ │ + │ └─ agent-b.invokeAgent() │ + │ ├─ assemblePrompt reads history + │ │ (sees agent-a's delegated message) + │ │ + │ └─ returns result + │ ├─ store.append({ │ + │ │ participant: { kind: 'agent', id: 'agent-b' }, + │ │ content: + │ │ }) → written to Agent B's store + │ │ + │ └─ returns AgentResult to Agent A +``` + +Agent B's history reflects the full delegation exchange. If agent B is later invoked again in the same conversation, it will have context about what agent A asked. + +--- + +## Delegation depth guard + +Circular delegation (A → B → A) is caught by `createDepthGuardInterceptor`. The `invocationDepth` counter in `InterceptorContext` increments with each delegation. When it exceeds `maxDepth` (default 5), a `DepthExceededError` is thrown. + +Add `createDepthGuardInterceptor` to your interceptors list for agents that participate in delegation chains: + +```typescript +import { createDepthGuardInterceptor } from '@toolpack-sdk/agents'; + +agent.interceptors = [ + createDepthGuardInterceptor({ maxDepth: 5 }), +]; +``` + +--- + +## Summary: delegate vs delegateAndWait vs sendTo + +| Method | Waits? | Requires registry? | Uses transport? | Target | +|---|---|---|---|---| +| `this.delegate(agentName, input)` | No | Yes | Yes (LocalTransport) | Agent | +| `this.delegateAndWait(agentName, input)` | Yes | Yes | Yes (LocalTransport) | Agent | +| `this.sendTo(channelName, message)` | No | Yes | No | Channel | +| `registry.invoke(agentName, input)` | Yes | — | Yes | Agent | +| `registry.sendTo(channelName, output)` | No | — | No | Channel | diff --git a/packages/toolpack-agents/package.json b/packages/toolpack-agents/package.json new file mode 100644 index 0000000..dbcb638 --- /dev/null +++ b/packages/toolpack-agents/package.json @@ -0,0 +1,117 @@ +{ + "name": "@toolpack-sdk/agents", + "version": "1.4.0", + "description": "Agent layer for the Toolpack SDK - build, compose, and deploy AI agents with a consistent, extensible pattern", + "engines": { + "node": ">=20" + }, + "type": "module", + "main": "dist/index.cjs", + "module": "dist/index.js", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./channels": { + "types": "./dist/channels/index.d.ts", + "import": "./dist/channels/index.js", + "require": "./dist/channels/index.cjs" + }, + "./testing": { + "types": "./dist/testing/index.d.ts", + "import": "./dist/testing/index.js", + "require": "./dist/testing/index.cjs" + }, + "./registry": { + "types": "./dist/registry/index.d.ts", + "import": "./dist/registry/index.js", + "require": "./dist/registry/index.cjs" + }, + "./capabilities": { + "types": "./dist/capabilities/index.d.ts", + "import": "./dist/capabilities/index.js", + "require": "./dist/capabilities/index.cjs" + }, + "./interceptors": { + "types": "./dist/interceptors/index.d.ts", + "import": "./dist/interceptors/index.js", + "require": "./dist/interceptors/index.cjs" + } + }, + "types": "dist/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsup", + "build:dev": "tsup", + "test": "vitest run", + "test:watch": "vitest", + "watch": "tsup --watch", + "lint": "eslint src/**", + "publish:npm": "npm run build && npm run test && npm publish --access public" + }, + "keywords": [ + "ai", + "llm", + "agent", + "ai-agent", + "slack", + "telegram", + "webhook", + "cron", + "scheduler", + "typescript", + "sdk", + "toolpack" + ], + "author": "Sajeer (https://sajeerzeji.com)", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/toolpack-ai/toolpack-sdk.git" + }, + "homepage": "https://toolpacksdk.com", + "bugs": { + "url": "https://github.com/toolpack-ai/toolpack-sdk/issues" + }, + "peerDependencies": { + "@toolpack-sdk/knowledge": "^1.4.0", + "better-sqlite3": "^11.x", + "discord.js": "^14.x", + "nodemailer": "^6.x", + "toolpack-sdk": "^1.4.0", + "twilio": "^5.x" + }, + "peerDependenciesMeta": { + "@toolpack-sdk/knowledge": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "discord.js": { + "optional": true + }, + "nodemailer": { + "optional": true + }, + "twilio": { + "optional": true + } + }, + "devDependencies": { + "@types/node": "^25.3.2", + "@types/nodemailer": "^6.4.23", + "discord.js": "^14.26.2", + "twilio": "^5.13.1", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + }, + "dependencies": { + "cron-parser": "^5.5.0" + } +} diff --git a/packages/toolpack-agents/src/agent/agent-registry.test.ts b/packages/toolpack-agents/src/agent/agent-registry.test.ts new file mode 100644 index 0000000..7faee83 --- /dev/null +++ b/packages/toolpack-agents/src/agent/agent-registry.test.ts @@ -0,0 +1,435 @@ +import { describe, it, expect, vi } from 'vitest'; +import { AgentRegistry } from './agent-registry.js'; +import { BaseAgent } from './base-agent.js'; +import { AgentInput, AgentResult, BaseAgentOptions } from './types.js'; +import { BaseChannel } from '../channels/base-channel.js'; +import type { Toolpack } from 'toolpack-sdk'; +import { CHAT_MODE } from 'toolpack-sdk'; + +// Mock Toolpack +const createMockToolpack = () => { + return { + generate: vi.fn().mockResolvedValue({ + content: 'Mock response', + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }), + setMode: vi.fn(), + registerMode: vi.fn(), + } as unknown as Toolpack; +}; + +// Test agent implementation +class TestAgent extends BaseAgent<'test_intent'> { + name = 'test-agent'; + description = 'A test agent'; + mode = CHAT_MODE; + + constructor(options: BaseAgentOptions) { + super(options); + } + + async invokeAgent(input: AgentInput<'test_intent'>): Promise { + return { + output: `Received: ${input.message}`, + }; + } +} + +// Test channel implementation +class TestChannel extends BaseChannel { + readonly isTriggerChannel = false; + handler?: (input: AgentInput) => Promise; + sent: { output: string; metadata?: Record }[] = []; + + listen(): void {} + + async send(output: { output: string; metadata?: Record }): Promise { + this.sent.push(output as { output: string; metadata?: Record }); + } + + normalize(incoming: unknown): AgentInput { + return { message: String(incoming) }; + } + + onMessage(handler: (input: AgentInput) => Promise): void { + this.handler = handler; + } + + async triggerMessage(input: AgentInput): Promise { + if (this.handler) { + await this.handler(input); + } + } +} + +describe('AgentRegistry', () => { + describe('constructor', () => { + it('should create with empty agents list', () => { + const registry = new AgentRegistry([]); + expect(registry).toBeDefined(); + }); + + it('should create with agent instances', () => { + const mockToolpack = createMockToolpack(); + const channel = new TestChannel(); + const agent = new TestAgent({ toolpack: mockToolpack }); + agent.channels = [channel]; + + const registry = new AgentRegistry([agent]); + expect(registry).toBeDefined(); + }); + }); + + describe('start', () => { + it('should bind message handlers and start channels', async () => { + const mockToolpack = createMockToolpack(); + const channel = new TestChannel(); + const spyListen = vi.spyOn(channel, 'listen'); + + const agent = new TestAgent({ toolpack: mockToolpack }); + agent.channels = [channel]; + + const registry = new AgentRegistry([agent]); + await registry.start(); + + expect(spyListen).toHaveBeenCalled(); + expect(channel.handler).toBeDefined(); + }); + + it('should set agent registry reference', async () => { + const mockToolpack = createMockToolpack(); + const channel = new TestChannel(); + + const agent = new TestAgent({ toolpack: mockToolpack }); + agent.channels = [channel]; + + const registry = new AgentRegistry([agent]); + await registry.start(); + + const retrieved = registry.getAgent('test-agent'); + expect(retrieved).toBeDefined(); + expect(retrieved?._registry).toBe(registry); + }); + + it('should register named channels for sendTo() routing', async () => { + const mockToolpack = createMockToolpack(); + const channel = new TestChannel(); + channel.name = 'test-channel'; + + const agent = new TestAgent({ toolpack: mockToolpack }); + agent.channels = [channel]; + + const registry = new AgentRegistry([agent]); + await registry.start(); + + const retrievedChannel = registry.getChannel('test-channel'); + expect(retrievedChannel).toBe(channel); + }); + }); + + describe('sendTo', () => { + it('should send to named channel', async () => { + const mockToolpack = createMockToolpack(); + const channel = new TestChannel(); + channel.name = 'my-channel'; + + const agent = new TestAgent({ toolpack: mockToolpack }); + agent.channels = [channel]; + + const registry = new AgentRegistry([agent]); + await registry.start(); + + await registry.sendTo('my-channel', { output: 'Hello!' }); + + expect(channel.sent).toHaveLength(1); + expect(channel.sent[0]).toEqual({ output: 'Hello!' }); + }); + + it('should throw for unknown channel', async () => { + const registry = new AgentRegistry([]); + await registry.start(); + + await expect(registry.sendTo('unknown', { output: 'test' })) + .rejects.toThrow('No channel registered with name "unknown"'); + }); + }); + + describe('getAgent', () => { + it('should return agent by name', async () => { + const mockToolpack = createMockToolpack(); + const agent = new TestAgent({ toolpack: mockToolpack }); + + const registry = new AgentRegistry([agent]); + await registry.start(); + + const retrieved = registry.getAgent('test-agent'); + expect(retrieved).toBeDefined(); + expect(retrieved?.name).toBe('test-agent'); + }); + + it('should return undefined for unknown agent', async () => { + const mockToolpack = createMockToolpack(); + const agent = new TestAgent({ toolpack: mockToolpack }); + + const registry = new AgentRegistry([agent]); + await registry.start(); + + expect(registry.getAgent('unknown')).toBeUndefined(); + }); + }); + + describe('getAllAgents', () => { + it('should return all agents', async () => { + const mockToolpack = createMockToolpack(); + + class TestAgent2 extends BaseAgent { + name = 'test-agent-2'; + description = 'Another test agent'; + mode = CHAT_MODE; + + constructor(options: BaseAgentOptions) { + super(options); + } + + async invokeAgent(): Promise { + return { output: 'Test 2' }; + } + } + + const agent1 = new TestAgent({ toolpack: mockToolpack }); + const agent2 = new TestAgent2({ toolpack: mockToolpack }); + + const registry = new AgentRegistry([agent1, agent2]); + await registry.start(); + + const agents = registry.getAllAgents(); + expect(agents).toHaveLength(2); + expect(agents.map(a => a.name)).toContain('test-agent'); + expect(agents.map(a => a.name)).toContain('test-agent-2'); + }); + }); + + describe('stop', () => { + it('should clear agents and channels', async () => { + const mockToolpack = createMockToolpack(); + const agent = new TestAgent({ toolpack: mockToolpack }); + + const registry = new AgentRegistry([agent]); + await registry.start(); + + expect(registry.getAgent('test-agent')).toBeDefined(); + + await registry.stop(); + + expect(registry.getAgent('test-agent')).toBeUndefined(); + }); + + it('should stop channels with stop method', async () => { + const mockToolpack = createMockToolpack(); + + class StoppableChannel extends TestChannel { + stopped = false; + async stop(): Promise { + this.stopped = true; + } + } + + const channel = new StoppableChannel(); + channel.name = 'stoppable'; + + const agent = new TestAgent({ toolpack: mockToolpack }); + agent.channels = [channel]; + + const registry = new AgentRegistry([agent]); + await registry.start(); + await registry.stop(); + + expect(channel.stopped).toBe(true); + }); + }); + + describe('PendingAsksStore', () => { + describe('addPendingAsk', () => { + it('should add a pending ask', () => { + const registry = new AgentRegistry([]); + const ask = registry.addPendingAsk({ + conversationId: 'test-conv', + agentName: 'test-agent', + question: 'What is your name?', + context: {}, + maxRetries: 2, + channelName: 'test-channel', + }); + + expect(ask.id).toBeDefined(); + expect(ask.conversationId).toBe('test-conv'); + expect(ask.question).toBe('What is your name?'); + expect(ask.status).toBe('pending'); + expect(ask.retries).toBe(0); + expect(ask.askedAt).toBeInstanceOf(Date); + }); + + it('should queue multiple asks for same conversation', () => { + const registry = new AgentRegistry([]); + + const ask1 = registry.addPendingAsk({ + conversationId: 'test-conv', + agentName: 'test-agent', + question: 'First question?', + context: {}, + maxRetries: 2, + channelName: 'test-channel', + }); + + const ask2 = registry.addPendingAsk({ + conversationId: 'test-conv', + agentName: 'test-agent', + question: 'Second question?', + context: {}, + maxRetries: 2, + channelName: 'test-channel', + }); + + expect(ask1.id).not.toBe(ask2.id); + }); + }); + + describe('getPendingAsk', () => { + it('should return the first pending ask', () => { + const registry = new AgentRegistry([]); + registry.addPendingAsk({ + conversationId: 'test-conv', + agentName: 'test-agent', + question: 'First question?', + context: {}, + maxRetries: 2, + channelName: 'test-channel', + }); + + const pending = registry.getPendingAsk('test-conv'); + expect(pending?.question).toBe('First question?'); + }); + + it('should return undefined if no pending asks', () => { + const registry = new AgentRegistry([]); + const pending = registry.getPendingAsk('test-conv'); + expect(pending).toBeUndefined(); + }); + }); + + describe('hasPendingAsks', () => { + it('should return true if has pending asks', () => { + const registry = new AgentRegistry([]); + registry.addPendingAsk({ + conversationId: 'test-conv', + agentName: 'test-agent', + question: 'Question?', + context: {}, + maxRetries: 2, + channelName: 'test-channel', + }); + + expect(registry.hasPendingAsks('test-conv')).toBe(true); + }); + + it('should return false if no pending asks', () => { + const registry = new AgentRegistry([]); + expect(registry.hasPendingAsks('test-conv')).toBe(false); + }); + }); + + describe('resolvePendingAsk', () => { + it('should resolve the ask', async () => { + const registry = new AgentRegistry([]); + const ask = registry.addPendingAsk({ + conversationId: 'test-conv', + agentName: 'test-agent', + question: 'Question?', + context: {}, + maxRetries: 2, + channelName: 'test-channel', + }); + + await registry.resolvePendingAsk(ask.id, 'Answer'); + + expect(registry.getPendingAsk('test-conv')).toBeUndefined(); + }); + + it('should auto-send next ask when resolving', async () => { + const registry = new AgentRegistry([]); + const ask1 = registry.addPendingAsk({ + conversationId: 'test-conv', + agentName: 'test-agent', + question: 'First question?', + context: {}, + maxRetries: 2, + channelName: 'test-channel', + }); + + registry.addPendingAsk({ + conversationId: 'test-conv', + agentName: 'test-agent', + question: 'Second question?', + context: {}, + maxRetries: 2, + channelName: 'test-channel', + }); + + expect(registry.getPendingAsk('test-conv')?.question).toBe('First question?'); + + const sendToMock = vi.fn().mockResolvedValue(undefined); + registry.sendTo = sendToMock; + + await registry.resolvePendingAsk(ask1.id, 'Answer 1'); + + expect(sendToMock).toHaveBeenCalledWith('test-channel', { output: 'Second question?' }); + expect(registry.getPendingAsk('test-conv')?.question).toBe('Second question?'); + }); + }); + + describe('incrementRetries', () => { + it('should increment retry count for a pending ask', () => { + const registry = new AgentRegistry([]); + const ask = registry.addPendingAsk({ + conversationId: 'test-conv', + agentName: 'test-agent', + question: 'Question?', + context: {}, + maxRetries: 2, + channelName: 'test-channel', + }); + + expect(ask.retries).toBe(0); + + expect(registry.incrementRetries(ask.id)).toBe(1); + expect(registry.incrementRetries(ask.id)).toBe(2); + }); + + it('should return undefined for non-existent ask', () => { + const registry = new AgentRegistry([]); + expect(registry.incrementRetries('non-existent-id')).toBeUndefined(); + }); + }); + + describe('stop clears pending asks', () => { + it('should clear pending asks on stop', async () => { + const registry = new AgentRegistry([]); + + registry.addPendingAsk({ + conversationId: 'test-conv', + agentName: 'test-agent', + question: 'Question?', + context: {}, + maxRetries: 2, + channelName: 'test-channel', + }); + + expect(registry.hasPendingAsks('test-conv')).toBe(true); + + await registry.stop(); + + expect(registry.hasPendingAsks('test-conv')).toBe(false); + }); + }); + }); +}); diff --git a/packages/toolpack-agents/src/agent/agent-registry.ts b/packages/toolpack-agents/src/agent/agent-registry.ts new file mode 100644 index 0000000..ee56d7e --- /dev/null +++ b/packages/toolpack-agents/src/agent/agent-registry.ts @@ -0,0 +1,257 @@ +import { randomUUID } from 'crypto'; +import type { AgentInput, AgentOutput, AgentResult, IAgentRegistry, ChannelInterface, AgentInstance, PendingAsk } from './types.js'; +import type { BaseAgent } from './base-agent.js'; +import type { AgentTransport, AgentRegistryTransportOptions } from '../transport/types.js'; +import { LocalTransport } from '../transport/local-transport.js'; + +/** + * Optional coordinator for multi-agent deployments. + * + * Accepts a list of agent instances (each carrying its own channels and + * interceptors). On `start()` it wires the registry reference into every agent + * so cross-agent features (sendTo, delegation, ask) work, then delegates + * channel lifecycle to each agent's own `start()` method. + * + * For a single-agent deployment you do not need this class at all — just call + * `agent.start()` directly. + */ +export class AgentRegistry implements IAgentRegistry { + private agentList: BaseAgent[]; + private instances: Map = new Map(); + private channels: Map = new Map(); + + /** Transport for agent-to-agent communication */ + _transport: AgentTransport; + + /** In-memory store for pending human-in-the-loop questions. Stored as Map */ + private pendingAsks: Map = new Map(); + + /** + * @param agents Agent instances to coordinate. Each agent's `channels` and + * `interceptors` are configured on the agent itself. + * @param options Optional transport override. + */ + constructor(agents: BaseAgent[], options?: AgentRegistryTransportOptions) { + this.agentList = agents; + this._transport = options?.transport || new LocalTransport(this); + } + + /** + * Start all agents. + * + * For each agent: + * 1. Ensures the agent's Toolpack instance is ready. + * 2. Sets `agent._registry = this` so cross-agent features are available + * when the agent's channels start processing messages. + * 3. Registers named channels in the registry's routing table for `sendTo()`. + * 4. Calls `agent.start()` which binds message handlers and begins listening. + */ + async start(): Promise { + for (const agent of this.agentList) { + // Initialise toolpack before setting registry so it is ready when the + // first message arrives. + await agent._ensureToolpack(); + + // Wire registry so sendTo(), ask(), and delegate() work inside the agent. + agent._registry = this; + + this.instances.set(agent.name, agent); + + // Register named channels for sendTo() routing. + for (const channel of agent.channels ?? []) { + if (channel.name) { + this.channels.set(channel.name, channel); + } + } + } + + // Start all agents (binds message handlers + begins listening). + for (const agent of this.agentList) { + await agent.start(); + } + } + + /** + * Send output to a named channel. + */ + async sendTo(channelName: string, output: AgentOutput): Promise { + const channel = this.channels.get(channelName); + if (!channel) { + throw new Error(`No channel registered with name "${channelName}"`); + } + await channel.send(output); + } + + /** + * Get a registered agent instance by name. + */ + getAgent(name: string): AgentInstance | undefined { + return this.instances.get(name); + } + + /** + * Get all registered agent instances. + */ + getAllAgents(): AgentInstance[] { + return Array.from(this.instances.values()); + } + + /** + * Get a registered channel by name. + */ + getChannel(name: string): ChannelInterface | undefined { + return this.channels.get(name); + } + + /** + * Invoke an agent by name through the transport layer. + * Used by BaseAgent.delegate() and BaseAgent.delegateAndWait(). + */ + async invoke(agentName: string, input: AgentInput): Promise { + return this._transport.invoke(agentName, input); + } + + /** + * Stop all agents and clean up resources. + */ + async stop(): Promise { + for (const agent of this.agentList) { + await agent.stop(); + } + + this.instances.clear(); + this.channels.clear(); + this.pendingAsks.clear(); + } + + // --- PendingAsksStore Methods --- + + getPendingAsk(conversationId: string): PendingAsk | undefined { + const asks = this.pendingAsks.get(conversationId); + if (!asks || asks.length === 0) { + return undefined; + } + + const now = new Date(); + while (asks.length > 0) { + const front = asks[0]; + if (front.expiresAt && front.expiresAt < now) { + asks.shift(); + } else { + break; + } + } + + if (asks.length === 0) { + this.pendingAsks.delete(conversationId); + return undefined; + } + + return asks[0]; + } + + hasPendingAsks(conversationId: string): boolean { + const asks = this.pendingAsks.get(conversationId); + if (!asks || asks.length === 0) { + return false; + } + + const now = new Date(); + const validAsks = asks.filter(a => !a.expiresAt || a.expiresAt >= now); + + if (validAsks.length === 0) { + this.pendingAsks.delete(conversationId); + return false; + } + + if (validAsks.length !== asks.length) { + this.pendingAsks.set(conversationId, validAsks); + } + + return validAsks.some(a => a.status === 'pending'); + } + + cleanupExpiredAsks(): number { + let removedCount = 0; + const now = new Date(); + + for (const [conversationId, asks] of this.pendingAsks.entries()) { + const validAsks = asks.filter(a => !a.expiresAt || a.expiresAt >= now); + removedCount += asks.length - validAsks.length; + + if (validAsks.length === 0) { + this.pendingAsks.delete(conversationId); + } else if (validAsks.length !== asks.length) { + this.pendingAsks.set(conversationId, validAsks); + } + } + + return removedCount; + } + + addPendingAsk( + ask: Omit + ): PendingAsk { + const pendingAsk: PendingAsk = { + ...ask, + id: randomUUID(), + askedAt: new Date(), + retries: 0, + status: 'pending', + }; + + const existing = this.pendingAsks.get(ask.conversationId); + if (existing) { + existing.push(pendingAsk); + } else { + this.pendingAsks.set(ask.conversationId, [pendingAsk]); + } + + return pendingAsk; + } + + incrementRetries(id: string): number | undefined { + for (const asks of this.pendingAsks.values()) { + const ask = asks.find(a => a.id === id); + if (ask) { + ask.retries += 1; + return ask.retries; + } + } + return undefined; + } + + async resolvePendingAsk(id: string, answer: string): Promise { + for (const [conversationId, asks] of this.pendingAsks.entries()) { + const index = asks.findIndex(a => a.id === id); + if (index !== -1) { + asks[index].status = 'answered'; + asks[index].answer = answer; + + const channelName = asks[index].channelName; + + asks.splice(index, 1); + + if (asks.length > 0) { + const nextAsk = asks[0]; + if (channelName && channelName.trim() !== '') { + try { + await this.sendTo(channelName, { output: nextAsk.question }); + } catch (error) { + console.error(`[AgentRegistry] Failed to auto-send next ask: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } else { + console.warn(`[AgentRegistry] Cannot auto-send next ask: channelName is empty for conversation ${conversationId}`); + } + } + + if (asks.length === 0) { + this.pendingAsks.delete(conversationId); + } + return; + } + } + + throw new Error(`Pending ask with id "${id}" not found`); + } +} diff --git a/packages/toolpack-agents/src/agent/base-agent.test.ts b/packages/toolpack-agents/src/agent/base-agent.test.ts new file mode 100644 index 0000000..fcc3084 --- /dev/null +++ b/packages/toolpack-agents/src/agent/base-agent.test.ts @@ -0,0 +1,1354 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BaseAgent } from './base-agent.js'; +import { AgentInput, AgentResult, BaseAgentOptions } from './types.js'; +import type { Toolpack, ConversationStore, StoredMessage, ModeConfig } from 'toolpack-sdk'; +import { CHAT_MODE } from 'toolpack-sdk'; + +// Mock Toolpack +const createMockToolpack = () => { + return { + generate: vi.fn().mockResolvedValue({ + content: 'Mock AI response', + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }), + setMode: vi.fn(), + registerMode: vi.fn(), + } as unknown as Toolpack; +}; + +const TEST_MODE: ModeConfig = { + ...CHAT_MODE, + name: 'test-agent-mode', + systemPrompt: 'You are a helpful test agent.', +}; + +// Test agent implementation +class TestAgent extends BaseAgent<'greet' | 'help'> { + name = 'test-agent'; + description = 'A test agent for unit testing'; + mode = TEST_MODE; + provider = 'openai'; + model = 'gpt-4'; + + beforeRunCalled = false; + completeCalled = false; + errorCalled = false; + stepCompleteCalled = false; + + constructor(options: BaseAgentOptions) { + super(options); + } + + async invokeAgent(input: AgentInput<'greet' | 'help'>): Promise { + if (input.intent === 'greet') { + return { output: 'Hello!' }; + } + return this.run(input.message || ''); + } + + async onBeforeRun(): Promise { + this.beforeRunCalled = true; + } + + async onComplete(): Promise { + this.completeCalled = true; + } + + async onError(): Promise { + this.errorCalled = true; + } + + async onStepComplete(): Promise { + this.stepCompleteCalled = true; + } +} + +describe('BaseAgent', () => { + let mockToolpack: Toolpack; + + beforeEach(() => { + mockToolpack = createMockToolpack(); + }); + + describe('properties', () => { + it('should have required abstract properties', () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + + expect(agent.name).toBe('test-agent'); + expect(agent.description).toBe('A test agent for unit testing'); + expect(agent.mode.name).toBe('test-agent-mode'); + }); + + it('should have optional identity properties', () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + + expect(agent.provider).toBe('openai'); + expect(agent.model).toBe('gpt-4'); + }); + + it('should have registry reference (set by AgentRegistry)', () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + expect(agent._registry).toBeUndefined(); + + const mockRegistry = { sendTo: vi.fn() }; + agent._registry = mockRegistry as unknown as import('./types.js').IAgentRegistry; + expect(agent._registry).toBe(mockRegistry); + }); + + it('should have triggering channel reference', () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + expect(agent._triggeringChannel).toBeUndefined(); + + agent._triggeringChannel = 'slack-support'; + expect(agent._triggeringChannel).toBe('slack-support'); + }); + }); + + describe('invokeAgent', () => { + it('should handle greet intent directly', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const result = await agent.invokeAgent({ + intent: 'greet', + message: 'Say hello', + conversationId: 'test-1', + }); + + expect(result.output).toBe('Hello!'); + }); + + it('should use run() for help intent', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const result = await agent.invokeAgent({ + intent: 'help', + message: 'I need help', + conversationId: 'test-2', + }); + + expect(result.output).toBe('Mock AI response'); + expect(mockToolpack.setMode).toHaveBeenCalledWith('test-agent-mode'); + }); + }); + + describe('run() execution engine', () => { + it('should register and set mode before generate', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + await agent.invokeAgent({ + message: 'Test message', + conversationId: 'test-3', + }); + + expect(mockToolpack.registerMode).toHaveBeenCalledWith(TEST_MODE); + expect(mockToolpack.setMode).toHaveBeenCalledWith('test-agent-mode'); + expect(mockToolpack.generate).toHaveBeenCalled(); + }); + + it('should pass provider override to generate', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + await agent.invokeAgent({ + message: 'Test', + }); + + expect(mockToolpack.generate).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.any(Array), + model: 'gpt-4', + }), + 'openai' + ); + }); + + it('should not inject systemPrompt directly (mode-owned now)', async () => { + // BaseAgent no longer pushes a system message; the mode's systemPrompt is + // injected by Toolpack.client via injectModeSystemPrompt. The mock Toolpack + // does not perform that injection, so request.messages should contain no + // system messages from BaseAgent itself. + const agent = new TestAgent({ toolpack: mockToolpack }); + await agent.invokeAgent({ + message: 'Test message', + }); + + const generateCall = vi.mocked(mockToolpack.generate).mock.calls[0]; + const request = generateCall[0] as { messages: Array<{ role: string; content: string }> }; + + const systemMessages = request.messages.filter(m => m.role === 'system'); + expect(systemMessages).toHaveLength(0); + }); + + it('should return AgentResult with output and metadata', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const result = await agent.invokeAgent({ + message: 'Test', + conversationId: 'test-5', + }); + + expect(result.output).toBe('Mock AI response'); + expect(result.metadata).toBeDefined(); + expect(result.metadata?.usage).toBeDefined(); + }); + + it('should handle errors from generate', async () => { + const errorToolpack = createMockToolpack(); + vi.mocked(errorToolpack.generate).mockRejectedValue(new Error('API Error')); + + const agent = new TestAgent({ toolpack: errorToolpack }); + + await expect(agent.invokeAgent({ + message: 'Test', + conversationId: 'test-6', + })).rejects.toThrow('API Error'); + }); + }); + + describe('lifecycle hooks', () => { + it('should call onBeforeRun before execution', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + await agent.invokeAgent({ + message: 'Test', + conversationId: 'test-7', + }); + + expect(agent.beforeRunCalled).toBe(true); + }); + + it('should call onComplete after successful execution', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + await agent.invokeAgent({ + message: 'Test', + conversationId: 'test-8', + }); + + expect(agent.completeCalled).toBe(true); + }); + + it('should call onError when execution fails', async () => { + const errorToolpack = createMockToolpack(); + vi.mocked(errorToolpack.generate).mockRejectedValue(new Error('API Error')); + + const agent = new TestAgent({ toolpack: errorToolpack }); + + try { + await agent.invokeAgent({ + message: 'Test', + conversationId: 'test-9', + }); + } catch { + // Expected + } + + expect(agent.errorCalled).toBe(true); + }); + }); + + describe('events', () => { + it('should emit agent:start event', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const startHandler = vi.fn(); + agent.on('agent:start', startHandler); + + await agent.invokeAgent({ + message: 'Test', + conversationId: 'test-10', + }); + + expect(startHandler).toHaveBeenCalledWith({ message: 'Test' }); + }); + + it('should emit agent:complete event', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const completeHandler = vi.fn(); + agent.on('agent:complete', completeHandler); + + await agent.invokeAgent({ + message: 'Test', + conversationId: 'test-11', + }); + + expect(completeHandler).toHaveBeenCalledWith( + expect.objectContaining({ + output: 'Mock AI response', + }) + ); + }); + + it('should emit agent:error event', async () => { + const errorToolpack = createMockToolpack(); + vi.mocked(errorToolpack.generate).mockRejectedValue(new Error('API Error')); + + const agent = new TestAgent({ toolpack: errorToolpack }); + const errorHandler = vi.fn(); + agent.on('agent:error', errorHandler); + + try { + await agent.invokeAgent({ + message: 'Test', + conversationId: 'test-12', + }); + } catch { + // Expected + } + + expect(errorHandler).toHaveBeenCalledWith(expect.any(Error)); + }); + }); + + describe('sendTo', () => { + it('should throw if registry not set', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + + await expect(agent['sendTo']('some-channel', 'message')).rejects.toThrow( + 'Agent not registered - _registry not set' + ); + }); + + it('should call registry.sendTo when registry is set', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const mockSendTo = vi.fn().mockResolvedValue(undefined); + agent._registry = { sendTo: mockSendTo } as unknown as import('./types.js').IAgentRegistry; + + await agent['sendTo']('slack-channel', 'Hello from agent'); + + expect(mockSendTo).toHaveBeenCalledWith('slack-channel', { + output: 'Hello from agent', + }); + }); + }); + + describe('ask', () => { + it('should return AgentResult with waitingForHuman metadata', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const mockRegistry = { + sendTo: vi.fn().mockResolvedValue(undefined), + addPendingAsk: vi.fn().mockReturnValue({ + id: 'test-conv:test-agent:1234567890', + conversationId: 'test-conv', + agentName: 'test-agent', + question: 'What is your name?', + context: {}, + maxRetries: 2, + status: 'pending', + retries: 0, + askedAt: new Date(), + }), + }; + agent._registry = mockRegistry as unknown as import('./types.js').IAgentRegistry; + agent._triggeringChannel = 'slack-support'; + agent._conversationId = 'test-conv'; + + const result = await agent['ask']('What is your name?'); + + expect(result.output).toBe('What is your name?'); + expect(result.metadata?.waitingForHuman).toBe(true); + expect(result.metadata?.askId).toBeDefined(); + }); + + it('should send question to triggering channel', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const mockSendTo = vi.fn().mockResolvedValue(undefined); + const mockRegistry = { + sendTo: mockSendTo, + addPendingAsk: vi.fn().mockReturnValue({ + id: 'test-conv:test-agent:1234567890', + conversationId: 'test-conv', + agentName: 'test-agent', + question: 'What is your name?', + context: {}, + maxRetries: 2, + status: 'pending', + retries: 0, + askedAt: new Date(), + }), + }; + agent._registry = mockRegistry as unknown as import('./types.js').IAgentRegistry; + agent._triggeringChannel = 'slack-support'; + agent._conversationId = 'test-conv'; + + await agent['ask']('What is your name?'); + + expect(mockSendTo).toHaveBeenCalledWith('slack-support', { output: 'What is your name?' }); + }); + + it('should throw if no registry is set', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + agent._triggeringChannel = 'slack-support'; + agent._conversationId = 'test-conv'; + + await expect(agent['ask']('What is your name?')).rejects.toThrow( + 'Agent not registered - cannot use ask()' + ); + }); + + it('should throw if no conversationId is available', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const mockRegistry = { sendTo: vi.fn() }; + agent._registry = mockRegistry as unknown as import('./types.js').IAgentRegistry; + agent._triggeringChannel = 'slack-support'; + + await expect(agent['ask']('What is your name?')).rejects.toThrow( + 'No conversationId available - ask() requires a conversation channel' + ); + }); + + it('should throw if called from a trigger channel (ScheduledChannel)', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const mockRegistry = { + sendTo: vi.fn().mockResolvedValue(undefined), + addPendingAsk: vi.fn(), + }; + agent._registry = mockRegistry as unknown as import('./types.js').IAgentRegistry; + agent._triggeringChannel = 'daily-report'; + agent._conversationId = 'scheduled:test:2024-01-01'; + agent._isTriggerChannel = true; // This flag is set by AgentRegistry for ScheduledChannel + + await expect(agent['ask']('What is your name?')).rejects.toThrow( + 'this.ask() called from a trigger channel (ScheduledChannel)' + ); + }); + + it('should support custom context, maxRetries, and expiresIn options', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const mockAddPendingAsk = vi.fn().mockReturnValue({ + id: 'test-conv:test-agent:1234567890', + conversationId: 'test-conv', + agentName: 'test-agent', + question: 'What is your name?', + context: { step: 3, data: 'test' }, + maxRetries: 5, + expiresAt: expect.any(Date), + status: 'pending', + retries: 0, + askedAt: expect.any(Date), + }); + const mockRegistry = { + sendTo: vi.fn().mockResolvedValue(undefined), + addPendingAsk: mockAddPendingAsk, + }; + agent._registry = mockRegistry as unknown as import('./types.js').IAgentRegistry; + agent._triggeringChannel = 'slack-support'; + agent._conversationId = 'test-conv'; + + await agent['ask']('What is your name?', { + context: { step: 3, data: 'test' }, + maxRetries: 5, + expiresIn: 300000, // 5 minutes + }); + + expect(mockAddPendingAsk).toHaveBeenCalledWith(expect.objectContaining({ + conversationId: 'test-conv', + agentName: 'test-agent', + question: 'What is your name?', + context: { step: 3, data: 'test' }, + maxRetries: 5, + expiresAt: expect.any(Date), + })); + }); + }); + + describe('getPendingAsk', () => { + it('should return pending ask from registry', () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const mockPendingAsk = { + id: 'test-conv:test-agent:1234567890', + conversationId: 'test-conv', + agentName: 'test-agent', + question: 'What is your name?', + context: {}, + maxRetries: 2, + status: 'pending' as const, + retries: 0, + askedAt: new Date(), + }; + const mockRegistry = { + getPendingAsk: vi.fn().mockReturnValue(mockPendingAsk), + }; + agent._registry = mockRegistry as unknown as import('./types.js').IAgentRegistry; + agent._conversationId = 'test-conv'; + + const result = agent['getPendingAsk'](); + + expect(result).toEqual(mockPendingAsk); + expect(mockRegistry.getPendingAsk).toHaveBeenCalledWith('test-conv'); + }); + + it('should return null if no registry', () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + agent._conversationId = 'test-conv'; + + const result = agent['getPendingAsk'](); + + expect(result).toBeNull(); + }); + + it('should return null if no conversationId', () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const mockRegistry = { getPendingAsk: vi.fn() }; + agent._registry = mockRegistry as unknown as import('./types.js').IAgentRegistry; + + const result = agent['getPendingAsk'](); + + expect(result).toBeNull(); + }); + }); + + describe('resolvePendingAsk', () => { + it('should resolve pending ask in registry', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const mockResolvePendingAsk = vi.fn().mockResolvedValue(undefined); + const mockRegistry = { + resolvePendingAsk: mockResolvePendingAsk, + }; + agent._registry = mockRegistry as unknown as import('./types.js').IAgentRegistry; + + await agent['resolvePendingAsk']('ask-id-123', 'John'); + + expect(mockResolvePendingAsk).toHaveBeenCalledWith('ask-id-123', 'John'); + }); + + it('should throw if no registry', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + + await expect(agent['resolvePendingAsk']('ask-id-123', 'John')).rejects.toThrow( + 'Agent not registered - cannot resolve ask' + ); + }); + }); + + describe('evaluateAnswer', () => { + it('should use simpleValidation when provided', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const simpleValidation = vi.fn().mockReturnValue(true); + + const result = await agent['evaluateAnswer']('What is your name?', 'John', { + simpleValidation, + }); + + expect(simpleValidation).toHaveBeenCalledWith('John'); + expect(result).toBe(true); + expect(mockToolpack.generate).not.toHaveBeenCalled(); // No LLM call + }); + + it('should use LLM when simpleValidation not provided', async () => { + const evaluationToolpack = createMockToolpack(); + vi.mocked(evaluationToolpack.generate).mockResolvedValue({ + content: 'yes', + usage: { prompt_tokens: 20, completion_tokens: 1, total_tokens: 21 }, + }); + + const agent = new TestAgent({ toolpack: evaluationToolpack }); + + const result = await agent['evaluateAnswer']('What is your name?', 'John'); + + expect(evaluationToolpack.generate).toHaveBeenCalled(); + expect(result).toBe(true); + }); + + it('should return false when LLM evaluation returns no', async () => { + const evaluationToolpack = createMockToolpack(); + vi.mocked(evaluationToolpack.generate).mockResolvedValue({ + content: 'no', + usage: { prompt_tokens: 20, completion_tokens: 1, total_tokens: 21 }, + }); + + const agent = new TestAgent({ toolpack: evaluationToolpack }); + + const result = await agent['evaluateAnswer']('What is your name?', ''); + + expect(result).toBe(false); + }); + }); + + describe('extractSteps', () => { + it('should extract steps from plan in result', async () => { + const planToolpack = createMockToolpack(); + vi.mocked(planToolpack.generate).mockResolvedValue({ + content: 'Response', + plan: { + steps: [ + { + number: 1, + description: 'Step 1', + status: 'completed', + result: { success: true }, + }, + { + number: 2, + description: 'Step 2', + status: 'in_progress', + }, + ], + }, + } as unknown as import('toolpack-sdk').CompletionResponse); + + const agent = new TestAgent({ toolpack: planToolpack }); + const result = await agent.invokeAgent({ + message: 'Test', + conversationId: 'test-13', + }); + + expect(result.steps).toHaveLength(2); + expect(result.steps?.[0].number).toBe(1); + expect(result.steps?.[0].status).toBe('completed'); + expect(result.steps?.[1].status).toBe('in_progress'); + }); + + it('should handle results without steps', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const result = await agent.invokeAgent({ + message: 'Test', + conversationId: 'test-14', + }); + + expect(result.steps).toBeUndefined(); + }); + }); + + describe('conversation history integration', () => { + it('auto-initialises conversationHistory to InMemoryConversationStore', () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + expect(agent.conversationHistory).toBeDefined(); + }); + + it('injects conversation_search tool when _conversationId is set', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + agent._conversationId = 'test-conv'; + + await agent.invokeAgent({ + message: 'Test message', + conversationId: 'test-conv', + }); + + expect(mockToolpack.generate).toHaveBeenCalledWith( + expect.objectContaining({ + requestTools: expect.arrayContaining([ + expect.objectContaining({ name: 'conversation_search' }), + ]), + }), + expect.anything() + ); + }); + + it('does not inject search tool when _conversationId is absent', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + // _conversationId not set + + await agent.invokeAgent({ message: 'Test message' }); + + expect(mockToolpack.generate).toHaveBeenCalledWith( + expect.objectContaining({ requestTools: undefined }), + expect.anything() + ); + }); + + it('loads conversation history via assemblePrompt and passes projected messages to generate', async () => { + // Use matching agent id so addressed-only filter includes the prior turn. + const storedMessages: StoredMessage[] = [ + { id: '1', conversationId: 'test-conv', participant: { kind: 'user', id: 'u1', displayName: 'Alice' }, content: 'Hello from user', timestamp: '2024-01-01T00:00:00Z', scope: 'channel' }, + { id: '2', conversationId: 'test-conv', participant: { kind: 'agent', id: 'test-agent' }, content: 'Hello from assistant', timestamp: '2024-01-01T00:00:01Z', scope: 'channel' }, + ]; + const mockConversationHistory: ConversationStore = { + get: vi.fn().mockResolvedValue(storedMessages), + append: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockResolvedValue([]), + deleteMessages: vi.fn().mockResolvedValue(undefined), + }; + + const agent = new TestAgent({ toolpack: mockToolpack }); + agent.conversationHistory = mockConversationHistory; + // Disable addressed-only mode so all stored messages appear in the prompt. + agent.assemblerOptions = { addressedOnlyMode: false }; + agent._conversationId = 'test-conv'; + + await agent.invokeAgent({ message: 'New message', conversationId: 'test-conv' }); + + // assemblePrompt calls store.get with an options object + expect(mockConversationHistory.get).toHaveBeenCalledWith('test-conv', expect.any(Object)); + + const generateCall = vi.mocked(mockToolpack.generate).mock.calls[0]; + const request = generateCall[0] as { messages: Array<{ role: string; content: string }> }; + const messages = request.messages; + + // User message is projected as "Alice: Hello from user" (displayName prefix) + expect(messages.some(m => m.role === 'user' && m.content.includes('Hello from user'))).toBe(true); + // Agent's own turn is projected as assistant role, content verbatim + expect(messages.some(m => m.role === 'assistant' && m.content === 'Hello from assistant')).toBe(true); + // The triggering message is appended last + expect(messages.some(m => m.role === 'user' && m.content === 'New message')).toBe(true); + }); + + it('projects system, user, and agent turns correctly (addressed-only off)', async () => { + const storedMessages: StoredMessage[] = [ + { id: '1', conversationId: 'test-conv', participant: { kind: 'system', id: 'system' }, content: 'You are helpful', timestamp: '2024-01-01T00:00:00Z', scope: 'channel' }, + { id: '2', conversationId: 'test-conv', participant: { kind: 'user', id: 'u1' }, content: 'Hello', timestamp: '2024-01-01T00:00:01Z', scope: 'channel' }, + { id: '3', conversationId: 'test-conv', participant: { kind: 'agent', id: 'test-agent' }, content: 'Hi!', timestamp: '2024-01-01T00:00:02Z', scope: 'channel' }, + ]; + const mockConversationHistory: ConversationStore = { + get: vi.fn().mockResolvedValue(storedMessages), + append: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockResolvedValue([]), + deleteMessages: vi.fn().mockResolvedValue(undefined), + }; + + const agent = new TestAgent({ toolpack: mockToolpack }); + agent.conversationHistory = mockConversationHistory; + agent.assemblerOptions = { addressedOnlyMode: false }; + agent._conversationId = 'test-conv'; + + await agent.invokeAgent({ message: 'New message', conversationId: 'test-conv' }); + + const generateCall = vi.mocked(mockToolpack.generate).mock.calls[0]; + const request = generateCall[0] as { messages: Array<{ role: string; content: string }> }; + const messages = request.messages; + + expect(messages.some(m => m.role === 'system' && m.content === 'You are helpful')).toBe(true); + expect(messages.some(m => m.role === 'user' && m.content.includes('Hello'))).toBe(true); + expect(messages.some(m => m.role === 'assistant' && m.content === 'Hi!')).toBe(true); + expect(messages.some(m => m.role === 'user' && m.content === 'New message')).toBe(true); + }); + + it('run() does not write to the store — capture-history interceptor owns writes', async () => { + const mockConversationHistory: ConversationStore = { + get: vi.fn().mockResolvedValue([]), + append: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockResolvedValue([]), + deleteMessages: vi.fn().mockResolvedValue(undefined), + }; + + const agent = new TestAgent({ toolpack: mockToolpack }); + agent.conversationHistory = mockConversationHistory; + agent._conversationId = 'test-conv'; + + // Call invokeAgent directly (not through a channel) — no capture interceptor runs. + await agent.invokeAgent({ message: 'User question', conversationId: 'test-conv' }); + + // run() must NOT call append — writes belong to capture-history. + expect(mockConversationHistory.append).not.toHaveBeenCalled(); + }); + + it('should inject conversation_search as a request-scoped tool when store is available', async () => { + const mockConversationHistory: ConversationStore = { + get: vi.fn().mockResolvedValue([]), + append: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockResolvedValue([]), + deleteMessages: vi.fn().mockResolvedValue(undefined), + }; + + const agent = new TestAgent({ toolpack: mockToolpack }); + agent.conversationHistory = mockConversationHistory; + agent._conversationId = 'test-conv'; + + await agent.invokeAgent({ + message: 'What did I say earlier?', + conversationId: 'test-conv', + }); + + const generateCall = vi.mocked(mockToolpack.generate).mock.calls[0]; + const request = generateCall[0] as { requestTools?: Array<{ name: string }> }; + + expect(request.requestTools).toBeDefined(); + expect(request.requestTools?.some(t => t.name === 'conversation_search')).toBe(true); + }); + + it('should pass a callable conversation_search request tool to the SDK', async () => { + const matchingMessage: StoredMessage = { + id: '1', conversationId: 'test-conv', + participant: { kind: 'user', id: 'u1' }, + content: 'Hello world', + timestamp: '2024-01-01T00:00:00Z', + scope: 'channel', + }; + const mockConversationHistory: ConversationStore = { + get: vi.fn().mockResolvedValue([]), + append: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockResolvedValue([matchingMessage]), + deleteMessages: vi.fn().mockResolvedValue(undefined), + }; + + const agent = new TestAgent({ toolpack: mockToolpack }); + agent.conversationHistory = mockConversationHistory; + agent._conversationId = 'test-conv'; + + await agent.invokeAgent({ + message: 'What did I say earlier?', + conversationId: 'test-conv', + }); + + const generateCall = vi.mocked(mockToolpack.generate).mock.calls[0]; + const request = generateCall[0] as { requestTools?: Array<{ name: string; execute: (args: Record) => Promise }> }; + const conversationTool = request.requestTools?.find(tool => tool.name === 'conversation_search'); + + expect(conversationTool).toBeDefined(); + await expect(conversationTool?.execute({ query: 'hello' })).resolves.toEqual({ + results: [{ role: 'user', content: 'Hello world', timestamp: '2024-01-01T00:00:00Z' }], + count: 1, + }); + }); + + it('should skip conversation history operations when conversationId is undefined', async () => { + const mockConversationHistory: ConversationStore = { + get: vi.fn().mockResolvedValue([]), + append: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockResolvedValue([]), + deleteMessages: vi.fn().mockResolvedValue(undefined), + }; + + const agent = new TestAgent({ toolpack: mockToolpack }); + agent.conversationHistory = mockConversationHistory; + + await agent.invokeAgent({ + message: 'Test message', + // No conversationId + }); + + expect(mockConversationHistory.get).not.toHaveBeenCalled(); + expect(mockConversationHistory.append).not.toHaveBeenCalled(); + }); + + it('auto-wires agentAliases from channel botUserId into addressed-only filtering', async () => { + // When addressed-only mode is on, only messages authored by the agent + // OR mentioning one of its ids should appear in the prompt. A stored + // message that mentions the channel's bot user id (e.g. a Slack + // <@U_KAEL_BOT>) must match via the auto-wired alias. + const aliasId = 'U_KAEL_BOT'; + const storedMessages: StoredMessage[] = [ + { + id: '1', conversationId: 'test-conv', + participant: { kind: 'user', id: 'u_alice', displayName: 'Alice' }, + content: 'Hey team, non-addressed chatter', + timestamp: '2024-01-01T00:00:00Z', + scope: 'channel', + }, + { + id: '2', conversationId: 'test-conv', + participant: { kind: 'user', id: 'u_alice', displayName: 'Alice' }, + content: 'Kael, what do you think?', + timestamp: '2024-01-01T00:00:01Z', + scope: 'channel', + metadata: { mentions: [aliasId] }, + }, + ]; + const mockConversationHistory: ConversationStore = { + get: vi.fn().mockResolvedValue(storedMessages), + append: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockResolvedValue([]), + deleteMessages: vi.fn().mockResolvedValue(undefined), + }; + + const agent = new TestAgent({ toolpack: mockToolpack }); + agent.conversationHistory = mockConversationHistory; + agent._conversationId = 'test-conv'; + // Simulate a channel that exposes botUserId (as SlackChannel / TelegramChannel do). + // The channel doesn't need to actually listen — _resolveAssemblerOptions() + // only reads the botUserId field. + agent.channels = [ + { name: 'slack', isTriggerChannel: false, botUserId: aliasId, onMessage: () => {}, send: async () => {}, listen: () => {} } as unknown as (typeof agent.channels)[number], + ]; + // addressed-only is the default, but set it explicitly for clarity. + agent.assemblerOptions = { addressedOnlyMode: true }; + + await agent.invokeAgent({ message: 'New message', conversationId: 'test-conv' }); + + const request = vi.mocked(mockToolpack.generate).mock.calls[0][0] as { + messages: Array<{ role: string; content: string }>; + }; + // The addressed message must have been projected in. + expect(request.messages.some(m => m.content.includes('Kael, what do you think?'))).toBe(true); + // The non-addressed chatter must have been filtered out. + expect(request.messages.some(m => m.content.includes('non-addressed chatter'))).toBe(false); + }); + + it('merges manual agentAliases with channel botUserId (dedup preserved)', async () => { + const manualAlias = 'U_KAEL_MANUAL'; + const channelAlias = 'U_KAEL_FROM_SLACK'; + const storedMessages: StoredMessage[] = [ + { + id: '1', conversationId: 'test-conv', + participant: { kind: 'user', id: 'u_alice' }, + content: 'Manual-alias mention', + timestamp: '2024-01-01T00:00:00Z', + scope: 'channel', + metadata: { mentions: [manualAlias] }, + }, + { + id: '2', conversationId: 'test-conv', + participant: { kind: 'user', id: 'u_alice' }, + content: 'Channel-alias mention', + timestamp: '2024-01-01T00:00:01Z', + scope: 'channel', + metadata: { mentions: [channelAlias] }, + }, + ]; + const mockConversationHistory: ConversationStore = { + get: vi.fn().mockResolvedValue(storedMessages), + append: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockResolvedValue([]), + deleteMessages: vi.fn().mockResolvedValue(undefined), + }; + + const agent = new TestAgent({ toolpack: mockToolpack }); + agent.conversationHistory = mockConversationHistory; + agent._conversationId = 'test-conv'; + agent.channels = [ + { name: 'slack', isTriggerChannel: false, botUserId: channelAlias, onMessage: () => {}, send: async () => {}, listen: () => {} } as unknown as (typeof agent.channels)[number], + ]; + agent.assemblerOptions = { addressedOnlyMode: true, agentAliases: [manualAlias] }; + + await agent.invokeAgent({ message: 'New message', conversationId: 'test-conv' }); + + const request = vi.mocked(mockToolpack.generate).mock.calls[0][0] as { + messages: Array<{ role: string; content: string }>; + }; + // Both aliases should resolve to matches. + expect(request.messages.some(m => m.content.includes('Manual-alias mention'))).toBe(true); + expect(request.messages.some(m => m.content.includes('Channel-alias mention'))).toBe(true); + }); + + it('should continue without history when get fails', async () => { + const mockConversationHistory: ConversationStore = { + get: vi.fn().mockRejectedValue(new Error('Query failed')), + append: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockResolvedValue([]), + deleteMessages: vi.fn().mockResolvedValue(undefined), + }; + + const agent = new TestAgent({ toolpack: mockToolpack }); + agent.conversationHistory = mockConversationHistory; + agent._conversationId = 'test-conv'; + + const result = await agent.invokeAgent({ + message: 'Test message', + conversationId: 'test-conv', + }); + + expect(result.output).toBe('Mock AI response'); + expect(mockToolpack.generate).toHaveBeenCalled(); + }); + + it('continues without history when assemblePrompt throws', async () => { + const mockConversationHistory: ConversationStore = { + get: vi.fn().mockRejectedValue(new Error('DB unavailable')), + append: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockResolvedValue([]), + deleteMessages: vi.fn().mockResolvedValue(undefined), + }; + + const agent = new TestAgent({ toolpack: mockToolpack }); + agent.conversationHistory = mockConversationHistory; + agent._conversationId = 'test-conv'; + + const result = await agent.invokeAgent({ + message: 'Test message', + conversationId: 'test-conv', + }); + + // Should still call generate and return a result even when history fails. + expect(result.output).toBe('Mock AI response'); + expect(mockToolpack.generate).toHaveBeenCalled(); + }); + + it('should not leak conversation state between multiple agents', async () => { + const secret1: StoredMessage = { id: 's1', conversationId: 'conv-1', participant: { kind: 'user', id: 'u1' }, content: 'Secret from agent 1: API key is abc123', timestamp: new Date().toISOString(), scope: 'channel' }; + const secret2: StoredMessage = { id: 's2', conversationId: 'conv-2', participant: { kind: 'user', id: 'u2' }, content: 'Secret from agent 2: Password is xyz789', timestamp: new Date().toISOString(), scope: 'channel' }; + + const store1: ConversationStore = { + get: vi.fn().mockResolvedValue([]), + append: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockImplementation(async (_convId: string, query: string) => + [secret1].filter(m => m.content.toLowerCase().includes(query.toLowerCase())) + ), + deleteMessages: vi.fn().mockResolvedValue(undefined), + }; + + const store2: ConversationStore = { + get: vi.fn().mockResolvedValue([]), + append: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockImplementation(async (_convId: string, query: string) => + [secret2].filter(m => m.content.toLowerCase().includes(query.toLowerCase())) + ), + deleteMessages: vi.fn().mockResolvedValue(undefined), + }; + + const agent1 = new TestAgent({ toolpack: mockToolpack }); + agent1.conversationHistory = store1; + agent1._conversationId = 'conv-1'; + + const agent2 = new TestAgent({ toolpack: mockToolpack }); + agent2.conversationHistory = store2; + agent2._conversationId = 'conv-2'; + + await agent1.invokeAgent({ message: 'test message 1', conversationId: 'conv-1' }); + await agent2.invokeAgent({ message: 'test message 2', conversationId: 'conv-2' }); + + const call1 = vi.mocked(mockToolpack.generate).mock.calls[0]; + const call2 = vi.mocked(mockToolpack.generate).mock.calls[1]; + + const request1 = typeof call1[0] === 'string' ? null : call1[0]; + const request2 = typeof call2[0] === 'string' ? null : call2[0]; + + expect(request1).not.toBeNull(); + expect(request2).not.toBeNull(); + + const tool1 = request1!.requestTools?.find((t: { name: string }) => t.name === 'conversation_search'); + const tool2 = request2!.requestTools?.find((t: { name: string }) => t.name === 'conversation_search'); + + expect(tool1).toBeDefined(); + expect(tool2).toBeDefined(); + + const results1 = await tool1!.execute({ query: 'Secret' }); + const results2 = await tool2!.execute({ query: 'Secret' }); + + expect(results1.count).toBe(1); + expect(results1.results[0].content).toContain('agent 1'); + expect(results1.results[0].content).toContain('abc123'); + expect(results1.results[0].content).not.toContain('agent 2'); + expect(results1.results[0].content).not.toContain('xyz789'); + + expect(results2.count).toBe(1); + expect(results2.results[0].content).toContain('agent 2'); + expect(results2.results[0].content).toContain('xyz789'); + expect(results2.results[0].content).not.toContain('agent 1'); + expect(results2.results[0].content).not.toContain('abc123'); + + expect(store1.search).toHaveBeenCalledWith('conv-1', expect.any(String), expect.any(Object)); + expect(store2.search).toHaveBeenCalledWith('conv-2', expect.any(String), expect.any(Object)); + }); + + // --- Pillar 2 tests --- + + it('isolation: conversation_search cannot reach turns from a different conversation in the same store', async () => { + // Shared store with turns for both conv-A and conv-B. + const turnA: StoredMessage = { id: 'a1', conversationId: 'conv-A', participant: { kind: 'user', id: 'u1' }, content: 'Secret in conv-A', timestamp: new Date().toISOString(), scope: 'channel' }; + const turnB: StoredMessage = { id: 'b1', conversationId: 'conv-B', participant: { kind: 'user', id: 'u2' }, content: 'Secret in conv-B', timestamp: new Date().toISOString(), scope: 'channel' }; + + const sharedStore: ConversationStore = { + get: vi.fn().mockResolvedValue([]), + append: vi.fn().mockResolvedValue(undefined), + // Real scoping: only return turns whose conversationId matches the queried id. + search: vi.fn().mockImplementation(async (convId: string) => + [turnA, turnB].filter(m => m.conversationId === convId) + ), + deleteMessages: vi.fn().mockResolvedValue(undefined), + }; + + const agent = new TestAgent({ toolpack: mockToolpack }); + agent.conversationHistory = sharedStore; + agent._conversationId = 'conv-A'; + + await agent.invokeAgent({ message: 'test', conversationId: 'conv-A' }); + + const tool = (vi.mocked(mockToolpack.generate).mock.calls[0][0] as { + requestTools?: Array<{ name: string; execute: (args: Record) => Promise<{ results: Array<{ content: string }>; count: number }> }>; + }).requestTools?.find(t => t.name === 'conversation_search'); + + expect(tool).toBeDefined(); + + // The tool must call store.search with 'conv-A' (the closure-captured id). + expect(sharedStore.search).not.toHaveBeenCalled(); // not yet — execute hasn't been called + const result = await tool!.execute({ query: 'Secret' }); + + expect(sharedStore.search).toHaveBeenCalledWith('conv-A', 'Secret', expect.any(Object)); + // conv-B's turn must not appear. + expect(result.count).toBe(1); + expect(result.results[0].content).toBe('Secret in conv-A'); + expect(result.results.every((r: { content: string }) => !r.content.includes('conv-B'))).toBe(true); + }); + + it('adversarial: conversation_search ignores conversationId injected into args; always uses closure-captured id', async () => { + const legitimateTurn: StoredMessage = { id: 'l1', conversationId: 'conv-safe', participant: { kind: 'user', id: 'u1' }, content: 'Legitimate content', timestamp: new Date().toISOString(), scope: 'channel' }; + + const store: ConversationStore = { + get: vi.fn().mockResolvedValue([]), + append: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockResolvedValue([legitimateTurn]), + deleteMessages: vi.fn().mockResolvedValue(undefined), + }; + + const agent = new TestAgent({ toolpack: mockToolpack }); + agent.conversationHistory = store; + agent._conversationId = 'conv-safe'; + + await agent.invokeAgent({ message: 'test', conversationId: 'conv-safe' }); + + const tool = (vi.mocked(mockToolpack.generate).mock.calls[0][0] as { + requestTools?: Array<{ name: string; execute: (args: Record) => Promise }>; + }).requestTools?.find(t => t.name === 'conversation_search'); + + expect(tool).toBeDefined(); + + // Simulate adversarial LLM call: injects a foreign conversationId into args. + await tool!.execute({ query: 'foo', conversationId: 'conv-other' }); + + // store.search must have been called with the closure-captured 'conv-safe', not 'conv-other'. + expect(store.search).toHaveBeenCalledWith('conv-safe', 'foo', expect.any(Object)); + expect(store.search).not.toHaveBeenCalledWith('conv-other', expect.anything(), expect.anything()); + }); + + it('delegation: delegated agent search is scoped to originating conversationId; resets for own next message', async () => { + // An agent that properly forwards the input conversationId to run() — as a real agent would. + class ConvAwareAgent extends BaseAgent { + name = 'conv-aware-agent'; + description = 'Aware agent'; + mode = TEST_MODE; + async invokeAgent(input: AgentInput): Promise { + return this.run(input.message || '', undefined, { conversationId: input.conversationId }); + } + } + + const originatingConvId = 'orch-conv-99'; + const ownConvId = 'target-own-conv-77'; + + const store: ConversationStore = { + get: vi.fn().mockResolvedValue([]), + append: vi.fn().mockResolvedValue(undefined), + search: vi.fn().mockResolvedValue([]), + deleteMessages: vi.fn().mockResolvedValue(undefined), + }; + + const delegatedAgent = new ConvAwareAgent({ toolpack: mockToolpack }); + delegatedAgent.conversationHistory = store; + + // Simulate delegation: registry calls invokeAgent with the originator's conversationId. + await delegatedAgent.invokeAgent({ message: 'delegated task', conversationId: originatingConvId }); + + const call1 = vi.mocked(mockToolpack.generate).mock.calls[0]; + const tool1 = (call1[0] as { + requestTools?: Array<{ name: string; execute: (args: Record) => Promise }>; + }).requestTools?.find(t => t.name === 'conversation_search'); + + expect(tool1).toBeDefined(); + await tool1!.execute({ query: 'test' }); + // During delegation, search must be scoped to the originating conversation. + expect(store.search).toHaveBeenLastCalledWith(originatingConvId, 'test', expect.any(Object)); + + vi.mocked(mockToolpack.generate).mockClear(); + vi.mocked(store.search).mockClear(); + + // Next inbound message with the agent's own conversationId — search must reset. + await delegatedAgent.invokeAgent({ message: 'own message', conversationId: ownConvId }); + + const call2 = vi.mocked(mockToolpack.generate).mock.calls[0]; + const tool2 = (call2[0] as { + requestTools?: Array<{ name: string; execute: (args: Record) => Promise }>; + }).requestTools?.find(t => t.name === 'conversation_search'); + + expect(tool2).toBeDefined(); + await tool2!.execute({ query: 'test' }); + // After reset, search must be scoped to the agent's own conversation. + expect(store.search).toHaveBeenLastCalledWith(ownConvId, 'test', expect.any(Object)); + }); + }); + + describe('handlePendingAsk', () => { + it('should resolve ask and call onSufficient when answer is sufficient', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const mockResolvePendingAsk = vi.fn().mockResolvedValue(undefined); + const mockOnSufficient = vi.fn().mockResolvedValue({ output: 'Task continued' }); + + agent._registry = { + resolvePendingAsk: mockResolvePendingAsk, + } as unknown as import('./types.js').IAgentRegistry; + + const pendingAsk = { + id: 'ask-123', + conversationId: 'conv-123', + agentName: 'test-agent', + question: 'What is your name?', + status: 'pending' as const, + retries: 0, + maxRetries: 2, + askedAt: new Date(), + channelName: 'slack', + context: { step: 1 }, + } as import('./types.js').PendingAsk; + + // Mock evaluateAnswer to return true (sufficient) + vi.spyOn(agent as unknown as { evaluateAnswer: () => Promise }, 'evaluateAnswer').mockResolvedValue(true); + + const result = await agent['handlePendingAsk'](pendingAsk, 'John Doe', mockOnSufficient); + + // Verify ask was resolved + expect(mockResolvePendingAsk).toHaveBeenCalledWith('ask-123', 'John Doe'); + + // Verify onSufficient was called + expect(mockOnSufficient).toHaveBeenCalledWith('John Doe'); + + // Verify result + expect(result.output).toBe('Task continued'); + }); + + it('should re-ask when answer is insufficient and retries remain', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const mockRegistry = { + resolvePendingAsk: vi.fn().mockResolvedValue(undefined), + incrementRetries: vi.fn().mockReturnValue(1), + sendTo: vi.fn().mockResolvedValue(undefined), + addPendingAsk: vi.fn().mockReturnValue({ + id: 'ask-456', + question: 'I need a bit more clarity on: "What is your name?". Could you provide more details?', + status: 'pending', + }), + }; + + agent._registry = mockRegistry as unknown as import('./types.js').IAgentRegistry; + agent._triggeringChannel = 'slack'; + agent._conversationId = 'conv-123'; + + const pendingAsk = { + id: 'ask-123', + conversationId: 'conv-123', + agentName: 'test-agent', + question: 'What is your name?', + status: 'pending' as const, + retries: 0, + maxRetries: 2, + askedAt: new Date(), + channelName: 'slack', + context: { step: 1 }, + } as import('./types.js').PendingAsk; + + // Mock evaluateAnswer to return false (insufficient) + vi.spyOn(agent as unknown as { evaluateAnswer: () => Promise }, 'evaluateAnswer').mockResolvedValue(false); + + const mockOnSufficient = vi.fn(); + + const result = await agent['handlePendingAsk'](pendingAsk, 'J', mockOnSufficient); + + // Verify retry counter was incremented + expect(mockRegistry.incrementRetries).toHaveBeenCalledWith('ask-123'); + + // Verify onSufficient was NOT called + expect(mockOnSufficient).not.toHaveBeenCalled(); + + // Verify result indicates waiting for human (re-ask) + expect(result.metadata?.waitingForHuman).toBe(true); + }); + + it('should skip step when maxRetries exceeded and onInsufficient not provided', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const mockRegistry = { + resolvePendingAsk: vi.fn().mockResolvedValue(undefined), + sendTo: vi.fn().mockResolvedValue(undefined), + }; + + agent._registry = mockRegistry as unknown as import('./types.js').IAgentRegistry; + agent._triggeringChannel = 'slack'; + agent._conversationId = 'conv-123'; + + const pendingAsk: import('./types.js').PendingAsk = { + id: 'ask-123', + conversationId: 'conv-123', + agentName: 'test-agent', + question: 'What is your name?', + retries: 2, // Already at max + maxRetries: 2, + status: 'pending', + askedAt: new Date(), + context: { step: 1 }, + channelName: 'slack', + }; + + // Mock evaluateAnswer to return false (insufficient) + vi.spyOn(agent as unknown as { evaluateAnswer: () => Promise }, 'evaluateAnswer').mockResolvedValue(false); + + const mockOnSufficient = vi.fn(); + + const result = await agent['handlePendingAsk'](pendingAsk, 'J', mockOnSufficient); + + // Verify ask was resolved with __insufficient__ marker + expect(mockRegistry.resolvePendingAsk).toHaveBeenCalledWith('ask-123', '__insufficient__'); + + // Verify user was notified (sendTo receives { output: message } object) + expect(mockRegistry.sendTo).toHaveBeenCalledWith( + 'slack', + { output: 'I was unable to get enough information to proceed. Skipping this step.' } + ); + + // Verify onSufficient was NOT called + expect(mockOnSufficient).not.toHaveBeenCalled(); + + // Verify fallback result + expect(result.output).toBe('Step skipped due to insufficient input.'); + expect(result.metadata?.skipped).toBe(true); + expect(result.metadata?.askId).toBe('ask-123'); + }); + + it('should call custom onInsufficient callback when maxRetries exceeded', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const mockRegistry = { + resolvePendingAsk: vi.fn().mockResolvedValue(undefined), + sendTo: vi.fn().mockResolvedValue(undefined), + }; + + agent._registry = mockRegistry as unknown as import('./types.js').IAgentRegistry; + agent._triggeringChannel = 'slack'; + + const pendingAsk = { + id: 'ask-123', + conversationId: 'conv-123', + agentName: 'test-agent', + question: 'What is your name?', + status: 'pending' as const, + retries: 2, + maxRetries: 2, + askedAt: new Date(), + channelName: 'slack', + context: { step: 1 }, + } as import('./types.js').PendingAsk; + + // Mock evaluateAnswer to return false + vi.spyOn(agent as unknown as { evaluateAnswer: () => Promise }, 'evaluateAnswer').mockResolvedValue(false); + + const mockOnSufficient = vi.fn(); + const mockOnInsufficient = vi.fn().mockReturnValue({ + output: 'Custom fallback behavior', + metadata: { custom: true }, + }); + + const result = await agent['handlePendingAsk']( + pendingAsk, + 'J', + mockOnSufficient, + mockOnInsufficient + ); + + // Verify custom callback was called + expect(mockOnInsufficient).toHaveBeenCalled(); + + // Verify custom result returned + expect(result.output).toBe('Custom fallback behavior'); + expect(result.metadata?.custom).toBe(true); + }); + + it('should skip notification if no triggering channel available', async () => { + const agent = new TestAgent({ toolpack: mockToolpack }); + const mockRegistry = { + resolvePendingAsk: vi.fn().mockResolvedValue(undefined), + sendTo: vi.fn().mockResolvedValue(undefined), + }; + + agent._registry = mockRegistry as unknown as import('./types.js').IAgentRegistry; + // No _triggeringChannel set + + const pendingAsk = { + id: 'ask-123', + conversationId: 'conv-123', + agentName: 'test-agent', + question: 'What is your name?', + status: 'pending' as const, + retries: 2, + maxRetries: 2, + askedAt: new Date(), + channelName: 'slack', + context: { step: 1 }, + } as import('./types.js').PendingAsk; + + // Mock evaluateAnswer to return false + vi.spyOn(agent as unknown as { evaluateAnswer: () => Promise }, 'evaluateAnswer').mockResolvedValue(false); + + const mockOnSufficient = vi.fn(); + + await agent['handlePendingAsk'](pendingAsk, 'J', mockOnSufficient); + + // Verify sendTo was NOT called (no channel to send to) + expect(mockRegistry.sendTo).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/toolpack-agents/src/agent/base-agent.ts b/packages/toolpack-agents/src/agent/base-agent.ts new file mode 100644 index 0000000..1f67f01 --- /dev/null +++ b/packages/toolpack-agents/src/agent/base-agent.ts @@ -0,0 +1,736 @@ +import { EventEmitter } from 'events'; +import type { RequestToolDefinition, ConversationStore, AssemblerOptions, ModeConfig } from 'toolpack-sdk'; +import { Toolpack, InMemoryConversationStore } from 'toolpack-sdk'; +import type { Interceptor } from '../interceptors/types.js'; +import { composeChain, executeChain } from '../interceptors/chain.js'; +import { createCaptureInterceptor, CAPTURE_INTERCEPTOR_MARKER } from '../interceptors/builtins/capture-history.js'; +import { assemblePrompt } from '../history/assembler.js'; +import type { AgentInput, AgentResult, AgentOutput, AgentRunOptions, WorkflowStep, IAgentRegistry, PendingAsk, ChannelInterface, BaseAgentOptions } from './types.js'; +import { AgentError } from './errors.js'; + +/** + * Abstract base class for all agents. + * Extend this to create custom agents with specific behaviors. + */ +export abstract class BaseAgent extends EventEmitter { + // --- Required properties (must be set by subclasses) --- + /** Unique agent identifier */ + abstract name: string; + + /** Human-readable description of what this agent does */ + abstract description: string; + + /** + * Mode this agent runs in. Each agent owns a full ModeConfig including its + * system prompt, allowed tools, workflow, and tool-search policy. The mode + * is registered with the Toolpack on first run and activated for every + * invocation. + * + * Use built-in modes (AGENT_MODE, CHAT_MODE, CODING_MODE) as a base, or + * compose a custom ModeConfig. + */ + abstract mode: ModeConfig | string; + + // --- Optional identity properties --- + /** Provider override (e.g., 'anthropic', 'openai') - inherits from Toolpack if not set */ + provider?: string; + + /** Model override - inherits from provider default if not set */ + model?: string; + + // --- Optional behavior properties --- + /** Workflow configuration merged on top of mode config */ + workflow?: Record; + + /** + * Conversation history store. Auto-initialised to `InMemoryConversationStore` in the + * constructor so subclass field initialisers (e.g. `interceptors = [createCaptureInterceptor({ + * store: this.conversationHistory })]`) can reference it safely. Replace with a + * database-backed implementation for production persistence. + */ + conversationHistory: ConversationStore; + + /** + * Options forwarded to `assemblePrompt()` when `run()` builds LLM context from history. + * Defaults to `assemblePrompt`'s own defaults (addressed-only mode on, 3 000-token budget). + */ + assemblerOptions?: AssemblerOptions; + + /** Channels this agent listens on and sends responses to */ + channels: ChannelInterface[] = []; + + /** Interceptors applied to every inbound message before invokeAgent is called */ + interceptors: Interceptor[] = []; + + // --- Internal references --- + /** Reference to the registry for sendTo() and delegation support */ + _registry?: IAgentRegistry; + + /** + * Invocation-scoped context fields — set by `_bindChannel` immediately before + * calling `invokeAgent` and read inside `run()`, `ask()`, and `delegate()`. + * + * KNOWN LIMITATION: these are instance-level fields, not async-local storage. + * Two different conversations processed concurrently by the same agent can + * clobber each other's values. The conversation lock serialises within a single + * conversationId, but distinct conversationIds run concurrently. + * + * Fix: replace with `AsyncLocalStorage` in a future release. For now, agents + * that call `this.run()` while processing multiple concurrent conversations + * should pass `conversationId` explicitly to avoid relying on these fields. + */ + _triggeringChannel?: string; + _conversationId?: string; + _isTriggerChannel?: boolean; + + protected toolpack!: Toolpack; + + private readonly _initConfig?: { apiKey: string; provider?: string; model?: string }; + private _ownedToolpack = false; + private readonly _conversationLocks = new Map>(); + + constructor(options: BaseAgentOptions) { + super(); + // Auto-init here (before child field initialisers run) so that subclass + // field expressions like `interceptors = [createCaptureInterceptor({ store: + // this.conversationHistory })]` see a live store, not undefined. + this.conversationHistory = new InMemoryConversationStore(); + if ('toolpack' in options) { + this.toolpack = options.toolpack; + } else { + this._initConfig = options; + } + } + + /** + * Ensure the Toolpack instance is ready. + * No-op if the toolpack was provided at construction time. + * Creates and owns the instance from `apiKey` if it was not. + */ + async _ensureToolpack(): Promise { + if (this.toolpack) return; + if (!this._initConfig) { + throw new Error(`[${this.name ?? 'agent'}] Cannot start: no apiKey or toolpack provided`); + } + this.toolpack = await Toolpack.init({ + provider: this._initConfig.provider ?? 'anthropic', + apiKey: this._initConfig.apiKey, + model: this._initConfig.model, + }); + this._ownedToolpack = true; + } + + /** + * Start the agent: initialise Toolpack (if needed), bind message handlers to all + * configured channels, and begin listening. + * + * When using AgentRegistry, the registry calls this after setting `_registry`. + * For standalone single-agent deployments, call this directly. + */ + async start(): Promise { + await this._ensureToolpack(); + // Register and activate the agent's mode as the Toolpack default so the + // startup log reflects the agent (e.g. "Kael") instead of the built-in + // default ("Chat"). + if (this.mode) { + if (typeof this.mode === 'string') { + this.toolpack.setMode(this.mode); + } else { + this.toolpack.registerMode(this.mode); + this.toolpack.setMode(this.mode.name); + } + } + for (const channel of this.channels) { + this._bindChannel(channel); + channel.listen(); + } + } + + /** + * Stop all channels and release resources owned by this agent. + */ + async stop(): Promise { + for (const channel of this.channels) { + if ('stop' in channel && typeof (channel as { stop?: unknown }).stop === 'function') { + await (channel as { stop: () => Promise }).stop(); + } + } + if (this._ownedToolpack) { + await this.toolpack.disconnect?.(); + } + } + + /** + * Main entry point for agent invocation. + * Implement this to handle incoming messages and route to appropriate logic. + */ + abstract invokeAgent(input: AgentInput): Promise; + + /** + * Execute the agent using the Toolpack SDK. + * + * @param message - The user message to process. + * @param _options - Optional per-run workflow overrides. + * @param context - Optional context overrides. Supply `conversationId` here when + * invoking from `invokeAgent()` to avoid the instance-level `_conversationId` + * race that occurs when the same agent handles multiple concurrent conversations. + */ + protected async run( + message: string, + _options?: AgentRunOptions, + context?: { conversationId?: string }, + ): Promise { + // Prefer the explicitly supplied conversationId; fall back to the + // instance-level field (set by _bindChannel) for channel-driven invocations. + const convId = context?.conversationId ?? this._conversationId; + + await this.onBeforeRun({ message, conversationId: convId } as AgentInput); + this.emit('agent:start', { message }); + + try { + // Register-then-activate. registerMode is idempotent for the same name, + // so calling it on every run is cheap and avoids requiring callers to + // pre-wire the mode in Toolpack.init({ customModes }). + if (typeof this.mode === 'string') { + this.toolpack.setMode(this.mode); + } else { + this.toolpack.registerMode(this.mode); + this.toolpack.setMode(this.mode.name); + } + + // System prompt is now owned by the mode and injected by the Toolpack + // client (see injectModeSystemPrompt). BaseAgent no longer pushes its + // own system message. + const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = []; + + // Load history via assemblePrompt: proper multi-participant projection, + // addressed-only mode, token budget, and rolling summary support. + // Writes are handled exclusively by the capture-history interceptor — + // run() is a read-only consumer of history. + if (convId) { + try { + const assembled = await assemblePrompt( + this.conversationHistory, + convId, + this.name, + this.name, + this._resolveAssemblerOptions(), + ); + messages.push(...assembled.messages); + } catch { + // History fetch failure is non-fatal — continue without context. + } + } + + messages.push({ role: 'user', content: message }); + + // Expose a search tool when a conversation is active so the LLM can + // retrieve specific past turns beyond the assembled context window. + const requestTools: RequestToolDefinition[] = []; + if (convId) { + const store = this.conversationHistory; + requestTools.push({ + name: 'conversation_search', + displayName: 'Conversation Search', + description: 'Search past conversation history for specific information, questions, or topics mentioned earlier in this conversation.', + category: 'search', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Keywords or phrases to search for in conversation history.' }, + limit: { type: 'number', description: 'Maximum number of results to return (default: 5).' }, + }, + required: ['query'], + }, + execute: async (args: Record) => { + // Pillar 2 invariant: `convId` is closure-captured from run() intentionally. + // Do NOT accept `args.conversationId` or any other channel/conversation + // identifier from the LLM — doing so would let an adversarial prompt + // reach turns from a different conversation. See §1.6 of + // development/plan-docs/AGENT_CONFIDENTIALITY_AND_KNOWLEDGE.md. + const results = await store.search(convId, String(args.query ?? ''), { + limit: typeof args.limit === 'number' ? args.limit : 5, + }); + return { + results: results.map(m => ({ + role: m.participant.kind === 'agent' ? 'assistant' : 'user', + content: m.content, + timestamp: m.timestamp, + })), + count: results.length, + }; + }, + }); + } + + const result = await this.toolpack.generate( + { + messages, + model: this.model || '', + requestTools: requestTools.length > 0 ? requestTools : undefined, + }, + this.provider + ); + + const agentResult: AgentResult = { + output: result.content || '', + steps: this.extractSteps(result), + metadata: result.usage ? { usage: result.usage } : undefined, + }; + + await this.onComplete(agentResult); + this.emit('agent:complete', agentResult); + + return agentResult; + } catch (error) { + await this.onError(error as Error); + this.emit('agent:error', error); + throw error; + } + } + + /** + * Returns extra identity strings (platform user ids, bot ids) that should + * be treated as this agent for the purposes of `addressed-only` mode in + * `assemblePrompt`. + * + * The default implementation collects `botUserId` from every attached channel + * that exposes it (e.g. `SlackChannel` after `auth.test` resolves). Override + * this to add further aliases. + */ + protected getAgentAliases(): string[] { + const aliases: string[] = []; + for (const channel of this.channels) { + const botUserId = (channel as unknown as { botUserId?: string }).botUserId; + if (botUserId) aliases.push(botUserId); + } + return aliases; + } + + /** + * Send a message to a named channel via the registry. + */ + protected async sendTo(channelName: string, message: string): Promise { + if (!this._registry) { + throw new Error('Agent not registered - _registry not set'); + } + await this._registry.sendTo(channelName, { output: message }); + } + + /** + * Ask the user a question and pause execution. + */ + protected async ask( + question: string, + options?: { + context?: Record; + maxRetries?: number; + expiresIn?: number; + } + ): Promise { + if (!this._registry) { + throw new AgentError('Agent not registered - cannot use ask()'); + } + + if (!this._conversationId) { + throw new AgentError('No conversationId available - ask() requires a conversation channel'); + } + + if (this._isTriggerChannel) { + throw new AgentError( + 'this.ask() called from a trigger channel (ScheduledChannel). ' + + 'Trigger channels have no human recipient — use a conversation channel (Slack, Telegram, Webhook) instead.' + ); + } + + if (!this._triggeringChannel || this._triggeringChannel.trim() === '') { + throw new AgentError( + 'Cannot use ask() - no triggering channel available. ' + + 'The channel must have a name registered with AgentRegistry.' + ); + } + + const pendingAsk = this._registry.addPendingAsk({ + conversationId: this._conversationId, + agentName: this.name, + question, + context: options?.context ?? {}, + maxRetries: options?.maxRetries ?? 2, + expiresAt: options?.expiresIn ? new Date(Date.now() + options.expiresIn) : undefined, + channelName: this._triggeringChannel, + }); + + await this.sendTo(this._triggeringChannel, question); + + return { + output: question, + metadata: { + waitingForHuman: true, + askId: pendingAsk.id, + }, + }; + } + + /** + * Get the current pending ask for a conversation. + */ + protected getPendingAsk(conversationId?: string): PendingAsk | null { + if (!this._registry) { + return null; + } + const convId = conversationId ?? this._conversationId; + if (!convId) { + return null; + } + return this._registry.getPendingAsk(convId) ?? null; + } + + /** + * Resolve a pending ask with an answer. + */ + protected async resolvePendingAsk(id: string, answer: string): Promise { + if (!this._registry) { + throw new AgentError('Agent not registered - cannot resolve ask'); + } + await this._registry.resolvePendingAsk(id, answer); + } + + /** + * Evaluate if an answer sufficiently addresses a question. + */ + protected async evaluateAnswer( + question: string, + answer: string, + options?: { + simpleValidation?: (answer: string) => boolean; + } + ): Promise { + if (options?.simpleValidation) { + return options.simpleValidation(answer); + } + + const result = await this.run( + `Evaluate if this answer sufficiently addresses the question.\n\nQuestion: "${question}"\nAnswer: "${answer}"\n\nIs this answer sufficient? Reply with ONLY "yes" or "no".`, + { workflow: { mode: 'single-shot' } } + ); + + return result.output.toLowerCase().trim().startsWith('yes'); + } + + /** + * Handle a pending ask reply with automatic retry logic. + */ + protected async handlePendingAsk( + pending: PendingAsk, + reply: string, + onSufficient: (answer: string) => Promise | AgentResult, + onInsufficient?: () => Promise | AgentResult + ): Promise { + const sufficient = await this.evaluateAnswer(pending.question, reply, { + simpleValidation: (a) => a.trim().length > 3, + }); + + if (sufficient) { + await this.resolvePendingAsk(pending.id, reply); + return onSufficient(reply); + } + + if (pending.retries >= pending.maxRetries) { + await this.resolvePendingAsk(pending.id, '__insufficient__'); + + if (this._triggeringChannel) { + await this.sendTo( + this._triggeringChannel, + 'I was unable to get enough information to proceed. Skipping this step.' + ); + } + + if (onInsufficient) { + return onInsufficient(); + } + + return { + output: 'Step skipped due to insufficient input.', + metadata: { skipped: true, askId: pending.id }, + }; + } + + this._registry?.incrementRetries(pending.id); + + return this.ask( + `I need a bit more clarity on: "${pending.question}". Could you provide more details?`, + { + context: pending.context, + maxRetries: pending.maxRetries, + } + ); + } + + /** + * Delegate a task to another agent by name (fire-and-forget). + */ + protected async delegate( + agentName: string, + input: Partial + ): Promise { + if (!this._registry) { + throw new AgentError('Agent not registered - cannot use delegate()'); + } + + const fullInput: AgentInput = { + message: input.message, + intent: input.intent, + data: input.data, + context: { + ...(input.context || {}), + delegatedBy: this.name, + }, + conversationId: input.conversationId || this._conversationId || `delegation-${Date.now()}`, + }; + + this._registry.invoke(agentName, fullInput).catch((error: Error) => { + console.error(`[${this.name}] Delegation to ${agentName} failed:`, error.message); + }); + } + + /** + * Delegate a task to another agent and wait for the result. + */ + protected async delegateAndWait( + agentName: string, + input: Partial + ): Promise { + if (!this._registry) { + throw new AgentError('Agent not registered - cannot use delegateAndWait()'); + } + + const fullInput: AgentInput = { + message: input.message, + intent: input.intent, + data: input.data, + context: { + ...(input.context || {}), + delegatedBy: this.name, + }, + conversationId: input.conversationId || this._conversationId || `delegation-${Date.now()}`, + }; + + return await this._registry.invoke(agentName, fullInput); + } + + // --- Lifecycle hooks (override in subclasses) --- + + async onBeforeRun(_input: AgentInput): Promise {} + + async onStepComplete(_step: WorkflowStep): Promise {} + + async onComplete(_result: AgentResult): Promise {} + + async onError(_error: Error): Promise {} + + // --- Private helpers --- + + /** + * Build the `AssemblerOptions` used for this call to `assemblePrompt`. + * + * Merges any subclass-provided `assemblerOptions.agentAliases` with platform-bot + * identities discovered on configured channels (e.g. `SlackChannel.botUserId`, + * `TelegramChannel.botUserId`). Read lazily on each `run()` so that identities + * populated asynchronously by each channel's startup self-check are picked up + * without a race. + */ + private _resolveAssemblerOptions(): AssemblerOptions | undefined { + const channelAliases = this.channels + .map(c => (c as { botUserId?: string }).botUserId) + .filter((x): x is string => typeof x === 'string' && x.length > 0); + + const manualAliases = this.assemblerOptions?.agentAliases ?? []; + + if (channelAliases.length === 0 && manualAliases.length === 0) { + return this.assemblerOptions; + } + + const merged = Array.from(new Set([...manualAliases, ...channelAliases])); + return { ...this.assemblerOptions, agentAliases: merged }; + } + + /** + * Returns the effective interceptor list for a channel binding. Prepends + * `createCaptureInterceptor` automatically so every inbound message and + * agent reply is persisted without manual wiring. The `CAPTURE_INTERCEPTOR_MARKER` + * check prevents double-registration if the developer already added one. + */ + private _getEffectiveInterceptors(): Interceptor[] { + const alreadyHasCapture = this.interceptors.some( + i => (i as unknown as Record)[CAPTURE_INTERCEPTOR_MARKER] === true + ); + if (alreadyHasCapture) return this.interceptors; + return [ + createCaptureInterceptor({ store: this.conversationHistory }), + ...this.interceptors, + ]; + } + + /** + * Bind a message handler to a channel. + * Extracted here so both standalone start() and AgentRegistry can reuse the same logic. + */ + + private _bindChannel(channel: ChannelInterface): void { + channel.onMessage(async (input: AgentInput) => { + if (!input.conversationId) { + console.warn(`[${this.name}] Message received without conversationId — skipping`); + return; + } + + const releaseLock = await this._acquireConversationLock(input.conversationId); + let detachStepUpdates: () => void = () => {}; + + try { + this._triggeringChannel = channel.name; + this._isTriggerChannel = channel.isTriggerChannel; + this._conversationId = input.conversationId; + + detachStepUpdates = this._attachWorkflowStepUpdates(channel, input); + + const chain = composeChain( + this._getEffectiveInterceptors(), + this, channel, this._registry ?? null + ); + const chainResult = await executeChain(chain, input); + if (chainResult === null) return; + const result: AgentOutput = { output: chainResult.output, metadata: chainResult.metadata }; + + await channel.send({ + output: result.output, + metadata: { + ...result.metadata, + conversationId: input.conversationId, + ...input.context, + }, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred'; + console.error(`[${this.name}] Error in agent invocation: ${errorMessage}`); + try { + await channel.send({ + output: `Error: ${errorMessage}`, + metadata: { + conversationId: input.conversationId, + error: true, + ...input.context, + }, + }); + } catch (sendError) { + console.error(`[${this.name}] Failed to send error to channel: ${sendError}`); + } + } finally { + detachStepUpdates(); + releaseLock(); + } + }); + } + + private _attachWorkflowStepUpdates(channel: ChannelInterface, input: AgentInput): () => void { + // Trigger channels have no human recipient, so skip step-by-step sends. + if (channel.isTriggerChannel) { + return () => {}; + } + + const planIds = new Set(); + const sentStepIds = new Set(); + + const onPlanCreated = (plan: any) => { + if (plan?.id) { + planIds.add(String(plan.id)); + } + }; + + const onStepComplete = (step: any, plan: any) => { + if (!plan?.id || !planIds.has(String(plan.id))) return; + if (!step?.result?.output || typeof step.result.output !== 'string') return; + if (plan?.steps?.length && Number(plan.steps.length) <= 1) return; + + const stepId = `${String(plan.id)}:${String(step.id ?? step.number ?? 'unknown')}`; + if (sentStepIds.has(stepId)) return; + sentStepIds.add(stepId); + + const rawOutput = step.result.output.trim(); + if (!rawOutput) return; + + const output = rawOutput.length > 3500 + ? `${rawOutput.slice(0, 3500)}\n... [truncated]` + : rawOutput; + + const prefix = `Step ${step.number}: ${step.description || 'Completed'}`; + + void channel.send({ + output: `${prefix}\n\n${output}`, + metadata: { + conversationId: input.conversationId, + ...input.context, + }, + }).catch(err => { + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[${this.name}] Failed to send workflow step update: ${msg}`); + }); + }; + + this.toolpack.on('workflow:plan_created', onPlanCreated); + this.toolpack.on('workflow:step_complete', onStepComplete); + + return () => { + this.toolpack.off('workflow:plan_created', onPlanCreated); + this.toolpack.off('workflow:step_complete', onStepComplete); + }; + } + + private async _acquireConversationLock(conversationId: string): Promise<() => void> { + while (this._conversationLocks.has(conversationId)) { + try { + await this._conversationLocks.get(conversationId); + } catch { + // Previous lock holder failed — proceed + } + } + + let releaseLock!: () => void; + const lock = new Promise(resolve => { releaseLock = resolve; }); + this._conversationLocks.set(conversationId, lock); + + return () => { + this._conversationLocks.delete(conversationId); + releaseLock(); + }; + } + + private extractSteps(result: unknown): WorkflowStep[] | undefined { + const r = result as Record; + + if (r.plan && typeof r.plan === 'object') { + const plan = r.plan as Record; + if (Array.isArray(plan.steps)) { + return plan.steps.map((step: Record) => ({ + number: (step.number as number) || 0, + description: (step.description as string) || '', + status: (step.status as WorkflowStep['status']) || 'completed', + result: step.result as WorkflowStep['result'], + })); + } + } + + if (Array.isArray(r.steps)) { + return r.steps as WorkflowStep[]; + } + + return undefined; + } +} + +// Agent event types for TypeScript users +export interface AgentEvents { + 'agent:start': (input: AgentInput) => void; + 'agent:step': (step: WorkflowStep) => void; + 'agent:complete': (result: AgentResult) => void; + 'agent:error': (error: Error) => void; +} diff --git a/packages/toolpack-agents/src/agent/errors.ts b/packages/toolpack-agents/src/agent/errors.ts new file mode 100644 index 0000000..0893737 --- /dev/null +++ b/packages/toolpack-agents/src/agent/errors.ts @@ -0,0 +1,9 @@ +/** + * Custom error class for agent-related errors. + */ +export class AgentError extends Error { + constructor(message: string) { + super(message); + this.name = 'AgentError'; + } +} diff --git a/packages/toolpack-agents/src/agent/types.test.ts b/packages/toolpack-agents/src/agent/types.test.ts new file mode 100644 index 0000000..58c955d --- /dev/null +++ b/packages/toolpack-agents/src/agent/types.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect } from 'vitest'; +import type { + AgentInput, + AgentResult, + AgentOutput, + AgentRunOptions, + WorkflowStep, + IAgentRegistry, + AgentInstance, + ChannelInterface, +} from './types.js'; + +describe('Agent Types', () => { + describe('AgentInput', () => { + it('should create a valid AgentInput', () => { + const input: AgentInput = { + intent: 'test_intent', + message: 'Hello agent', + data: { key: 'value' }, + context: { user: 'test' }, + conversationId: 'thread-123', + }; + + expect(input.intent).toBe('test_intent'); + expect(input.message).toBe('Hello agent'); + expect(input.conversationId).toBe('thread-123'); + }); + + it('should create a minimal AgentInput', () => { + const input: AgentInput = { + message: 'Test', + }; + + expect(input.message).toBe('Test'); + }); + + it('should support typed intents', () => { + type TestIntent = 'intent_a' | 'intent_b'; + const input: AgentInput = { + intent: 'intent_a', + message: 'Test', + }; + + expect(input.intent).toBe('intent_a'); + }); + }); + + describe('AgentResult', () => { + it('should create a valid AgentResult', () => { + const result: AgentResult = { + output: 'Response from agent', + steps: [ + { + number: 1, + description: 'Step 1', + status: 'completed', + }, + ], + metadata: { key: 'value' }, + }; + + expect(result.output).toBe('Response from agent'); + expect(result.steps).toHaveLength(1); + expect(result.metadata).toEqual({ key: 'value' }); + }); + + it('should create a minimal AgentResult', () => { + const result: AgentResult = { + output: 'Simple response', + }; + + expect(result.output).toBe('Simple response'); + }); + }); + + describe('AgentOutput', () => { + it('should create a valid AgentOutput', () => { + const output: AgentOutput = { + output: 'Channel message', + metadata: { chatId: 12345 }, + }; + + expect(output.output).toBe('Channel message'); + expect(output.metadata).toEqual({ chatId: 12345 }); + }); + }); + + describe('WorkflowStep', () => { + it('should create a valid WorkflowStep', () => { + const step: WorkflowStep = { + number: 1, + description: 'Process data', + status: 'completed', + result: { + success: true, + output: 'Data processed', + toolsUsed: ['tool1'], + duration: 1000, + }, + }; + + expect(step.number).toBe(1); + expect(step.description).toBe('Process data'); + expect(step.status).toBe('completed'); + expect(step.result?.success).toBe(true); + }); + + it('should support all status values', () => { + const statuses: WorkflowStep['status'][] = [ + 'pending', + 'in_progress', + 'completed', + 'failed', + 'skipped', + ]; + + for (const status of statuses) { + const step: WorkflowStep = { + number: 1, + description: 'Test', + status, + }; + expect(step.status).toBe(status); + } + }); + }); + + describe('IAgentRegistry', () => { + it('should define IAgentRegistry structure', () => { + const mockRegistry: IAgentRegistry = { + start: async () => {}, + stop: async () => {}, + sendTo: async () => {}, + getAgent: () => undefined, + getAllAgents: () => [], + getChannel: () => undefined, + invoke: async () => ({ output: '' }), + getPendingAsk: () => undefined, + addPendingAsk: (ask) => ({ ...ask, id: 'test', askedAt: new Date(), retries: 0, status: 'pending' }), + resolvePendingAsk: async () => {}, + hasPendingAsks: () => false, + incrementRetries: () => undefined, + cleanupExpiredAsks: () => 0, + }; + + expect(mockRegistry.start).toBeDefined(); + expect(mockRegistry.sendTo).toBeDefined(); + expect(mockRegistry.invoke).toBeDefined(); + }); + }); + + describe('AgentInstance', () => { + it('should define AgentInstance structure', () => { + // Type-only test + type TestInstance = AgentInstance<'test'>; + expect(true).toBe(true); + }); + }); + + describe('ChannelInterface', () => { + it('should define ChannelInterface structure', () => { + // Create a mock implementation + const mockChannel: ChannelInterface = { + name: 'test-channel', + listen: () => {}, + send: async () => {}, + normalize: (incoming) => ({ + message: String(incoming), + }), + onMessage: () => {}, + }; + + expect(mockChannel.name).toBe('test-channel'); + expect(mockChannel.listen).toBeDefined(); + expect(mockChannel.send).toBeDefined(); + expect(mockChannel.normalize).toBeDefined(); + expect(mockChannel.onMessage).toBeDefined(); + }); + }); +}); diff --git a/packages/toolpack-agents/src/agent/types.ts b/packages/toolpack-agents/src/agent/types.ts new file mode 100644 index 0000000..8a93464 --- /dev/null +++ b/packages/toolpack-agents/src/agent/types.ts @@ -0,0 +1,352 @@ +import type { Toolpack, Participant, ModeConfig } from 'toolpack-sdk'; +import type { EventEmitter } from 'events'; +import type { Interceptor } from '../interceptors/types.js'; + +export type { Participant }; + +/** + * Options for constructing a BaseAgent. + * + * - `{ apiKey, provider?, model? }` — agent creates and owns its own Toolpack instance. + * The instance is initialised lazily in `start()`. + * - `{ toolpack }` — agent uses a shared Toolpack instance (e.g. passed from AgentRegistry + * for multi-agent setups where API client and config are shared). + */ +export type BaseAgentOptions = + | { apiKey: string; provider?: string; model?: string } + | { toolpack: Toolpack }; + + +/** + * Input structure for agent invocation. + * Channels normalize external events into this format. + */ +export interface AgentInput { + /** Typed intent for routing decisions - compile-time safe when using generics */ + intent?: TIntent; + + /** Natural language message from the user */ + message?: string; + + /** Structured payload from the channel */ + data?: unknown; + + /** Additional context for the agent */ + context?: Record; + + /** Channel-agnostic thread/session identifier for conversation continuity */ + conversationId?: string; + + /** + * The participant who produced this message, as populated by the channel + * during `normalize()` or resolved later via `channel.resolveParticipant`. + * Interceptors such as `participant-resolver` read and/or enrich this. + */ + participant?: Participant; +} + +/** + * Represents a step in a workflow execution. + * This is a simplified interface that captures essential step information. + */ +export interface WorkflowStep { + /** Step number (1-indexed) */ + number: number; + + /** Human-readable description of the step */ + description: string; + + /** Step execution status */ + status: 'pending' | 'in_progress' | 'completed' | 'failed' | 'skipped'; + + /** Result after completion (if available) */ + result?: { + success: boolean; + output?: string; + error?: string; + toolsUsed?: string[]; + duration?: number; + }; +} + +/** + * Result structure returned by agents. + */ +export interface AgentResult { + /** The agent's response/output */ + output: string; + + /** Workflow steps taken during execution (populated by run()) */ + steps?: WorkflowStep[]; + + /** Optional metadata for routing decisions or post-processing */ + metadata?: Record; +} + +/** + * Output structure sent to channels. + */ +export interface AgentOutput { + output: string; + metadata?: Record; +} + +/** + * Options for a single agent run. + */ +export interface AgentRunOptions { + /** One-off workflow override for this specific run */ + workflow?: Record; +} + +/** + * Agent instance interface - shape of a BaseAgent instance. + * This represents the public API surface of any agent. + */ +export interface AgentInstance extends EventEmitter { + /** Unique name of the agent */ + name: string; + + /** Human-readable description of the agent's purpose */ + description: string; + + /** LLM mode used by this agent (full ModeConfig or a registered mode name) */ + mode: ModeConfig | string; + + /** Channels this agent listens on and sends responses to */ + channels: ChannelInterface[]; + + /** Interceptors applied to every inbound message before invokeAgent is called */ + interceptors: Interceptor[]; + + /** + * Main entry point for agent execution. + * @param input The input containing message, intent, context, etc. + * @returns The agent's result including output and metadata + */ + invokeAgent(input: AgentInput): Promise; + + /** + * Start the agent: initialise Toolpack (if not provided), bind message handlers + * to all configured channels, and begin listening. + */ + start(): Promise; + + /** Stop all channels and release owned resources. */ + stop(): Promise; + + /** + * Ensure the internal Toolpack instance is ready. + * Called by AgentRegistry before start() so the toolpack is available + * when _registry is set. + */ + _ensureToolpack(): Promise; + + /** Internal reference to the agent registry (set before start() by AgentRegistry) */ + _registry?: IAgentRegistry; + + /** Name of the channel that triggered this agent */ + _triggeringChannel?: string; + + /** Conversation ID for maintaining context across interactions */ + _conversationId?: string; + + /** Whether the triggering channel is a trigger channel (no human recipient) */ + _isTriggerChannel?: boolean; +} + +/** + * Channel interface for connecting agents to external systems. + * Channels normalize incoming messages to AgentInput and send AgentOutput back. + */ +export interface ChannelInterface { + /** Optional channel name for identification */ + name?: string; + + /** + * Whether this is a trigger channel (no human recipient). + * Trigger channels cannot use ask() - they must be fire-and-forget. + */ + isTriggerChannel: boolean; + + /** + * Start listening for incoming messages. + * Called by AgentRegistry when the system starts. + */ + listen(): void; + + /** + * Send output back to the external system. + * @param output The output to send + */ + send(output: AgentOutput): Promise; + + /** + * Normalize raw incoming data to AgentInput format. + * @param incoming Raw data from the external system + * @returns Normalized AgentInput + */ + normalize(incoming: unknown): AgentInput; + + /** + * Register a handler for incoming messages. + * @param handler Function to process incoming AgentInput + */ + onMessage(handler: (input: AgentInput) => Promise): void; + + /** + * Optional hook to resolve richer `Participant` details (e.g. display name) + * for a normalized input. + * + * Design: + * - **Lazy.** Called at render/interceptor time, not during `normalize()`, + * so capture stays cheap. + * - **Cacheable.** Implementations should cache per-process and invalidate + * on explicit platform signals (e.g. Slack `user_change`). + * - **Fallback-safe.** If resolution fails, return `undefined` so the + * pipeline can fall back to the id. Must never throw on miss. + * + * The returned participant is merged into `input.participant` by the + * `participant-resolver` interceptor. If the channel cannot resolve + * anything, it should return `undefined`. + */ + resolveParticipant?(input: AgentInput): Promise | Participant | undefined; +} + +/** + * Alias for ChannelInterface to match spec naming convention. + * @deprecated Use ChannelInterface for new code + */ +export type BaseChannel = ChannelInterface; + +/** + * Represents a pending human-in-the-loop question. + * Stored in-memory in PendingAsksStore (inside AgentRegistry). + */ +export interface PendingAsk { + /** Unique identifier for this ask */ + id: string; + + /** Ties ask to the conversation thread */ + conversationId: string; + + /** Agent that created this ask */ + agentName: string; + + /** The question sent to the human */ + question: string; + + /** Developer-stored state needed to continue */ + context: Record; + + /** Current status of the ask */ + status: 'pending' | 'answered' | 'expired'; + + /** The human's answer (if status is 'answered') */ + answer?: string; + + /** Number of times this ask has been retried */ + retries: number; + + /** Maximum retry attempts before giving up */ + maxRetries: number; + + /** When the ask was created */ + askedAt: Date; + + /** Optional expiration time */ + expiresAt?: Date; + + /** Channel name to send follow-up questions to (required for auto-send) */ + channelName: string; +} + +/** + * Interface for the AgentRegistry. + * Manages agent instances, channels, pending asks, and agent-to-agent communication. + */ +export interface IAgentRegistry { + /** + * Start all registered agents and their channels. + * Each agent initialises its own Toolpack instance (or uses the shared one it was + * constructed with) before channels begin listening. + */ + start(): Promise; + + /** + * Send output to a specific channel by name. + * @param channelName The name of the channel to send to + * @param output The output to send + */ + sendTo(channelName: string, output: AgentOutput): Promise; + + /** + * Get an agent instance by name. + * @param name The agent name + * @returns The agent instance or undefined if not found + */ + getAgent(name: string): AgentInstance | undefined; + + /** + * Get all registered agent instances. + * @returns Array of all agent instances + */ + getAllAgents(): AgentInstance[]; + + /** + * Get a registered channel by name. + * @param name The channel name + * @returns The channel interface or undefined if not found + */ + getChannel(name: string): ChannelInterface | undefined; + + /** + * Invoke an agent by name through the transport layer. + * Used internally by delegate() and delegateAndWait() on BaseAgent. + * @param agentName The target agent's name + * @param input The invocation input + * @returns The agent's result + */ + invoke(agentName: string, input: AgentInput): Promise; + + /** + * Get a pending ask for a conversation. + * @param conversationId The conversation ID + * @returns The pending ask or undefined + */ + getPendingAsk(conversationId: string): PendingAsk | undefined; + + /** + * Add a new pending ask to the store. + * @param ask The ask data (without auto-generated fields) + * @returns The created PendingAsk with generated fields + */ + addPendingAsk(ask: Omit): PendingAsk; + + /** + * Resolve a pending ask with an answer. + * @param id The ask ID + * @param answer The human's answer + */ + resolvePendingAsk(id: string, answer: string): Promise; + + /** + * Check if a conversation has pending asks. + * @param conversationId The conversation ID + * @returns True if there are pending asks + */ + hasPendingAsks(conversationId: string): boolean; + + /** + * Increment the retry count for a pending ask. + * @param id The ask ID + * @returns The new retry count or undefined if ask not found + */ + incrementRetries(id: string): number | undefined; + + /** + * Clean up expired pending asks. + * @returns Number of asks cleaned up + */ + cleanupExpiredAsks(): number; +} diff --git a/packages/toolpack-agents/src/agents/browser-agent.test.ts b/packages/toolpack-agents/src/agents/browser-agent.test.ts new file mode 100644 index 0000000..ebc3d0a --- /dev/null +++ b/packages/toolpack-agents/src/agents/browser-agent.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { BrowserAgent } from './browser-agent.js'; +import type { Toolpack } from 'toolpack-sdk'; + +const createMockToolpack = () => { + return { + generate: vi.fn().mockResolvedValue({ + content: 'Form filled successfully', + usage: { prompt_tokens: 80, completion_tokens: 40, total_tokens: 120 }, + }), + setMode: vi.fn(), + registerMode: vi.fn(), + } as unknown as Toolpack; +}; + +describe('BrowserAgent', () => { + let mockToolpack: Toolpack; + let agent: BrowserAgent; + + beforeEach(() => { + mockToolpack = createMockToolpack(); + agent = new BrowserAgent({ toolpack: mockToolpack }); + }); + + it('should have correct configuration', () => { + expect(agent.name).toBe('browser-agent'); + expect(agent.description).toContain('Browser'); + expect(agent.mode.name).toBe('browser-agent-mode'); + }); + + it('should have browser-focused system prompt', () => { + expect(agent.mode.systemPrompt).toContain('browser'); + expect(agent.mode.systemPrompt).toContain('web.fetch'); + expect(agent.mode.systemPrompt).toContain('extraction'); + }); + + it('should invoke agent with browser task', async () => { + const input = { + message: 'Fill in the contact form at acme.com/contact', + }; + + const result = await agent.invokeAgent(input); + + expect(mockToolpack.setMode).toHaveBeenCalledWith('browser-agent-mode'); + expect(result).toBeDefined(); + expect(result.output).toBeDefined(); + }); + + it('should handle empty message', async () => { + const input = { + message: undefined, + }; + + const result = await agent.invokeAgent(input); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/toolpack-agents/src/agents/browser-agent.ts b/packages/toolpack-agents/src/agents/browser-agent.ts new file mode 100644 index 0000000..3fd051b --- /dev/null +++ b/packages/toolpack-agents/src/agents/browser-agent.ts @@ -0,0 +1,44 @@ +import { BaseAgentOptions } from './../agent/types.js'; +import { BaseAgent } from '../agent/base-agent.js'; +import { AgentInput, AgentResult } from '../agent/types.js'; +import { CHAT_MODE, type ModeConfig } from 'toolpack-sdk'; + +/** + * Built-in browser agent for web interaction tasks. + * Handles web browsing, form interaction, page extraction, and link following. + * + * @example + * ```ts + * const browserAgent = new BrowserAgent(toolpack); + * const result = await browserAgent.invokeAgent({ + * message: 'Extract all product prices from example.com/products' + * }); + * ``` + */ +const BROWSER_AGENT_MODE: ModeConfig = { + ...CHAT_MODE, + name: 'browser-agent-mode', + systemPrompt: [ + 'You are a browser agent specialized in web interaction and content extraction.', + 'Use web.fetch to retrieve pages, web.screenshot for visual content, and web.extract_links for navigation.', + 'Follow links intelligently to gather comprehensive information.', + 'Extract structured data from web pages when possible.', + 'Be mindful of rate limits and respectful of website resources.', + ].join(' '), +}; + +export class BrowserAgent extends BaseAgent { + name = 'browser-agent'; + description = 'Browser agent for web browsing, form interaction, page extraction, and link following'; + mode = BROWSER_AGENT_MODE; + + constructor(options: BaseAgentOptions) { + super(options); + } + + async invokeAgent(input: AgentInput): Promise { + const result = await this.run(input.message || '', undefined, { conversationId: input.conversationId }); + await this.onComplete(result); + return result; + } +} diff --git a/packages/toolpack-agents/src/agents/coding-agent.test.ts b/packages/toolpack-agents/src/agents/coding-agent.test.ts new file mode 100644 index 0000000..57ca056 --- /dev/null +++ b/packages/toolpack-agents/src/agents/coding-agent.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { CodingAgent } from './coding-agent.js'; +import type { Toolpack } from 'toolpack-sdk'; + +const createMockToolpack = () => { + return { + generate: vi.fn().mockResolvedValue({ + content: 'Code changes completed successfully', + usage: { prompt_tokens: 150, completion_tokens: 75, total_tokens: 225 }, + }), + setMode: vi.fn(), + registerMode: vi.fn(), + } as unknown as Toolpack; +}; + +describe('CodingAgent', () => { + let mockToolpack: Toolpack; + let agent: CodingAgent; + + beforeEach(() => { + mockToolpack = createMockToolpack(); + agent = new CodingAgent({ toolpack: mockToolpack }); + }); + + it('should have correct configuration', () => { + expect(agent.name).toBe('coding-agent'); + expect(agent.description).toContain('Coding'); + expect(agent.mode.name).toBe('coding-agent-mode'); + }); + + it('should have coding-focused system prompt', () => { + expect(agent.mode.systemPrompt).toContain('coding'); + expect(agent.mode.systemPrompt).toContain('coding.*'); + expect(agent.mode.systemPrompt).toContain('best practices'); + }); + + it('should invoke agent with coding task', async () => { + const input = { + message: 'Refactor the auth module to use the new SDK pattern', + }; + + const result = await agent.invokeAgent(input); + + expect(mockToolpack.setMode).toHaveBeenCalledWith('coding-agent-mode'); + expect(result).toBeDefined(); + expect(result.output).toBeDefined(); + }); + + it('should handle empty message', async () => { + const input = { + message: undefined, + }; + + const result = await agent.invokeAgent(input); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/toolpack-agents/src/agents/coding-agent.ts b/packages/toolpack-agents/src/agents/coding-agent.ts new file mode 100644 index 0000000..d97b2a4 --- /dev/null +++ b/packages/toolpack-agents/src/agents/coding-agent.ts @@ -0,0 +1,44 @@ +import { BaseAgentOptions } from './../agent/types.js'; +import { BaseAgent } from '../agent/base-agent.js'; +import { AgentInput, AgentResult } from '../agent/types.js'; +import { CODING_MODE, type ModeConfig } from 'toolpack-sdk'; + +/** + * Built-in coding agent for software development tasks. + * Handles code generation, refactoring, debugging, test writing, and code review. + * + * @example + * ```ts + * const codingAgent = new CodingAgent(toolpack); + * const result = await codingAgent.invokeAgent({ + * message: 'Refactor this function to use async/await' + * }); + * ``` + */ +const CODING_AGENT_MODE: ModeConfig = { + ...CODING_MODE, + name: 'coding-agent-mode', + systemPrompt: [ + 'You are a coding agent specialized in software development tasks.', + 'Use coding.* tools for code analysis, fs.* for file operations, and git.* for version control.', + 'Write clean, idiomatic code following best practices.', + 'Always verify your changes and check for potential issues.', + 'Provide clear explanations of your code changes.', + ].join(' '), +}; + +export class CodingAgent extends BaseAgent { + name = 'coding-agent'; + description = 'Coding agent for code generation, refactoring, debugging, test writing, and code review'; + mode = CODING_AGENT_MODE; + + constructor(options: BaseAgentOptions) { + super(options); + } + + async invokeAgent(input: AgentInput): Promise { + const result = await this.run(input.message || '', undefined, { conversationId: input.conversationId }); + await this.onComplete(result); + return result; + } +} diff --git a/packages/toolpack-agents/src/agents/data-agent.test.ts b/packages/toolpack-agents/src/agents/data-agent.test.ts new file mode 100644 index 0000000..9cf1f26 --- /dev/null +++ b/packages/toolpack-agents/src/agents/data-agent.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { DataAgent } from './data-agent.js'; +import type { Toolpack } from 'toolpack-sdk'; + +const createMockToolpack = () => { + return { + generate: vi.fn().mockResolvedValue({ + content: 'Weekly signup summary generated', + usage: { prompt_tokens: 120, completion_tokens: 60, total_tokens: 180 }, + }), + setMode: vi.fn(), + registerMode: vi.fn(), + } as unknown as Toolpack; +}; + +describe('DataAgent', () => { + let mockToolpack: Toolpack; + let agent: DataAgent; + + beforeEach(() => { + mockToolpack = createMockToolpack(); + agent = new DataAgent({ toolpack: mockToolpack }); + }); + + it('should have correct configuration', () => { + expect(agent.name).toBe('data-agent'); + expect(agent.description).toContain('data'); + expect(agent.mode.name).toBe('data-agent-mode'); + }); + + it('should have data-focused system prompt', () => { + expect(agent.mode.systemPrompt).toContain('data'); + expect(agent.mode.systemPrompt).toContain('db.*'); + expect(agent.mode.systemPrompt).toContain('analysis'); + }); + + it('should invoke agent with data task', async () => { + const input = { + message: 'Generate a weekly summary of signups by region', + }; + + const result = await agent.invokeAgent(input); + + expect(mockToolpack.setMode).toHaveBeenCalledWith('data-agent-mode'); + expect(result).toBeDefined(); + expect(result.output).toBeDefined(); + }); + + it('should handle empty message', async () => { + const input = { + message: undefined, + }; + + const result = await agent.invokeAgent(input); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/toolpack-agents/src/agents/data-agent.ts b/packages/toolpack-agents/src/agents/data-agent.ts new file mode 100644 index 0000000..7fe26d7 --- /dev/null +++ b/packages/toolpack-agents/src/agents/data-agent.ts @@ -0,0 +1,44 @@ +import { BaseAgentOptions } from './../agent/types.js'; +import { BaseAgent } from '../agent/base-agent.js'; +import { AgentInput, AgentResult } from '../agent/types.js'; +import { AGENT_MODE, type ModeConfig } from 'toolpack-sdk'; + +/** + * Built-in data agent for database and data analysis tasks. + * Handles database queries, CSV generation, data analysis, reporting, and aggregation. + * + * @example + * ```ts + * const dataAgent = new DataAgent(toolpack); + * const result = await dataAgent.invokeAgent({ + * message: 'Generate a monthly sales report from the orders table' + * }); + * ``` + */ +const DATA_AGENT_MODE: ModeConfig = { + ...AGENT_MODE, + name: 'data-agent-mode', + systemPrompt: [ + 'You are a data agent specialized in database operations and data analysis.', + 'Use db.* tools for database queries, fs.* for file operations, and http.* for API requests.', + 'Generate clear, well-formatted reports and summaries.', + 'Always validate data integrity and handle errors gracefully.', + 'Provide insights and patterns when analyzing data.', + ].join(' '), +}; + +export class DataAgent extends BaseAgent { + name = 'data-agent'; + description = 'Data agent for database queries, CSV generation, data analysis, reporting, and aggregation'; + mode = DATA_AGENT_MODE; + + constructor(options: BaseAgentOptions) { + super(options); + } + + async invokeAgent(input: AgentInput): Promise { + const result = await this.run(input.message || '', undefined, { conversationId: input.conversationId }); + await this.onComplete(result); + return result; + } +} diff --git a/packages/toolpack-agents/src/agents/index.ts b/packages/toolpack-agents/src/agents/index.ts new file mode 100644 index 0000000..91b0426 --- /dev/null +++ b/packages/toolpack-agents/src/agents/index.ts @@ -0,0 +1,4 @@ +export { ResearchAgent } from './research-agent.js'; +export { CodingAgent } from './coding-agent.js'; +export { DataAgent } from './data-agent.js'; +export { BrowserAgent } from './browser-agent.js'; diff --git a/packages/toolpack-agents/src/agents/research-agent.test.ts b/packages/toolpack-agents/src/agents/research-agent.test.ts new file mode 100644 index 0000000..271a5e2 --- /dev/null +++ b/packages/toolpack-agents/src/agents/research-agent.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ResearchAgent } from './research-agent.js'; +import type { Toolpack } from 'toolpack-sdk'; + +const createMockToolpack = () => { + return { + generate: vi.fn().mockResolvedValue({ + content: 'Research findings: Edge AI is rapidly evolving...', + usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 }, + }), + setMode: vi.fn(), + registerMode: vi.fn(), + } as unknown as Toolpack; +}; + +describe('ResearchAgent', () => { + let mockToolpack: Toolpack; + let agent: ResearchAgent; + + beforeEach(() => { + mockToolpack = createMockToolpack(); + agent = new ResearchAgent({ toolpack: mockToolpack }); + }); + + it('should have correct configuration', () => { + expect(agent.name).toBe('research-agent'); + expect(agent.description).toContain('research'); + expect(agent.mode.name).toBe('research-agent-mode'); + }); + + it('should have research-focused system prompt', () => { + expect(agent.mode.systemPrompt).toContain('research'); + expect(agent.mode.systemPrompt).toContain('web.search'); + expect(agent.mode.systemPrompt).toContain('sources'); + }); + + it('should invoke agent with message', async () => { + const input = { + message: 'Research recent developments in edge AI', + }; + + const result = await agent.invokeAgent(input); + + expect(mockToolpack.setMode).toHaveBeenCalledWith('research-agent-mode'); + expect(result).toBeDefined(); + expect(result.output).toBeDefined(); + }); + + it('should handle empty message', async () => { + const input = { + message: undefined, + }; + + const result = await agent.invokeAgent(input); + + expect(result).toBeDefined(); + }); +}); diff --git a/packages/toolpack-agents/src/agents/research-agent.ts b/packages/toolpack-agents/src/agents/research-agent.ts new file mode 100644 index 0000000..1b38522 --- /dev/null +++ b/packages/toolpack-agents/src/agents/research-agent.ts @@ -0,0 +1,44 @@ +import { BaseAgentOptions } from './../agent/types.js'; +import { BaseAgent } from '../agent/base-agent.js'; +import { AgentInput, AgentResult } from '../agent/types.js'; +import { AGENT_MODE, type ModeConfig } from 'toolpack-sdk'; + +/** + * Built-in research agent for web research and information gathering. + * Specialized in summarization, fact-finding, competitive analysis, and trend monitoring. + * + * @example + * ```ts + * const researchAgent = new ResearchAgent(toolpack); + * const result = await researchAgent.invokeAgent({ + * message: 'Research latest AI regulations in the EU' + * }); + * ``` + */ +const RESEARCH_AGENT_MODE: ModeConfig = { + ...AGENT_MODE, + name: 'research-agent-mode', + systemPrompt: [ + 'You are a research agent specialized in web research and information gathering.', + 'Use web.search to find relevant information, web.fetch to retrieve content, and web.scrape when needed.', + 'Always cite your sources with URLs.', + 'Provide comprehensive, well-structured summaries.', + 'Flag any conflicting information or uncertainty in your findings.', + ].join(' '), +}; + +export class ResearchAgent extends BaseAgent { + name = 'research-agent'; + description = 'Web research agent for summarization, fact-finding, competitive analysis, and trend monitoring'; + mode = RESEARCH_AGENT_MODE; + + constructor(options: BaseAgentOptions) { + super(options); + } + + async invokeAgent(input: AgentInput): Promise { + const result = await this.run(input.message || '', undefined, { conversationId: input.conversationId }); + await this.onComplete(result); + return result; + } +} diff --git a/packages/toolpack-agents/src/capabilities/index.ts b/packages/toolpack-agents/src/capabilities/index.ts new file mode 100644 index 0000000..ee9f027 --- /dev/null +++ b/packages/toolpack-agents/src/capabilities/index.ts @@ -0,0 +1,16 @@ +// Capability agents - reusable agents for cross-cutting concerns +// These agents have no direct channel exposure and are invoked by interceptors or other agents + +export { + IntentClassifierAgent, + IntentClassifierInput, + IntentClassification +} from './intent-classifier-agent.js'; + +export { + SummarizerAgent, + SummarizerInput, + SummarizerOutput, + HistoryTurn, + Participant +} from './summarizer-agent.js'; diff --git a/packages/toolpack-agents/src/capabilities/intent-classifier-agent.test.ts b/packages/toolpack-agents/src/capabilities/intent-classifier-agent.test.ts new file mode 100644 index 0000000..6bf26c3 --- /dev/null +++ b/packages/toolpack-agents/src/capabilities/intent-classifier-agent.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, vi } from 'vitest'; +import { IntentClassifierAgent, IntentClassifierInput, IntentClassification } from './intent-classifier-agent.js'; +import { AgentResult } from '../agent/types.js'; + +// Mock Toolpack +function createMockToolpack(generateResult: string) { + return { + setMode: vi.fn(), + registerMode: vi.fn(), + generate: vi.fn().mockResolvedValue({ + content: generateResult, + usage: { promptTokens: 50, completionTokens: 10, totalTokens: 60 } + }) + } as unknown as import('toolpack-sdk').Toolpack; +} + +describe('IntentClassifierAgent', () => { + describe('DM short-circuit', () => { + it('returns direct without LLM call when isDirectMessage is true', async () => { + const toolpack = createMockToolpack('passive'); + const agent = new IntentClassifierAgent({ toolpack }); + + const result = await agent.invokeAgent({ + message: 'classify', + data: { + message: 'Hello there', + agentName: 'assistant', + agentId: 'U123', + senderName: 'alice', + channelName: 'dm-alice', + isDirectMessage: true + } as IntentClassifierInput + }); + + expect(result.output).toBe('direct'); + expect(result.metadata).toEqual({ + classification: 'direct', + shortCircuit: 'dm' + }); + expect(toolpack.generate).not.toHaveBeenCalled(); + }); + + it('short-circuits even when message is empty', async () => { + const toolpack = createMockToolpack('ignore'); + const agent = new IntentClassifierAgent({ toolpack }); + + const result = await agent.invokeAgent({ + message: 'classify', + data: { + message: '', + agentName: 'assistant', + agentId: 'U123', + senderName: 'alice', + channelName: 'dm-alice', + isDirectMessage: true + } as IntentClassifierInput + }); + + expect(result.output).toBe('direct'); + expect(toolpack.generate).not.toHaveBeenCalled(); + }); + }); + + describe('missing payload', () => { + it('returns ignore when no message provided', async () => { + const toolpack = createMockToolpack('direct'); + const agent = new IntentClassifierAgent({ toolpack }); + + const result = await agent.invokeAgent({ + message: 'classify', + data: undefined + }); + + expect(result.output).toBe('ignore'); + expect(result.metadata).toEqual({ error: 'No message provided for classification' }); + expect(toolpack.generate).not.toHaveBeenCalled(); + }); + + it('returns ignore when message is empty string', async () => { + const toolpack = createMockToolpack('direct'); + const agent = new IntentClassifierAgent({ toolpack }); + + const result = await agent.invokeAgent({ + message: 'classify', + data: { + message: '', + agentName: 'assistant', + agentId: 'U123', + senderName: 'alice', + channelName: 'general', + isDirectMessage: false + } as IntentClassifierInput + }); + + expect(result.output).toBe('ignore'); + expect(toolpack.generate).not.toHaveBeenCalled(); + }); + }); + + describe('normalizeClassification', () => { + async function testNormalization( + llmOutput: string, + expected: IntentClassification + ): Promise { + const toolpack = createMockToolpack(llmOutput); + const agent = new IntentClassifierAgent({ toolpack }); + + const result = await agent.invokeAgent({ + message: 'classify', + data: { + message: 'test message', + agentName: 'assistant', + agentId: 'U123', + senderName: 'bob', + channelName: 'general', + isDirectMessage: false + } as IntentClassifierInput + }); + + expect(result.output).toBe(expected); + } + + describe('exact first-word matches', () => { + it('normalizes "direct" to direct', async () => { + await testNormalization('direct', 'direct'); + }); + + it('normalizes "indirect" to indirect', async () => { + await testNormalization('indirect', 'indirect'); + }); + + it('normalizes "passive" to passive', async () => { + await testNormalization('passive', 'passive'); + }); + + it('normalizes "ignore" to ignore', async () => { + await testNormalization('ignore', 'ignore'); + }); + + it('handles uppercase first word', async () => { + await testNormalization('Direct', 'direct'); + }); + + it('handles mixed case first word', async () => { + await testNormalization('InDiReCt', 'indirect'); + }); + }); + + describe('first word with trailing text', () => { + it('extracts direct from "direct - clearly addressing the agent"', async () => { + await testNormalization('direct - clearly addressing the agent', 'direct'); + }); + + it('extracts indirect from "indirect, the user is mentioning"', async () => { + await testNormalization('indirect, the user is mentioning', 'indirect'); + }); + + it('extracts passive from "passive: no addressing detected"', async () => { + await testNormalization('passive: no addressing detected', 'passive'); + }); + }); + + describe('fuzzy fallback on full output', () => { + it('detects "direct" in full sentence "The message is directly addressing"', async () => { + await testNormalization('The message is directly addressing', 'direct'); + }); + + it('detects "addressed" keyword for direct', async () => { + await testNormalization('This is clearly addressed to the bot', 'direct'); + }); + + it('detects "indirect" in full sentence', async () => { + await testNormalization('The message is indirectly referring', 'indirect'); + }); + + it('detects "mention" keyword for indirect', async () => { + await testNormalization('Just mentioning the agent here', 'indirect'); + }); + + it('detects "passive" in full sentence', async () => { + await testNormalization('The agent should passively observe', 'passive'); + }); + + it('detects "listen" keyword for passive', async () => { + await testNormalization('Agent should just listen', 'passive'); + }); + + it('detects "ignore" in full sentence', async () => { + await testNormalization('This message should be ignored', 'ignore'); + }); + + it('detects "skip" keyword for ignore', async () => { + await testNormalization('Skip this message', 'ignore'); + }); + }); + + describe('unrecognized output', () => { + it('defaults to ignore for empty string', async () => { + await testNormalization('', 'ignore'); + }); + + it('defaults to ignore for whitespace', async () => { + await testNormalization(' ', 'ignore'); + }); + + it('defaults to ignore for random text', async () => { + await testNormalization('I am a large language model', 'ignore'); + }); + + it('defaults to ignore for "yes" (no keyword match)', async () => { + await testNormalization('yes', 'ignore'); + }); + }); + }); + + describe('metadata', () => { + it('includes raw output and confidence in metadata', async () => { + const toolpack = createMockToolpack('direct response here'); + const agent = new IntentClassifierAgent({ toolpack }); + + const result = await agent.invokeAgent({ + message: 'classify', + data: { + message: '@assistant help', + agentName: 'assistant', + agentId: 'U123', + senderName: 'alice', + channelName: 'general', + isDirectMessage: false + } as IntentClassifierInput + }); + + expect(result.metadata).toMatchObject({ + rawOutput: 'direct response here', + classification: 'direct', + confidence: 'high' + }); + }); + }); +}); diff --git a/packages/toolpack-agents/src/capabilities/intent-classifier-agent.ts b/packages/toolpack-agents/src/capabilities/intent-classifier-agent.ts new file mode 100644 index 0000000..a8e8d07 --- /dev/null +++ b/packages/toolpack-agents/src/capabilities/intent-classifier-agent.ts @@ -0,0 +1,186 @@ +import { BaseAgentOptions } from './../agent/types.js'; +import { BaseAgent } from '../agent/base-agent.js'; +import { AgentInput, AgentResult } from '../agent/types.js'; +import { CHAT_MODE, type ModeConfig } from 'toolpack-sdk'; + +/** + * Input payload for intent classification. + */ +export interface IntentClassifierInput { + /** The message content to classify */ + message: string; + /** The agent's display name (e.g., "Assistant") */ + agentName: string; + /** The agent's unique identifier */ + agentId: string; + /** The sender's display name */ + senderName: string; + /** The conversation channel name */ + channelName: string; + /** Whether this is a direct message (IM) context */ + isDirectMessage?: boolean; + /** Previous message context (last 3 messages for continuity) */ + recentContext?: Array<{ + sender: string; + content: string; + }>; + /** Whether to include classification examples in the prompt (helps tiny models) */ + includeExamples?: boolean; +} + +/** + * Classification result indicating how the message relates to the target agent. + */ +export type IntentClassification = + | 'direct' // Explicitly addressed to the agent (e.g., "@Assistant help me") + | 'indirect' // Mentions agent but not clearly requesting response + | 'passive' // No addressing, agent should listen but not reply + | 'ignore'; // Definitely not for this agent (noise, other bot, etc.) + +/** + * Capability agent that classifies whether a message is directly asking + * the target agent to respond. + * + * Used by the intent-classifier interceptor when the rules-based address + * check is ambiguous. Returns a single-word classification. + * + * Register this agent with an empty channels list to use it as a capability. + * + * @example + * ```ts + * const classifier = new IntentClassifierAgent(toolpack); + * const result = await classifier.invokeAgent({ + * message: 'classify', + * data: { + * message: 'Hey @assistant can you help?', + * agentName: 'assistant', + * agentId: 'U123', + * senderName: 'alice', + * channelName: 'general', + * isDirectMessage: false + * } as IntentClassifierInput + * }); + * // result.output === 'direct' + * ``` + */ +const INTENT_CLASSIFIER_MODE: ModeConfig = { + ...CHAT_MODE, + name: 'intent-classifier-mode', + systemPrompt: [ + 'You classify whether a message is asking an agent to respond.', + '', + 'Categories:', + 'direct = Message uses @mention, name in greeting, possessive, or commands the agent to act', + 'indirect = Agent is mentioned but unclear if response wanted (talking ABOUT, not TO them)', + 'passive = No addressing detected; agent should only listen, not reply', + 'ignore = Definitely not for this agent (noise, code blocks, other bots)', + '', + 'Response must start with one of: direct, indirect, passive, ignore' + ].join('\n'), +}; + +export class IntentClassifierAgent extends BaseAgent { + name = 'intent-classifier'; + description = 'Classifies whether a message is directly addressing an agent for response'; + mode = INTENT_CLASSIFIER_MODE; + + constructor(options: BaseAgentOptions) { + super(options); + } + + async invokeAgent(input: AgentInput): Promise { + const payload = input.data as IntentClassifierInput | undefined; + + // DMs are always direct — bypass classification entirely + if (payload?.isDirectMessage) { + return { + output: 'direct', + metadata: { classification: 'direct', shortCircuit: 'dm' } + }; + } + + if (!payload?.message) { + return { + output: 'ignore', + metadata: { error: 'No message provided for classification' } + }; + } + + const contextLines: string[] = []; + + // DM case already short-circuited above; this only runs for channel messages + contextLines.push(`Context: Public channel #${payload.channelName}`); + + contextLines.push(`Target agent: "${payload.agentName}" (ID: ${payload.agentId})`); + contextLines.push(`Message sender: ${payload.senderName}`); + + if (payload.recentContext && payload.recentContext.length > 0) { + contextLines.push('\nRecent conversation:'); + for (const msg of payload.recentContext) { + contextLines.push(` ${msg.sender}: ${msg.content.substring(0, 100)}`); + } + } + + contextLines.push(`\nMessage to classify: "${payload.message}"`); + + if (payload.includeExamples) { + contextLines.push('\nExamples of classifications:'); + contextLines.push(` "@${payload.agentName} help me" → direct`); + contextLines.push(` "Can someone ask ${payload.agentName} about this?" → indirect`); + contextLines.push(` "I was talking to ${payload.agentName} earlier" → passive`); + contextLines.push(` "Check the logs" → ignore`); + } + + contextLines.push('\nClassification (start with direct, indirect, passive, or ignore):'); + + const prompt = contextLines.join('\n'); + + // Note: per-run mode override reserved for future use (currently uses agent mode) + const result = await this.run(prompt); + + // Normalize output to valid classification + const normalized = this.normalizeClassification(result.output); + + return { + output: normalized, + metadata: { + rawOutput: result.output, + classification: normalized, + confidence: 'high' // Could be enhanced with token probabilities in future + } + }; + } + + /** + * Normalize the LLM output to a valid classification. + */ + private normalizeClassification(output: string): IntentClassification { + const cleaned = output.toLowerCase().trim().split(/\s+/)[0]; + const fullOutput = output.toLowerCase(); + + const validClassifications: IntentClassification[] = ['direct', 'indirect', 'passive', 'ignore']; + + // Exact match on first word + if (validClassifications.includes(cleaned as IntentClassification)) { + return cleaned as IntentClassification; + } + + // Fuzzy fallback: check full output for keywords + // Order matters: check more specific terms before substring matches + if (fullOutput.includes('indirect') || fullOutput.includes('mention')) { + return 'indirect'; + } + if (fullOutput.includes('passive') || fullOutput.includes('listen')) { + return 'passive'; + } + if (fullOutput.includes('ignore') || fullOutput.includes('skip')) { + return 'ignore'; + } + if (fullOutput.includes('direct') || fullOutput.includes('addressed')) { + return 'direct'; + } + + // Default to ignore for any unrecognized output + return 'ignore'; + } +} diff --git a/packages/toolpack-agents/src/capabilities/summarizer-agent.test.ts b/packages/toolpack-agents/src/capabilities/summarizer-agent.test.ts new file mode 100644 index 0000000..1b72e05 --- /dev/null +++ b/packages/toolpack-agents/src/capabilities/summarizer-agent.test.ts @@ -0,0 +1,354 @@ +import { describe, it, expect, vi } from 'vitest'; +import { SummarizerAgent, SummarizerInput, HistoryTurn, Participant } from './summarizer-agent.js'; + +// Mock Toolpack +function createMockToolpack(generateResult: string) { + return { + setMode: vi.fn(), + registerMode: vi.fn(), + generate: vi.fn().mockResolvedValue({ + content: generateResult, + usage: { promptTokens: 200, completionTokens: 50, totalTokens: 250 } + }) + } as unknown as import('toolpack-sdk').Toolpack; +} + +function createParticipant(kind: 'user' | 'agent' | 'system', id: string, displayName?: string): Participant { + return { kind, id, displayName }; +} + +function createTurn(id: string, participant: Participant, content: string, timestamp?: string): HistoryTurn { + return { + id, + participant, + content, + timestamp: timestamp ?? new Date().toISOString() + }; +} + +describe('SummarizerAgent', () => { + describe('empty input handling', () => { + it('returns placeholder when turns array is empty', async () => { + const toolpack = createMockToolpack('{"summary":"test"}'); + const agent = new SummarizerAgent({ toolpack }); + + const result = await agent.invokeAgent({ + message: 'summarize', + data: { + turns: [], + agentName: 'assistant', + agentId: 'U123' + } as SummarizerInput + }); + + const output = JSON.parse(result.output); + expect(output.summary).toBe('(No history to summarize)'); + expect(output.turnsSummarized).toBe(0); + expect(output.hasDecisions).toBe(false); + expect(toolpack.generate).not.toHaveBeenCalled(); + }); + + it('returns placeholder when data is undefined', async () => { + const toolpack = createMockToolpack('{"summary":"test"}'); + const agent = new SummarizerAgent({ toolpack }); + + const result = await agent.invokeAgent({ + message: 'summarize', + data: undefined + }); + + const output = JSON.parse(result.output); + expect(output.summary).toBe('(No history to summarize)'); + expect(toolpack.generate).not.toHaveBeenCalled(); + }); + }); + + describe('parseSummarizerOutput', () => { + async function testParse( + llmOutput: string, + turnCount: number, + expectedSummary: string, + expectedHasDecisions?: boolean + ): Promise { + const toolpack = createMockToolpack(llmOutput); + const agent = new SummarizerAgent({ toolpack }); + + const user = createParticipant('user', 'U1', 'alice'); + const result = await agent.invokeAgent({ + message: 'summarize', + data: { + turns: [createTurn('1', user, 'Hello')], + agentName: 'assistant', + agentId: 'U123' + } as SummarizerInput + }); + + const output = JSON.parse(result.output); + expect(output.summary).toBe(expectedSummary); + expect(output.turnsSummarized).toBe(turnCount); + if (expectedHasDecisions !== undefined) { + expect(output.hasDecisions).toBe(expectedHasDecisions); + } + expect(output.estimatedTokens).toBeGreaterThan(0); + } + + describe('clean JSON parsing', () => { + it('parses clean JSON object', async () => { + await testParse( + '{"summary":"Key point discussed","turnsSummarized":5,"hasDecisions":true,"estimatedTokens":50}', + 5, + 'Key point discussed', + true + ); + }); + + it('handles JSON with whitespace', async () => { + await testParse( + `{ + "summary": "Multi-line summary", + "turnsSummarized": 3, + "hasDecisions": false, + "estimatedTokens": 40 + }`, + 3, + 'Multi-line summary', + false + ); + }); + }); + + describe('JSON in markdown code blocks', () => { + it('parses JSON wrapped in ```json block', async () => { + await testParse( + '```json\n{"summary":"From code block","turnsSummarized":4,"hasDecisions":false,"estimatedTokens":30}\n```', + 4, + 'From code block', + false + ); + }); + + it('parses JSON wrapped in plain ``` block', async () => { + await testParse( + '```\n{"summary":"Plain code block","turnsSummarized":2,"hasDecisions":true,"estimatedTokens":25}\n```', + 2, + 'Plain code block', + true + ); + }); + + it('handles code block with extra whitespace', async () => { + await testParse( + '```json\n\n {"summary":"With whitespace","turnsSummarized":1,"hasDecisions":false,"estimatedTokens":20}\n\n```', + 1, + 'With whitespace', + false + ); + }); + }); + + describe('non-JSON fallback', () => { + it('uses fallback summary for plain text response', async () => { + await testParse( + 'Here is a summary of the conversation that happened earlier', + 1, + '(Summary of 1 conversation turns - key details preserved in full context)', + false + ); + }); + + it('detects "decision" keyword for hasDecisions', async () => { + await testParse( + 'The decision was made to proceed with the plan', + 1, + '(Summary of 1 conversation turns - key details preserved in full context)', + true + ); + }); + + it('detects "action" keyword for hasDecisions', async () => { + await testParse( + 'Action items were assigned to the team', + 1, + '(Summary of 1 conversation turns - key details preserved in full context)', + true + ); + }); + }); + + describe('field validation', () => { + it('uses defaults for missing fields', async () => { + const toolpack = createMockToolpack('{"summary":"Valid"}'); + const agent = new SummarizerAgent({ toolpack }); + const user = createParticipant('user', 'U1', 'alice'); + + const result = await agent.invokeAgent({ + message: 'summarize', + data: { + turns: [createTurn('1', user, 'Hello')], + agentName: 'assistant', + agentId: 'U123' + } as SummarizerInput + }); + + const output = JSON.parse(result.output); + expect(output.summary).toBe('Valid'); + expect(output.turnsSummarized).toBe(1); // defaults to provided count + expect(output.hasDecisions).toBe(false); // defaults to false + expect(output.estimatedTokens).toBeGreaterThan(0); // estimated from output length + }); + + it('uses fallback for empty summary string', async () => { + const toolpack = createMockToolpack('{"summary":"","turnsSummarized":3}'); + const agent = new SummarizerAgent({ toolpack }); + const user = createParticipant('user', 'U1', 'alice'); + + const result = await agent.invokeAgent({ + message: 'summarize', + data: { + turns: [createTurn('1', user, 'Hello'), createTurn('2', user, 'World'), createTurn('3', user, '!')], + agentName: 'assistant', + agentId: 'U123' + } as SummarizerInput + }); + + const output = JSON.parse(result.output); + expect(output.summary).toBe('(Summary of 3 conversation turns - key details preserved in full context)'); + }); + }); + }); + + describe('prompt construction', () => { + it('formats participant with display name', async () => { + const toolpack = createMockToolpack('{"summary":"test"}'); + const agent = new SummarizerAgent({ toolpack }); + + const user = createParticipant('user', 'U1', 'Alice Smith'); + const result = await agent.invokeAgent({ + message: 'summarize', + data: { + turns: [createTurn('1', user, 'Hello there')], + agentName: 'assistant', + agentId: 'U123' + } as SummarizerInput + }); + + expect(toolpack.generate).toHaveBeenCalled(); + const messages = (toolpack.generate as ReturnType).mock.calls[0][0].messages; + const userPrompt = messages[messages.length - 1].content; + expect(userPrompt).toContain('Alice Smith:'); + expect(userPrompt).not.toContain('[BOT]'); + }); + + it('marks agent participants with [BOT] prefix', async () => { + const toolpack = createMockToolpack('{"summary":"test"}'); + const agent = new SummarizerAgent({ toolpack }); + + const bot = createParticipant('agent', 'B1', 'HelperBot'); + const result = await agent.invokeAgent({ + message: 'summarize', + data: { + turns: [createTurn('1', bot, 'How can I help?')], + agentName: 'assistant', + agentId: 'U123' + } as SummarizerInput + }); + + const messages = (toolpack.generate as ReturnType).mock.calls[0][0].messages; + const userPrompt = messages[messages.length - 1].content; + expect(userPrompt).toContain('[BOT] HelperBot:'); + }); + + it('falls back to participant id when no displayName', async () => { + const toolpack = createMockToolpack('{"summary":"test"}'); + const agent = new SummarizerAgent({ toolpack }); + + const user = createParticipant('user', 'U999'); // no displayName + const result = await agent.invokeAgent({ + message: 'summarize', + data: { + turns: [createTurn('1', user, 'Message')], + agentName: 'assistant', + agentId: 'U123' + } as SummarizerInput + }); + + const messages = (toolpack.generate as ReturnType).mock.calls[0][0].messages; + const userPrompt = messages[messages.length - 1].content; + expect(userPrompt).toContain('U999:'); + }); + + it('truncates long messages in prompt', async () => { + const toolpack = createMockToolpack('{"summary":"test"}'); + const agent = new SummarizerAgent({ toolpack }); + + const user = createParticipant('user', 'U1', 'alice'); + const longMessage = 'a'.repeat(300); + const result = await agent.invokeAgent({ + message: 'summarize', + data: { + turns: [createTurn('1', user, longMessage)], + agentName: 'assistant', + agentId: 'U123' + } as SummarizerInput + }); + + const messages = (toolpack.generate as ReturnType).mock.calls[0][0].messages; + const userPrompt = messages[messages.length - 1].content; + expect(userPrompt).toContain('a'.repeat(200)); + expect(userPrompt).toContain('...'); + expect(userPrompt).not.toContain('a'.repeat(250)); + }); + + it('includes tool call metadata when present', async () => { + const toolpack = createMockToolpack('{"summary":"test"}'); + const agent = new SummarizerAgent({ toolpack }); + + const bot = createParticipant('agent', 'B1', 'ToolBot'); + const turn: HistoryTurn = { + id: '1', + participant: bot, + content: 'Searching...', + timestamp: new Date().toISOString(), + metadata: { + isToolCall: true, + toolName: 'web.search' + } + }; + + const result = await agent.invokeAgent({ + message: 'summarize', + data: { + turns: [turn], + agentName: 'assistant', + agentId: 'U123' + } as SummarizerInput + }); + + const messages = (toolpack.generate as ReturnType).mock.calls[0][0].messages; + const userPrompt = messages[messages.length - 1].content; + expect(userPrompt).toContain('[tool: web.search]'); + }); + }); + + describe('metadata', () => { + it('includes turns processed count', async () => { + const toolpack = createMockToolpack('{"summary":"Key discussion happened"}'); + const agent = new SummarizerAgent({ toolpack }); + + const user = createParticipant('user', 'U1', 'alice'); + const result = await agent.invokeAgent({ + message: 'summarize', + data: { + turns: [createTurn('1', user, 'Hello'), createTurn('2', user, 'World')], + agentName: 'assistant', + agentId: 'U123' + } as SummarizerInput + }); + + expect(result.metadata).toMatchObject({ + turnsProcessed: 2, + rawOutputLength: expect.any(Number) + }); + }); + }); +}); diff --git a/packages/toolpack-agents/src/capabilities/summarizer-agent.ts b/packages/toolpack-agents/src/capabilities/summarizer-agent.ts new file mode 100644 index 0000000..9f731fa --- /dev/null +++ b/packages/toolpack-agents/src/capabilities/summarizer-agent.ts @@ -0,0 +1,243 @@ +import { BaseAgentOptions } from './../agent/types.js'; +import { BaseAgent } from '../agent/base-agent.js'; +import { AgentInput, AgentResult, type Participant } from '../agent/types.js'; +import { CHAT_MODE, type ModeConfig } from 'toolpack-sdk'; + +// Re-export Participant from core types for back-compat with earlier imports +// from this module. New code should import Participant from 'toolpack-agents' +// (the root) or from '../agent/types.js' directly. +export type { Participant }; + +/** + * A message turn in the conversation history. + */ +export interface HistoryTurn { + /** Unique identifier for this turn */ + id: string; + /** Participant who sent this message */ + participant: Participant; + /** The message content */ + content: string; + /** ISO timestamp */ + timestamp: string; + /** Optional metadata about the turn */ + metadata?: { + /** Whether this was a tool invocation */ + isToolCall?: boolean; + /** Tool name if applicable */ + toolName?: string; + /** Tool result if applicable */ + toolResult?: string; + }; +} + +/** + * Input payload for summarization. + */ +export interface SummarizerInput { + /** The conversation turns to summarize (older messages first) */ + turns: HistoryTurn[]; + /** The target agent's name (for perspective-aware summary) */ + agentName: string; + /** The agent's unique identifier */ + agentId: string; + /** Maximum length of the summary in tokens (approximate) */ + maxTokens?: number; + /** Whether to include action items/decisions in the summary */ + extractDecisions?: boolean; +} + +/** + * Result of a summarization operation. + */ +export interface SummarizerOutput { + /** The generated summary text */ + summary: string; + /** Number of turns that were summarized */ + turnsSummarized: number; + /** Whether decisions/action items were extracted */ + hasDecisions: boolean; + /** Approximate token count of the summary */ + estimatedTokens: number; +} + +/** + * Capability agent that compresses older conversation history turns + * into a summary turn for the prompt assembler. + * + * Used by the prompt assembler when conversation history exceeds + * the configured threshold. Returns a compact summary preserving + * key facts, decisions, and context. + * + * Register this agent with an empty channels list to use it as a capability. + * + * @example + * ```ts + * const summarizer = new SummarizerAgent(toolpack); + * const result = await summarizer.invokeAgent({ + * message: 'summarize', + * data: { + * turns: olderTurns, + * agentName: 'name', + * agentId: 'U123', + * maxTokens: 500, + * extractDecisions: true + * } as SummarizerInput + * }); + * const summary = JSON.parse(result.output) as SummarizerOutput; + * ``` + */ +const SUMMARIZER_MODE: ModeConfig = { + ...CHAT_MODE, + name: 'summarizer-mode', + systemPrompt: [ + 'You are a conversation summarizer for multi-participant chat histories.', + 'Your job is to compress older conversation turns into a dense summary that preserves:', + '', + '1. Key facts and information shared', + '2. Decisions made or action items assigned', + '3. Context relevant to the target agent\'s perspective', + '4. Important questions asked or problems raised', + '', + 'Summarize from the perspective of the target agent.', + 'If the agent was not addressed in a turn, note it as observed context.', + 'Use bullet points for clarity. Be concise but complete.', + '', + 'Output format: Return ONLY a JSON object with these fields:', + '- summary: string (the summary text)', + '- turnsSummarized: number (count of turns processed)', + '- hasDecisions: boolean (whether any decisions/action items were found)', + '- estimatedTokens: number (rough estimate: characters / 4)', + '', + 'Do not include markdown code blocks, just the raw JSON.' + ].join('\n'), +}; + +export class SummarizerAgent extends BaseAgent { + name = 'summarizer'; + description = 'Compresses conversation history into compact summaries for prompt assembly'; + mode = SUMMARIZER_MODE; + + constructor(options: BaseAgentOptions) { + super(options); + } + + async invokeAgent(input: AgentInput): Promise { + const payload = input.data as SummarizerInput | undefined; + + if (!payload?.turns || payload.turns.length === 0) { + return { + output: JSON.stringify({ + summary: '(No history to summarize)', + turnsSummarized: 0, + hasDecisions: false, + estimatedTokens: 5 + } as SummarizerOutput), + metadata: { emptyInput: true } + }; + } + + const maxTokens = payload.maxTokens ?? 800; + const extractDecisions = payload.extractDecisions ?? true; + + // Build the prompt + const promptLines: string[] = [ + `Target agent: "${payload.agentName}" (ID: ${payload.agentId})`, + `Maximum summary length: ~${maxTokens} tokens`, + `Extract decisions/action items: ${extractDecisions ? 'yes' : 'no'}`, + '', + `Conversation turns to summarize (${payload.turns.length} turns):`, + '' + ]; + + // Format turns chronologically + for (const turn of payload.turns) { + const timestamp = new Date(turn.timestamp).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + + const participantName = turn.participant.displayName ?? turn.participant.id; + const participantLabel = turn.participant.kind === 'agent' ? `[BOT] ${participantName}` : participantName; + let line = `[${timestamp}] ${participantLabel}: ${turn.content.substring(0, 200)}`; + if (turn.content.length > 200) { + line += '...'; + } + + if (turn.metadata?.isToolCall && turn.metadata.toolName) { + line += ` [tool: ${turn.metadata.toolName}]`; + } + + promptLines.push(line); + } + + promptLines.push('', 'Generate a JSON summary object:'); + + const prompt = promptLines.join('\n'); + + // Note: per-run mode override reserved for future use (currently uses agent mode) + const result = await this.run(prompt); + + // Parse and validate the output + const parsed = this.parseSummarizerOutput(result.output, payload.turns.length); + + return { + output: JSON.stringify(parsed), + metadata: { + turnsProcessed: payload.turns.length, + rawOutputLength: result.output.length + } + }; + } + + /** + * Parse and validate the LLM output into a SummarizerOutput. + */ + private parseSummarizerOutput(output: string, turnCount: number): SummarizerOutput { + // Try to extract JSON if wrapped in markdown + let jsonText = output.trim(); + + // Remove markdown code blocks if present + const codeBlockMatch = jsonText.match(/```(?:json)?\s*([\s\S]*?)\s*```/); + if (codeBlockMatch) { + jsonText = codeBlockMatch[1].trim(); + } + + try { + const parsed = JSON.parse(jsonText) as Partial; + + // Validate and provide defaults + return { + summary: typeof parsed.summary === 'string' && parsed.summary.length > 0 + ? parsed.summary + : this.generateFallbackSummary(turnCount), + turnsSummarized: typeof parsed.turnsSummarized === 'number' + ? parsed.turnsSummarized + : turnCount, + hasDecisions: typeof parsed.hasDecisions === 'boolean' + ? parsed.hasDecisions + : false, + estimatedTokens: typeof parsed.estimatedTokens === 'number' && parsed.estimatedTokens > 0 + ? parsed.estimatedTokens + : Math.ceil(output.length / 4) + }; + } catch { + // JSON parsing failed - use fallback + return { + summary: this.generateFallbackSummary(turnCount), + turnsSummarized: turnCount, + hasDecisions: output.toLowerCase().includes('decision') || output.toLowerCase().includes('action'), + estimatedTokens: Math.ceil(output.length / 4) + }; + } + } + + /** + * Generate a fallback summary when parsing fails. + */ + private generateFallbackSummary(turnCount: number): string { + return `(Summary of ${turnCount} conversation turns - key details preserved in full context)`; + } +} diff --git a/packages/toolpack-agents/src/channels/base-channel.test.ts b/packages/toolpack-agents/src/channels/base-channel.test.ts new file mode 100644 index 0000000..21b1e61 --- /dev/null +++ b/packages/toolpack-agents/src/channels/base-channel.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi } from 'vitest'; +import { BaseChannel } from './base-channel.js'; +import { AgentInput, AgentOutput } from '../agent/types.js'; + +// Test implementation of BaseChannel +class TestChannel extends BaseChannel { + listened = false; + sent: AgentOutput[] = []; + normalized: unknown[] = []; + + listen(): void { + this.listened = true; + } + + async send(output: AgentOutput): Promise { + this.sent.push(output); + } + + normalize(incoming: unknown): AgentInput { + this.normalized.push(incoming); + return { + message: String(incoming), + }; + } +} + +describe('BaseChannel', () => { + describe('name property', () => { + it('should support optional name', () => { + const channel = new TestChannel(); + expect(channel.name).toBeUndefined(); + + channel.name = 'test-channel'; + expect(channel.name).toBe('test-channel'); + }); + }); + + describe('onMessage', () => { + it('should set handler', async () => { + const channel = new TestChannel(); + const handler = vi.fn().mockResolvedValue(undefined); + + channel.onMessage(handler); + + // Trigger handler via protected handleMessage method + const input: AgentInput = { message: 'test' }; + await (channel as unknown as { handleMessage(input: AgentInput): Promise }).handleMessage(input); + + expect(handler).toHaveBeenCalledWith(input); + }); + }); + + describe('abstract methods', () => { + it('should require listen implementation', () => { + const channel = new TestChannel(); + + channel.listen(); + expect(channel.listened).toBe(true); + }); + + it('should require send implementation', async () => { + const channel = new TestChannel(); + + const output: AgentOutput = { output: 'test' }; + await channel.send(output); + + expect(channel.sent).toContainEqual(output); + }); + + it('should require normalize implementation', () => { + const channel = new TestChannel(); + + const input = channel.normalize('test-input'); + + expect(channel.normalized).toContainEqual('test-input'); + expect(input.message).toBe('test-input'); + }); + }); + + describe('normalize patterns', () => { + it('should handle string input', () => { + const channel = new TestChannel(); + const input = channel.normalize('hello'); + + expect(input.message).toBe('hello'); + }); + + it('should handle object input', () => { + const channel = new TestChannel(); + const input = channel.normalize({ text: 'hello', user: 'test' }); + + expect(input.message).toBe('[object Object]'); + }); + + it('should handle complex input with all fields', () => { + class ComplexChannel extends BaseChannel { + listen(): void {} + async send(): Promise {} + normalize(incoming: unknown): AgentInput { + const data = incoming as Record; + return { + intent: data.intent as string, + message: data.text as string, + data: incoming, + context: { source: data.source as string }, + conversationId: data.threadId as string, + }; + } + } + + const channel = new ComplexChannel(); + const input = channel.normalize({ + intent: 'greeting', + text: 'Hello!', + source: 'slack', + threadId: 'thread-123', + }); + + expect(input.intent).toBe('greeting'); + expect(input.message).toBe('Hello!'); + expect(input.conversationId).toBe('thread-123'); + expect(input.context?.source).toBe('slack'); + }); + }); +}); diff --git a/packages/toolpack-agents/src/channels/base-channel.ts b/packages/toolpack-agents/src/channels/base-channel.ts new file mode 100644 index 0000000..e9cbe70 --- /dev/null +++ b/packages/toolpack-agents/src/channels/base-channel.ts @@ -0,0 +1,58 @@ +import { AgentInput, AgentOutput } from '../agent/types.js'; + +/** + * Abstract base class for all agent channels. + * Channels handle the two-way communication between the external world and agents. + */ +export abstract class BaseChannel { + /** Optional name for the channel - required for sendTo() routing */ + name?: string; + + /** + * Whether this is a trigger channel (no human recipient). + * Trigger channels like ScheduledChannel cannot use this.ask() since there's no human to answer. + * Conversation channels (Slack, Telegram, Webhook) can use this.ask(). + */ + abstract readonly isTriggerChannel: boolean; + + /** Message handler set by AgentRegistry */ + protected _handler?: (input: AgentInput) => Promise; + + /** + * Start listening for incoming messages. + * Called by AgentRegistry when the SDK initializes. + */ + abstract listen(): void; + + /** + * Send output back to the external world. + * @param output The agent's output to deliver + */ + abstract send(output: AgentOutput): Promise; + + /** + * Normalize an incoming event into AgentInput. + * Each channel implementation maps its specific event format. + * @param incoming Raw event from the external source + * @returns Normalized AgentInput + */ + abstract normalize(incoming: unknown): AgentInput; + + /** + * Set the message handler. Called by AgentRegistry. + * @param handler Function to call when a message arrives + */ + onMessage(handler: (input: AgentInput) => Promise): void { + this._handler = handler; + } + + /** + * Helper to call the handler if set. + * @param input The normalized agent input + */ + protected async handleMessage(input: AgentInput): Promise { + if (this._handler) { + await this._handler(input); + } + } +} diff --git a/packages/toolpack-agents/src/channels/discord-channel.test.ts b/packages/toolpack-agents/src/channels/discord-channel.test.ts new file mode 100644 index 0000000..cffeb3e --- /dev/null +++ b/packages/toolpack-agents/src/channels/discord-channel.test.ts @@ -0,0 +1,208 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { DiscordChannel } from './discord-channel.js'; + +describe('DiscordChannel', () => { + let channel: DiscordChannel; + + beforeEach(() => { + channel = new DiscordChannel({ + name: 'test-discord', + token: 'discord-bot-token', + guildId: '123456789', + channelId: '987654321', + }); + }); + + it('should have correct configuration', () => { + expect(channel.name).toBe('test-discord'); + expect(channel.isTriggerChannel).toBe(false); + }); + + it('should not be a trigger channel (supports two-way)', () => { + expect(channel.isTriggerChannel).toBe(false); + }); + + it('should normalize Discord message', () => { + const message = { + content: 'Hello from Discord', + channelId: '987654321', + guildId: '123456789', + id: 'msg123', + author: { + id: 'user123', + username: 'testuser', + bot: false, + }, + }; + + const input = channel.normalize(message); + + expect(input.message).toBe('Hello from Discord'); + expect(input.conversationId).toBe('987654321'); + expect(input.context?.userId).toBe('user123'); + expect(input.context?.username).toBe('testuser'); + expect(input.context?.channelId).toBe('987654321'); + }); + + it('should normalize Discord message with thread', () => { + const message = { + content: 'Hello from thread', + channelId: '987654321', + guildId: '123456789', + id: 'msg123', + thread: { + id: 'thread123', + }, + author: { + id: 'user123', + username: 'testuser', + bot: false, + }, + }; + + const input = channel.normalize(message); + + expect(input.conversationId).toBe('987654321:thread123'); + expect(input.context?.threadId).toBe('thread123'); + }); + + it('keeps context.channelId as bare channel ID for threaded messages', () => { + const message = { + content: 'Thread reply', + channelId: '987654321', + id: 'msg123', + thread: { id: 'thread123' }, + author: { id: 'user123', username: 'testuser' }, + }; + + const input = channel.normalize(message); + + expect(input.conversationId).toBe('987654321:thread123'); + expect(input.context?.channelId).toBe('987654321'); + }); + + it('produces empty string conversationId when message.channelId is absent', () => { + const message = { + content: 'Hello', + id: 'msg1', + author: { id: 'u1', username: 'alice' }, + }; + + const input = channel.normalize(message); + + expect(input.conversationId).toBe(''); + expect(input.context?.channelId).toBeUndefined(); + }); + + it('populates participant from message.author', () => { + const message = { + content: 'Hello', + channelId: '987654321', + id: 'msg1', + author: { id: 'u1', username: 'alice', globalName: 'Alice' }, + }; + + const input = channel.normalize(message); + + expect(input.participant).toEqual({ kind: 'user', id: 'u1', displayName: 'Alice' }); + }); + + it('uses username as displayName when globalName is absent', () => { + const message = { + content: 'Hello', + channelId: '987654321', + id: 'msg1', + author: { id: 'u1', username: 'alice' }, + }; + + const input = channel.normalize(message); + + expect(input.participant).toEqual({ kind: 'user', id: 'u1', displayName: 'alice' }); + }); + + it('sets participant to undefined when author is absent (webhook/system message)', () => { + const message = { + content: 'System message', + channelId: '987654321', + id: 'msg1', + }; + + const input = channel.normalize(message); + + expect(input.participant).toBeUndefined(); + }); + + it('sets channelType to "dm" for DM channels (type 1)', () => { + const message = { + content: 'DM', + channelId: '987654321', + id: 'msg1', + channel: { type: 1, name: undefined }, + author: { id: 'u1', username: 'alice' }, + }; + + const input = channel.normalize(message); + + expect(input.context?.channelType).toBe('dm'); + }); + + it('sets channelType to "dm" for Group DM channels (type 3)', () => { + const message = { + content: 'Group DM', + channelId: '987654321', + id: 'msg1', + channel: { type: 3 }, + author: { id: 'u1', username: 'alice' }, + }; + + const input = channel.normalize(message); + + expect(input.context?.channelType).toBe('dm'); + }); + + it('sets channelType to "channel" for guild text channels', () => { + const message = { + content: 'Hello', + channelId: '987654321', + id: 'msg1', + channel: { type: 0, name: 'general' }, + author: { id: 'u1', username: 'alice' }, + }; + + const input = channel.normalize(message); + + expect(input.context?.channelType).toBe('channel'); + }); + + it('sets channelName from channel.name', () => { + const message = { + content: 'Hello', + channelId: '987654321', + id: 'msg1', + channel: { type: 0, name: 'general' }, + author: { id: 'u1', username: 'alice' }, + }; + + const input = channel.normalize(message); + + expect(input.context?.channelName).toBe('general'); + }); + + it('sets channelName to undefined when channel.name is absent (DM)', () => { + const message = { + content: 'DM', + channelId: '987654321', + id: 'msg1', + channel: { type: 1 }, + author: { id: 'u1', username: 'alice' }, + }; + + const input = channel.normalize(message); + + expect(input.context?.channelName).toBeUndefined(); + }); + + it('should initialize without errors', () => { + expect(() => channel.listen()).not.toThrow(); + }); +}); diff --git a/packages/toolpack-agents/src/channels/discord-channel.ts b/packages/toolpack-agents/src/channels/discord-channel.ts new file mode 100644 index 0000000..c63a709 --- /dev/null +++ b/packages/toolpack-agents/src/channels/discord-channel.ts @@ -0,0 +1,175 @@ +import { BaseChannel } from './base-channel.js'; +import { AgentInput, AgentOutput } from '../agent/types.js'; + +/** + * Configuration options for DiscordChannel. + */ +export interface DiscordChannelConfig { + /** Optional name for the channel - required for sendTo() routing */ + name?: string; + + /** Discord bot token */ + token: string; + + /** Target guild (server) ID */ + guildId: string; + + /** Target channel ID */ + channelId: string; +} + +/** + * Discord channel for two-way Discord bot integration. + * Receives messages from guild channels or DMs and replies in-thread. + */ +export class DiscordChannel extends BaseChannel { + readonly isTriggerChannel = false; + private config: DiscordChannelConfig; + private client?: any; + + constructor(config: DiscordChannelConfig) { + super(); + this.config = config; + this.name = config.name; + } + + /** + * Start listening for Discord messages. + */ + listen(): void { + if (typeof process !== 'undefined') { + import('discord.js').then((discord) => { + const { Client, GatewayIntentBits } = discord; + + this.client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + GatewayIntentBits.DirectMessages, + ], + }); + + this.client.on('ready', () => { + console.log(`[DiscordChannel] Bot logged in as ${this.client.user?.tag}`); + }); + + this.client.on('messageCreate', (message: any) => { + this.handleDiscordMessage(message); + }); + + this.client.login(this.config.token).catch((err: Error) => { + console.error('[DiscordChannel] Failed to login to Discord:', err); + }); + }).catch((err) => { + console.error('[DiscordChannel] Failed to initialize Discord client:', err); + console.error('[DiscordChannel] Make sure to install discord.js: npm install discord.js'); + }); + } + } + + /** + * Send a message to Discord. + * @param output The agent output to send + */ + async send(output: AgentOutput): Promise { + if (!this.client) { + throw new Error('Discord client not initialized. Did you call listen()?'); + } + + try { + const channelId = (output.metadata?.channelId as string) || this.config.channelId; + const channel = await this.client.channels.fetch(channelId); + + if (!channel || !('send' in channel)) { + throw new Error(`Channel ${channelId} not found or is not a text channel`); + } + + const messageOptions: any = { + content: output.output, + }; + + const threadId = output.metadata?.threadId as string | undefined; + if (threadId) { + const thread = await this.client.channels.fetch(threadId); + if (thread && 'send' in thread) { + await thread.send(messageOptions); + return; + } + } + + await channel.send(messageOptions); + } catch (error) { + console.error('[DiscordChannel] Failed to send Discord message:', error); + throw new Error(`Failed to send Discord message: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Normalize a Discord message into AgentInput. + * @param incoming Discord message object + * @returns Normalized AgentInput + */ + normalize(incoming: unknown): AgentInput { + const message = incoming as Record; + + // Threads use "channelId:threadId" so thread history is scoped separately + // from the parent channel. + const rawChannelId = message.channelId as string | undefined; + const conversationId = (rawChannelId ?? '') + (message.thread?.id ? `:${message.thread.id}` : ''); + + // Discord channel type constants: 1 = DM, 3 = GROUP_DM. + const discordChannelType = message.channel?.type as number | undefined; + const isDm = discordChannelType === 1 || discordChannelType === 3; + + const authorId = message.author?.id as string | undefined; + const authorName = (message.author?.globalName as string | undefined) + || (message.author?.username as string | undefined); + + return { + message: message.content, + conversationId, + data: message, + participant: authorId + ? { kind: 'user', id: authorId, displayName: authorName } + : undefined, + context: { + userId: authorId, + username: message.author?.username, + // 'dm' for DM/group-DM channels so defaultGetScope returns scope: 'dm'. + channelType: isDm ? 'dm' : 'channel', + channelId: rawChannelId, + channelName: message.channel?.name as string | undefined, + guildId: message.guildId, + threadId: message.thread?.id, + messageId: message.id, + }, + }; + } + + /** + * Handle incoming Discord messages. + */ + private handleDiscordMessage(message: any): void { + if (message.author?.bot) { + return; + } + + if (message.channelId !== this.config.channelId || message.guildId !== this.config.guildId) { + return; + } + + const input = this.normalize(message); + this.handleMessage(input); + } + + /** + * Stop the Discord client. + */ + async stop(): Promise { + if (this.client) { + await this.client.destroy(); + this.client = undefined; + } + } +} diff --git a/packages/toolpack-agents/src/channels/email-channel.test.ts b/packages/toolpack-agents/src/channels/email-channel.test.ts new file mode 100644 index 0000000..2809acf --- /dev/null +++ b/packages/toolpack-agents/src/channels/email-channel.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EmailChannel } from './email-channel.js'; + +describe('EmailChannel', () => { + let channel: EmailChannel; + + beforeEach(() => { + channel = new EmailChannel({ + name: 'test-email', + from: 'agent@example.com', + to: 'user@example.com', + smtp: { + host: 'smtp.example.com', + port: 587, + auth: { + user: 'agent@example.com', + pass: 'password', + }, + }, + subject: 'Test Email', + }); + }); + + it('should have correct configuration', () => { + expect(channel.name).toBe('test-email'); + expect(channel.isTriggerChannel).toBe(true); + }); + + it('should be a trigger channel (outbound-only)', () => { + expect(channel.isTriggerChannel).toBe(true); + }); + + it('should throw error when normalize is called', () => { + expect(() => channel.normalize({})).toThrow('outbound-only'); + }); + + it('should support multiple recipients', () => { + const multiChannel = new EmailChannel({ + from: 'agent@example.com', + to: ['user1@example.com', 'user2@example.com'], + smtp: { + host: 'smtp.example.com', + port: 587, + auth: { + user: 'agent@example.com', + pass: 'password', + }, + }, + }); + + expect(multiChannel).toBeDefined(); + }); + + it('should initialize without errors', () => { + expect(() => channel.listen()).not.toThrow(); + }); +}); diff --git a/packages/toolpack-agents/src/channels/email-channel.ts b/packages/toolpack-agents/src/channels/email-channel.ts new file mode 100644 index 0000000..455c86a --- /dev/null +++ b/packages/toolpack-agents/src/channels/email-channel.ts @@ -0,0 +1,129 @@ +import { BaseChannel } from './base-channel.js'; +import { AgentOutput } from '../agent/types.js'; + +/** + * Configuration options for EmailChannel. + */ +export interface EmailChannelConfig { + /** Optional name for the channel - required for sendTo() routing */ + name?: string; + + /** Sender email address */ + from: string; + + /** Recipient email address(es) - for scheduled/outbound emails */ + to: string | string[]; + + /** SMTP configuration */ + smtp: { + host: string; + port: number; + auth: { + user: string; + pass: string; + }; + secure?: boolean; + }; + + /** Optional subject line template */ + subject?: string; +} + +/** + * Email channel for sending outbound emails via SMTP. + * This is an outbound-only channel - for inbound email handling, + * use a custom email-reader tool + WebhookChannel. + */ +export class EmailChannel extends BaseChannel { + readonly isTriggerChannel = true; + private config: EmailChannelConfig; + private transporter?: any; + + constructor(config: EmailChannelConfig) { + super(); + this.config = config; + this.name = config.name; + } + + /** + * Initialize the email transporter. + * EmailChannel is outbound-only, so listen() just sets up the transporter. + */ + listen(): void { + if (typeof process !== 'undefined') { + import('nodemailer').then((nodemailer) => { + this.transporter = nodemailer.default.createTransport({ + host: this.config.smtp.host, + port: this.config.smtp.port, + secure: this.config.smtp.secure ?? (this.config.smtp.port === 465), + auth: { + user: this.config.smtp.auth.user, + pass: this.config.smtp.auth.pass, + }, + }); + + console.log(`[EmailChannel] Email transporter initialized for ${this.config.from}`); + }).catch((err) => { + console.error('[EmailChannel] Failed to initialize nodemailer:', err); + console.error('[EmailChannel] Make sure to install nodemailer: npm install nodemailer'); + }); + } + } + + /** + * Send an email with the agent's output. + * @param output The agent output to send + */ + async send(output: AgentOutput): Promise { + if (!this.transporter) { + throw new Error('Email transporter not initialized. Did you call listen()?'); + } + + const recipients = Array.isArray(this.config.to) ? this.config.to : [this.config.to]; + const subject = this.config.subject || 'Message from Agent'; + + const mailOptions = { + from: this.config.from, + to: recipients.join(', '), + subject, + text: output.output, + html: this.formatAsHtml(output.output), + }; + + try { + const info = await this.transporter.sendMail(mailOptions); + console.log(`[EmailChannel] Email sent: ${info.messageId}`); + } catch (error) { + console.error('[EmailChannel] Failed to send email:', error); + throw new Error(`Failed to send email: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * EmailChannel is outbound-only and doesn't receive messages. + * This method should not be called. + */ + normalize(_incoming: unknown): never { + throw new Error('EmailChannel is outbound-only. Use WebhookChannel with email webhook events for inbound email.'); + } + + /** + * Format plain text as HTML for better email rendering. + */ + private formatAsHtml(text: string): string { + return text + .split('\n\n') + .map(para => `

${para.replace(/\n/g, '
')}

`) + .join(''); + } + + /** + * Close the email transporter. + */ + async stop(): Promise { + if (this.transporter) { + this.transporter.close(); + this.transporter = undefined; + } + } +} diff --git a/packages/toolpack-agents/src/channels/index.ts b/packages/toolpack-agents/src/channels/index.ts new file mode 100644 index 0000000..0aec180 --- /dev/null +++ b/packages/toolpack-agents/src/channels/index.ts @@ -0,0 +1,8 @@ +export { BaseChannel } from './base-channel.js'; +export { SlackChannel, SlackChannelConfig } from './slack-channel.js'; +export { WebhookChannel, WebhookChannelConfig } from './webhook-channel.js'; +export { ScheduledChannel, ScheduledChannelConfig } from './scheduled-channel.js'; +export { TelegramChannel, TelegramChannelConfig } from './telegram-channel.js'; +export { DiscordChannel, DiscordChannelConfig } from './discord-channel.js'; +export { EmailChannel, EmailChannelConfig } from './email-channel.js'; +export { SMSChannel, SMSChannelConfig } from './sms-channel.js'; diff --git a/packages/toolpack-agents/src/channels/scheduled-channel.test.ts b/packages/toolpack-agents/src/channels/scheduled-channel.test.ts new file mode 100644 index 0000000..0a3f184 --- /dev/null +++ b/packages/toolpack-agents/src/channels/scheduled-channel.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ScheduledChannel, ScheduledChannelConfig } from './scheduled-channel.js'; +import { AgentInput, AgentOutput } from '../agent/types.js'; + +describe('ScheduledChannel', () => { + const baseConfig: ScheduledChannelConfig = { + cron: '0 9 * * 1-5', + notify: 'webhook:https://hooks.example.com/report', + }; + + describe('constructor', () => { + it('should create with required config', () => { + const channel = new ScheduledChannel(baseConfig); + expect(channel).toBeDefined(); + }); + + it('should set name from config', () => { + const channel = new ScheduledChannel({ ...baseConfig, name: 'morning-report' }); + expect(channel.name).toBe('morning-report'); + }); + + it('should parse cron expression', () => { + const channel = new ScheduledChannel(baseConfig); + // Just verify it doesn't throw + expect(channel).toBeDefined(); + }); + + it('should throw on invalid cron expression', () => { + expect(() => { + new ScheduledChannel({ + cron: 'invalid', + notify: 'webhook:https://hooks.example.com/x', + }); + }).toThrow('Invalid cron expression'); + }); + }); + + describe('normalize', () => { + it('should create AgentInput with pre-set intent', () => { + const channel = new ScheduledChannel({ + ...baseConfig, + intent: 'daily_report', + }); + + const input = channel.normalize(null); + + expect(input.intent).toBe('daily_report'); + expect(input.message).toContain('Scheduled task triggered'); + }); + + it('should have isTriggerChannel set to true', () => { + const channel = new ScheduledChannel(baseConfig); + expect(channel.isTriggerChannel).toBe(true); + }); + + it('should include date-keyed conversationId', () => { + const channel = new ScheduledChannel(baseConfig); + + const input = channel.normalize(null); + + // Should be in format: scheduled:{name}:{date} + expect(input.conversationId).toMatch(/^scheduled:/); + }); + + it('should include scheduled metadata in data', () => { + const channel = new ScheduledChannel(baseConfig); + + const input = channel.normalize(null); + + expect(input.data).toMatchObject({ + scheduled: true, + cron: '0 9 * * 1-5', + }); + expect(input.data).toHaveProperty('timestamp'); + }); + }); + + describe('send', () => { + it("rejects the removed 'slack:' notify protocol with a migration hint", async () => { + const channel = new ScheduledChannel({ + cron: '0 9 * * 1-5', + notify: 'slack:#ops', + }); + + await expect(channel.send({ + output: 'Daily report', + metadata: {}, + })).rejects.toThrow(/no longer supports the 'slack:' notify protocol/); + }); + + it('should send to webhook URL', async () => { + const channel = new ScheduledChannel({ + cron: '0 * * * *', + notify: 'webhook:https://hooks.example.com/report', + }); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => ({ success: true }), + } as unknown as Response); + + await channel.send({ + output: 'Scheduled task complete', + metadata: { task: 'cleanup' }, + }); + + expect(fetch).toHaveBeenCalledWith( + 'https://hooks.example.com/report', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + }), + }) + ); + + // Verify the body contains the expected data + const callArgs = (fetch as ReturnType).mock.calls[0]; + const body = JSON.parse(callArgs[1].body); + expect(body.output).toBe('Scheduled task complete'); + expect(body.metadata).toEqual({ task: 'cleanup' }); + expect(body.timestamp).toBeDefined(); + }); + + it('should throw on invalid notify format', async () => { + const channel = new ScheduledChannel({ + cron: '0 * * * *', + notify: 'invalid', + }); + + await expect(channel.send({ output: 'test' })) + .rejects.toThrow('Invalid notify format'); + }); + + it('should throw on unknown protocol', async () => { + const channel = new ScheduledChannel({ + cron: '0 * * * *', + notify: 'unknown:destination', + }); + + await expect(channel.send({ output: 'test' })) + .rejects.toThrow('Unknown notify protocol'); + }); + + it('should throw on webhook failure', async () => { + const channel = new ScheduledChannel({ + cron: '0 * * * *', + notify: 'webhook:https://hooks.example.com/fail', + }); + + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + statusText: 'Server Error', + } as Response); + + await expect(channel.send({ output: 'test' })) + .rejects.toThrow('Webhook notification failed: Server Error'); + }); + }); + + describe('cron parsing', () => { + it('should parse standard cron with 5 parts', () => { + const channel = new ScheduledChannel({ + cron: '0 9 * * 1-5', + notify: 'webhook:https://example.com', + }); + + expect(channel).toBeDefined(); + }); + + it('should support wildcards', () => { + const channel = new ScheduledChannel({ + cron: '* * * * *', + notify: 'webhook:https://example.com', + }); + + expect(channel).toBeDefined(); + }); + + it('should support step values (every 15 minutes)', () => { + const channel = new ScheduledChannel({ + cron: '*/15 * * * *', + notify: 'console', + }); + + expect(channel).toBeDefined(); + }); + + it('should support ranges (9am-5pm)', () => { + const channel = new ScheduledChannel({ + cron: '0 9-17 * * *', + notify: 'console', + }); + + expect(channel).toBeDefined(); + }); + + it('should support lists (specific minutes)', () => { + const channel = new ScheduledChannel({ + cron: '0,15,30,45 * * * *', + notify: 'console', + }); + + expect(channel).toBeDefined(); + }); + + it('should support combinations (every 5 min from 0-30)', () => { + const channel = new ScheduledChannel({ + cron: '0-30/5 * * * *', + notify: 'console', + }); + + expect(channel).toBeDefined(); + }); + + it('should support complex expressions (business hours)', () => { + const channel = new ScheduledChannel({ + cron: '*/15 9-17 * * 1-5', + notify: 'console', + }); + + expect(channel).toBeDefined(); + }); + + it('should support specific days of week', () => { + const channel = new ScheduledChannel({ + cron: '0 10 * * 1,3,5', + notify: 'console', + }); + + expect(channel).toBeDefined(); + }); + + it('should support specific days of month', () => { + const channel = new ScheduledChannel({ + cron: '0 0 1,15 * *', + notify: 'console', + }); + + expect(channel).toBeDefined(); + }); + + it('should support specific months', () => { + const channel = new ScheduledChannel({ + cron: '0 9 1 1,6,12 *', + notify: 'console', + }); + + expect(channel).toBeDefined(); + }); + + it('should support midnight cron', () => { + const channel = new ScheduledChannel({ + cron: '0 0 * * *', + notify: 'console', + }); + + expect(channel).toBeDefined(); + }); + + it('should support noon cron', () => { + const channel = new ScheduledChannel({ + cron: '0 12 * * *', + notify: 'console', + }); + + expect(channel).toBeDefined(); + }); + }); + + describe('listen', () => { + it('should schedule next run', () => { + const channel = new ScheduledChannel(baseConfig); + + // Just verify listen doesn't throw + expect(() => channel.listen()).not.toThrow(); + }); + }); + + describe('stop', () => { + it('should clear timer if set', async () => { + const channel = new ScheduledChannel(baseConfig); + + // Start listening to set up timer + channel.listen(); + + // Should not throw + await expect(channel.stop()).resolves.not.toThrow(); + }); + + it('should handle missing timer gracefully', async () => { + const channel = new ScheduledChannel(baseConfig); + + await expect(channel.stop()).resolves.not.toThrow(); + }); + }); +}); diff --git a/packages/toolpack-agents/src/channels/scheduled-channel.ts b/packages/toolpack-agents/src/channels/scheduled-channel.ts new file mode 100644 index 0000000..3f77220 --- /dev/null +++ b/packages/toolpack-agents/src/channels/scheduled-channel.ts @@ -0,0 +1,214 @@ +import { BaseChannel } from './base-channel.js'; +import { AgentInput, AgentOutput } from '../agent/types.js'; +import { CronExpressionParser } from 'cron-parser'; + +/** + * Configuration options for ScheduledChannel. + */ +export interface ScheduledChannelConfig { + /** Optional name for the channel - required for sendTo() routing */ + name?: string; + + /** + * Cron expression - supports full cron syntax including wildcards, ranges, steps, and lists. + * Supports both 5-field (min hour dom month dow) and 6-field (sec min hour dom month dow) expressions. + * Examples: '0 9 * * 1-5' for 9am weekdays, or '0 * /15 * * * *' for every 15 minutes (6-field) + */ + cron: string; + + /** Optional intent to pre-set in AgentInput */ + intent?: string; + + /** Optional message to send to the agent on each trigger */ + message?: string; + + /** + * Where to deliver the output. Supported protocols: + * + * - `webhook:` — POSTs JSON `{ output, metadata, timestamp }` to the URL. + * + * For Slack delivery, attach a named `SlackChannel` to the same agent and + * route from inside `run()`: + * + * ```ts + * agent.channels = [ + * new ScheduledChannel({ name: 'daily', cron: '0 9 * * 1-5', notify: 'webhook:...' }), + * new SlackChannel({ name: 'kore-slack', channel: '#project-kore', token, signingSecret }), + * ]; + * + * async run(input) { + * const report = await this.buildReport(); + * await this.sendTo('kore-slack', report); + * return { output: report }; + * } + * ``` + * + * This keeps Slack credentials, thread routing, and multi-channel listening + * in one place (`SlackChannel`) instead of duplicated inside `ScheduledChannel`. + */ + notify: string; +} + + +/** + * Scheduled channel that runs agents on a cron schedule. + * Delivers output to the configured notification destination. + */ +export class ScheduledChannel extends BaseChannel { + readonly isTriggerChannel = true; + private config: ScheduledChannelConfig; + private timer?: ReturnType; + + constructor(config: ScheduledChannelConfig) { + super(); + this.config = config; + this.name = config.name; + + // Validate cron expression on construction + try { + CronExpressionParser.parse(config.cron); + } catch (error) { + throw new Error(`Invalid cron expression '${config.cron}': ${(error as Error).message}`); + } + } + + /** + * Start the cron scheduler. + */ + listen(): void { + // Calculate initial delay and set up recurring schedule + this.scheduleNextRun(); + } + + /** + * Send the agent output to the configured notify destination. + * @param output The agent output to send + */ + async send(output: AgentOutput): Promise { + // Split only on the first colon to preserve URLs like https://... + const colonIndex = this.config.notify.indexOf(':'); + if (colonIndex === -1) { + throw new Error(`Invalid notify format: ${this.config.notify}. Expected format: 'webhook:https://...'`); + } + + const protocol = this.config.notify.substring(0, colonIndex); + const destination = this.config.notify.substring(colonIndex + 1); + + if (!protocol || !destination) { + throw new Error(`Invalid notify format: ${this.config.notify}. Expected format: 'webhook:https://...'`); + } + + switch (protocol.toLowerCase()) { + case 'webhook': + await this.sendToWebhook(destination, output); + break; + case 'slack': + throw new Error( + `ScheduledChannel no longer supports the 'slack:' notify protocol. ` + + `Attach a named SlackChannel to the agent and route from inside run() via ` + + `this.sendTo('', output). See ScheduledChannelConfig.notify docs.` + ); + default: + throw new Error(`Unknown notify protocol: ${protocol}`); + } + } + + /** + * Normalize the scheduled trigger into AgentInput. + * Sets the intent and generates a date-keyed conversationId. + * @param _incoming Ignored for scheduled triggers + * @returns Normalized AgentInput + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + normalize(_incoming: unknown): AgentInput { + const date = new Date(); + const dateKey = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`; + + return { + intent: this.config.intent, + message: `Scheduled task triggered at ${date.toISOString()}`, + conversationId: `scheduled:${this.name || 'default'}:${dateKey}`, + data: { + scheduled: true, + cron: this.config.cron, + timestamp: date.toISOString(), + }, + }; + } + + /** + * Send output to a webhook URL. + */ + private async sendToWebhook(url: string, output: AgentOutput): Promise { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + output: output.output, + metadata: output.metadata, + timestamp: new Date().toISOString(), + }), + }); + + if (!response.ok) { + throw new Error(`Webhook notification failed: ${response.statusText}`); + } + } + + /** + * Calculate next run time using cron-parser. + */ + private getNextRunTime(): Date { + const interval = CronExpressionParser.parse(this.config.cron, { + currentDate: new Date(), + }); + + return interval.next().toDate(); + } + + /** + * Schedule the next run. + */ + private scheduleNextRun(): void { + const nextRun = this.getNextRunTime(); + const delay = nextRun.getTime() - Date.now(); + + if (delay <= 0) { + // Next run is in the past (race condition) — reschedule immediately + this.scheduleNextRun(); + return; + } + + console.log(`[ScheduledChannel] Next run scheduled for ${nextRun.toISOString()}`); + + this.timer = setTimeout(() => { + this.trigger(); + this.scheduleNextRun(); // Schedule the next occurrence + }, delay); + } + + /** + * Trigger the scheduled task. + */ + private async trigger(): Promise { + const input = this.normalize(null); + + try { + await this.handleMessage(input); + } catch (error) { + console.error('[ScheduledChannel] Error triggering scheduled task:', error); + } + } + + /** + * Stop the scheduler. + */ + async stop(): Promise { + if (this.timer) { + clearTimeout(this.timer); + this.timer = undefined; + } + } +} diff --git a/packages/toolpack-agents/src/channels/slack-channel.test.ts b/packages/toolpack-agents/src/channels/slack-channel.test.ts new file mode 100644 index 0000000..87f54fe --- /dev/null +++ b/packages/toolpack-agents/src/channels/slack-channel.test.ts @@ -0,0 +1,1147 @@ +import { createHmac } from 'crypto'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SlackChannel, SlackChannelConfig } from './slack-channel.js'; +import { AgentInput, AgentOutput } from '../agent/types.js'; + +describe('SlackChannel', () => { + const baseConfig: SlackChannelConfig = { + channel: '#support', + token: 'xoxb-test-token', + signingSecret: 'test-secret', + port: 3101, // Unique port for Slack tests + }; + + describe('constructor', () => { + it('should create with required config', () => { + const channel = new SlackChannel(baseConfig); + expect(channel).toBeDefined(); + }); + + it('should set name from config', () => { + const channel = new SlackChannel({ ...baseConfig, name: 'slack-support' }); + expect(channel.name).toBe('slack-support'); + }); + + it('should use default port if not specified', () => { + const channel = new SlackChannel({ + channel: '#general', + token: 'token', + signingSecret: 'secret', + }); + expect(channel).toBeDefined(); + }); + + it('should have isTriggerChannel set to false', () => { + const channel = new SlackChannel(baseConfig); + expect(channel.isTriggerChannel).toBe(false); + }); + }); + + describe('normalize', () => { + it('should map Slack event to AgentInput', () => { + const channel = new SlackChannel(baseConfig); + + const slackEvent = { + text: 'Hello bot', + user: 'U12345', + channel: 'C67890', + ts: '1234567890.123456', + thread_ts: '1234567890.000000', + team: 'T123', + }; + + const input = channel.normalize(slackEvent); + + expect(input.message).toBe('Hello bot'); + expect(input.conversationId).toBe('1234567890.000000'); + expect(input.context?.user).toBe('U12345'); + expect(input.context?.channel).toBe('C67890'); + expect(input.context?.team).toBe('T123'); + }); + + it('falls back to ts when event.channel is absent', () => { + const channel = new SlackChannel(baseConfig); + const input = channel.normalize({ + text: 'msg', + user: 'U123', + ts: '1234567890.999999', + }); + expect(input.conversationId).toBe('1234567890.999999'); + }); + + it('produces empty string conversationId when both channel and ts are absent', () => { + const channel = new SlackChannel(baseConfig); + const input = channel.normalize({ text: 'msg', user: 'U123' }); + expect(input.conversationId).toBe(''); + }); + + it('should use channel id as conversationId when thread_ts not present', () => { + const channel = new SlackChannel(baseConfig); + + // Top-level channel messages are keyed by channel id so all messages + // in the same channel share one conversation in the store. + const slackEvent = { + text: 'Direct message', + user: 'U12345', + channel: 'C67890', + ts: '1234567890.123456', + }; + + const input = channel.normalize(slackEvent); + + expect(input.conversationId).toBe('C67890'); + }); + + it('should handle missing text', () => { + const channel = new SlackChannel(baseConfig); + + const slackEvent = { + user: 'U12345', + ts: '1234567890.123456', + }; + + const input = channel.normalize(slackEvent); + + expect(input.message).toBe(''); + }); + + it('should include raw event in data', () => { + const channel = new SlackChannel(baseConfig); + + const slackEvent = { + text: 'Hello', + ts: '1234567890.123456', + custom_field: 'value', + }; + + const input = channel.normalize(slackEvent); + + expect(input.data).toEqual(slackEvent); + }); + }); + + describe('send', () => { + it('should call Slack API with correct payload', async () => { + const channel = new SlackChannel(baseConfig); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true }), + } as Response); + + await channel.send({ + output: 'Hello user!', + metadata: {}, + }); + + expect(fetch).toHaveBeenCalledWith( + 'https://slack.com/api/chat.postMessage', + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Authorization': 'Bearer xoxb-test-token', + 'Content-Type': 'application/json', + }), + body: expect.stringContaining('Hello user!'), + }) + ); + + const body = JSON.parse((fetch as ReturnType).mock.calls[0][1].body); + expect(body.channel).toBe('#support'); + expect(body.text).toBe('Hello user!'); + }); + + it('should include thread_ts for threaded replies', async () => { + const channel = new SlackChannel(baseConfig); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true }), + } as Response); + + await channel.send({ + output: 'Reply in thread', + metadata: { + thread_ts: '1234567890.000000', + }, + }); + + const body = JSON.parse((fetch as ReturnType).mock.calls[0][1].body); + expect(body.thread_ts).toBe('1234567890.000000'); + }); + + it('should support threadTs alias', async () => { + const channel = new SlackChannel(baseConfig); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true }), + } as Response); + + await channel.send({ + output: 'Reply in thread', + metadata: { + threadTs: '1234567890.000000', + }, + }); + + const body = JSON.parse((fetch as ReturnType).mock.calls[0][1].body); + expect(body.thread_ts).toBe('1234567890.000000'); + }); + + it('should use metadata.channelId when present instead of config.channel', async () => { + const channel = new SlackChannel({ ...baseConfig, channel: '#general' }); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true }), + } as Response); + + await channel.send({ + output: 'Hello from a different channel', + metadata: { + channelId: 'C99999', // runtime channel from input context + }, + }); + + const body = JSON.parse((fetch as ReturnType).mock.calls[0][1].body); + expect(body.channel).toBe('C99999'); // should use metadata, not config + }); + + it('should fall back to config.channel when no metadata.channelId', async () => { + const channel = new SlackChannel({ ...baseConfig, channel: '#general' }); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true }), + } as Response); + + await channel.send({ + output: 'Hello using config channel', + }); + + const body = JSON.parse((fetch as ReturnType).mock.calls[0][1].body); + expect(body.channel).toBe('#general'); // fallback to config + }); + + it('should use metadata.threadId for threaded replies', async () => { + const channel = new SlackChannel(baseConfig); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true }), + } as Response); + + await channel.send({ + output: 'Reply in thread via threadId', + metadata: { + threadId: '1730250000.000001', // set by normalize via context propagation + }, + }); + + const body = JSON.parse((fetch as ReturnType).mock.calls[0][1].body); + expect(body.thread_ts).toBe('1730250000.000001'); + }); + + it('should throw on API error', async () => { + const channel = new SlackChannel(baseConfig); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: false, error: 'channel_not_found' }), + } as Response); + + await expect(channel.send({ output: 'Test' })).rejects.toThrow('Slack API error: channel_not_found'); + }); + + it('should throw on HTTP error', async () => { + const channel = new SlackChannel(baseConfig); + + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + statusText: 'Unauthorized', + } as Response); + + await expect(channel.send({ output: 'Test' })).rejects.toThrow('Failed to send Slack message: Unauthorized'); + }); + }); + + describe('listen', () => { + it('should start HTTP server', () => { + const channel = new SlackChannel(baseConfig); + + // Mock http module + const mockServer = { + listen: vi.fn(), + }; + + vi.doMock('http', () => ({ + createServer: () => mockServer, + })); + + // Just verify listen() doesn't throw + expect(() => channel.listen()).not.toThrow(); + }); + }); + + describe('normalize - participant', () => { + it('populates first-class participant field with user id when user is present', () => { + const channel = new SlackChannel(baseConfig); + const input = channel.normalize({ + text: 'hi', + user: 'U12345', + ts: '1234567890.123456', + }); + expect(input.participant).toEqual({ kind: 'user', id: 'U12345' }); + }); + + it('leaves participant undefined when event has no user (e.g. bot messages)', () => { + const channel = new SlackChannel(baseConfig); + const input = channel.normalize({ + text: 'bot msg', + ts: '1234567890.123456', + }); + expect(input.participant).toBeUndefined(); + }); + + it('exposes channelType in context for DM detection', () => { + const channel = new SlackChannel(baseConfig); + const input = channel.normalize({ + text: 'dm', + user: 'U12345', + ts: '1234567890.123456', + channel_type: 'im', + }); + expect(input.context?.channelType).toBe('im'); + }); + + it('sets context.threadId for threaded replies so defaultGetScope returns "thread"', () => { + const channel = new SlackChannel(baseConfig); + // A threaded reply has thread_ts (parent) !== ts (this message). + const input = channel.normalize({ + text: 'reply in thread', + user: 'U12345', + ts: '1234567890.999999', + thread_ts: '1234567890.000000', // parent ts + }); + expect(input.context?.threadId).toBe('1234567890.000000'); + // conversationId should still be the thread root ts + expect(input.conversationId).toBe('1234567890.000000'); + }); + + it('does not set context.threadId for top-level messages (thread_ts equals ts)', () => { + const channel = new SlackChannel(baseConfig); + // Some Slack events set thread_ts === ts for the parent message itself. + const input = channel.normalize({ + text: 'top-level message', + user: 'U12345', + ts: '1234567890.000000', + thread_ts: '1234567890.000000', + }); + expect(input.context?.threadId).toBeUndefined(); + }); + + it('does not set context.threadId when thread_ts is absent', () => { + const channel = new SlackChannel(baseConfig); + const input = channel.normalize({ + text: 'plain channel message', + user: 'U12345', + ts: '1234567890.000000', + }); + expect(input.context?.threadId).toBeUndefined(); + }); + + it('extracts @-mention user ids from <@UABC123> tokens in text', () => { + const channel = new SlackChannel(baseConfig); + const input = channel.normalize({ + text: 'Hey <@UABC123> and <@UDEF456>, can you help?', + user: 'U12345', + ts: '1234567890.000000', + }); + expect(input.context?.mentions).toEqual(['UABC123', 'UDEF456']); + }); + + it('sets context.mentions to undefined when no mentions are present', () => { + const channel = new SlackChannel(baseConfig); + const input = channel.normalize({ + text: 'hello everyone', + user: 'U12345', + ts: '1234567890.000000', + }); + expect(input.context?.mentions).toBeUndefined(); + }); + + it('sets context.channelId and context.channelName for channel-level messages', () => { + const channel = new SlackChannel({ ...baseConfig, channel: '#support' }); + const input = channel.normalize({ + text: 'hello', + user: 'U12345', + channel: 'C67890', + ts: '1234567890.000000', + }); + expect(input.context?.channelId).toBe('C67890'); + expect(input.context?.channelName).toBe('#support'); + }); + + it('uses channel id as conversationId for top-level messages (channels are grouped by id)', () => { + const channel = new SlackChannel(baseConfig); + const msg1 = channel.normalize({ text: 'first', user: 'U1', channel: 'C99', ts: '1000.001' }); + const msg2 = channel.normalize({ text: 'second', user: 'U2', channel: 'C99', ts: '1000.002' }); + // Both messages in C99 share the same conversationId + expect(msg1.conversationId).toBe('C99'); + expect(msg2.conversationId).toBe('C99'); + }); + + it('uses thread_ts as conversationId for thread replies (threads grouped separately)', () => { + const channel = new SlackChannel(baseConfig); + const reply1 = channel.normalize({ text: 'r1', user: 'U1', channel: 'C99', ts: '1000.002', thread_ts: '1000.001' }); + const reply2 = channel.normalize({ text: 'r2', user: 'U2', channel: 'C99', ts: '1000.003', thread_ts: '1000.001' }); + expect(reply1.conversationId).toBe('1000.001'); + expect(reply2.conversationId).toBe('1000.001'); + }); + }); + + describe('resolveParticipant', () => { + beforeEach(() => { + // Ensure fetch is a fresh mock per test. + global.fetch = vi.fn(); + }); + + it('returns undefined when input has no user id', async () => { + const channel = new SlackChannel(baseConfig); + const p = await channel.resolveParticipant({ message: 'hi', conversationId: 'c1' }); + expect(p).toBeUndefined(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('hits users.info and returns participant with displayName', async () => { + const channel = new SlackChannel(baseConfig); + (fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + ok: true, + user: { + name: 'alice', + real_name: 'Alice Real', + profile: { display_name: 'alice-display', real_name: 'Alice Profile' }, + }, + }), + } as Response); + + const p = await channel.resolveParticipant({ + message: 'hi', + conversationId: 'c1', + participant: { kind: 'user', id: 'U12345' }, + }); + + expect(p).toMatchObject({ + kind: 'user', + id: 'U12345', + displayName: 'alice-display', + }); + expect(fetch).toHaveBeenCalledWith( + 'https://slack.com/api/users.info?user=U12345', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer xoxb-test-token' }), + }) + ); + }); + + it('caches resolved participants and does not hit fetch twice', async () => { + const channel = new SlackChannel(baseConfig); + (fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + ok: true, + user: { name: 'alice', profile: { display_name: 'alice' } }, + }), + } as Response); + + const input: AgentInput = { + message: 'hi', + conversationId: 'c1', + participant: { kind: 'user', id: 'U12345' }, + }; + + await channel.resolveParticipant(input); + await channel.resolveParticipant(input); + + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('invalidateParticipant forces a re-fetch next time', async () => { + const channel = new SlackChannel(baseConfig); + (fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + ok: true, + user: { name: 'alice', profile: { display_name: 'alice' } }, + }), + } as Response); + + const input: AgentInput = { + message: 'hi', + conversationId: 'c1', + participant: { kind: 'user', id: 'U12345' }, + }; + + await channel.resolveParticipant(input); + channel.invalidateParticipant('U12345'); + await channel.resolveParticipant(input); + + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it('falls back to id-only participant on HTTP error (no throw)', async () => { + const channel = new SlackChannel(baseConfig); + (fetch as ReturnType).mockResolvedValue({ + ok: false, + statusText: 'Unauthorized', + } as Response); + + const p = await channel.resolveParticipant({ + message: 'hi', + conversationId: 'c1', + participant: { kind: 'user', id: 'U12345' }, + }); + expect(p).toEqual({ kind: 'user', id: 'U12345' }); + }); + + it('falls back to id-only participant when fetch throws', async () => { + const channel = new SlackChannel(baseConfig); + (fetch as ReturnType).mockRejectedValue(new Error('network down')); + + const p = await channel.resolveParticipant({ + message: 'hi', + conversationId: 'c1', + participant: { kind: 'user', id: 'U12345' }, + }); + expect(p).toEqual({ kind: 'user', id: 'U12345' }); + }); + + it('reads user id from context.user when input.participant is missing', async () => { + const channel = new SlackChannel(baseConfig); + (fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + ok: true, + user: { name: 'carol', profile: { display_name: 'carol' } }, + }), + } as Response); + + const p = await channel.resolveParticipant({ + message: 'hi', + conversationId: 'c1', + context: { user: 'U99999' }, + }); + expect(p).toMatchObject({ kind: 'user', id: 'U99999', displayName: 'carol' }); + }); + }); + + describe('invalidateParticipant', () => { + beforeEach(() => { + global.fetch = vi.fn(); + }); + + it('user_change event removes the stale entry from the participant cache', async () => { + const channel = new SlackChannel(baseConfig); + + // Prime the cache with a resolved participant. + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + ok: true, + user: { id: 'U12345', profile: { display_name: 'Alice' } }, + }), + } as Response); + await channel.resolveParticipant({ message: 'hi', conversationId: 'c1', context: { user: 'U12345' } }); + expect(fetch).toHaveBeenCalledTimes(1); + + // Invalidate manually (same path the user_change handler takes). + channel.invalidateParticipant('U12345'); + + // Next lookup should hit the API again (cache miss). + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + ok: true, + user: { id: 'U12345', profile: { display_name: 'Alice (updated)' } }, + }), + } as Response); + const p = await channel.resolveParticipant({ message: 'hi', conversationId: 'c1', context: { user: 'U12345' } }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(p?.displayName).toBe('Alice (updated)'); + }); + }); + + // --------------------------------------------------------------------------- + // Signature verification + // --------------------------------------------------------------------------- + + describe('verifySignature', () => { + const signingSecret = 'test-signing-secret'; + const channel = new SlackChannel({ ...baseConfig, signingSecret }); + + function makeTimestamp(): string { + return String(Math.floor(Date.now() / 1000)); + } + + function sign(body: string, timestamp: string, secret = signingSecret): string { + const basestring = `v0:${timestamp}:${body}`; + const hmac = createHmac('sha256', secret).update(basestring).digest('hex'); + return `v0=${hmac}`; + } + + it('accepts a valid signature', () => { + const body = '{"type":"event_callback"}'; + const timestamp = makeTimestamp(); + const signature = sign(body, timestamp); + + expect(channel.verifySignature( + { 'x-slack-request-timestamp': timestamp, 'x-slack-signature': signature }, + body + )).toBe(true); + }); + + it('rejects a wrong signature', () => { + const body = '{"type":"event_callback"}'; + const timestamp = makeTimestamp(); + const wrongSig = sign(body, timestamp, 'wrong-secret'); + + expect(channel.verifySignature( + { 'x-slack-request-timestamp': timestamp, 'x-slack-signature': wrongSig }, + body + )).toBe(false); + }); + + it('rejects a stale timestamp (older than 5 minutes)', () => { + const body = '{"type":"event_callback"}'; + const staleTimestamp = String(Math.floor(Date.now() / 1000) - 310); + const signature = sign(body, staleTimestamp); + + expect(channel.verifySignature( + { 'x-slack-request-timestamp': staleTimestamp, 'x-slack-signature': signature }, + body + )).toBe(false); + }); + + it('rejects a missing timestamp header', () => { + const body = '{"type":"event_callback"}'; + const timestamp = makeTimestamp(); + const signature = sign(body, timestamp); + + expect(channel.verifySignature( + { 'x-slack-signature': signature }, + body + )).toBe(false); + }); + + it('rejects a missing signature header', () => { + const body = '{"type":"event_callback"}'; + const timestamp = makeTimestamp(); + + expect(channel.verifySignature( + { 'x-slack-request-timestamp': timestamp }, + body + )).toBe(false); + }); + + it('rejects when signature length does not match (malformed input)', () => { + const body = '{"type":"event_callback"}'; + const timestamp = makeTimestamp(); + + expect(channel.verifySignature( + { 'x-slack-request-timestamp': timestamp, 'x-slack-signature': 'v0=tooshort' }, + body + )).toBe(false); + }); + + it('rejects array-valued headers (duplicate header attack)', () => { + const body = '{"type":"event_callback"}'; + const timestamp = makeTimestamp(); + const signature = sign(body, timestamp); + + // HTTP allows duplicate headers; Node.js represents them as arrays. + // The Array.isArray guards prevent these from being coerced to strings. + expect(channel.verifySignature( + { 'x-slack-request-timestamp': [timestamp, timestamp] as any, 'x-slack-signature': signature }, + body + )).toBe(false); + + expect(channel.verifySignature( + { 'x-slack-request-timestamp': timestamp, 'x-slack-signature': [signature, signature] as any }, + body + )).toBe(false); + }); + + it('rejects a non-numeric timestamp (NaN must not bypass the age check)', () => { + const body = '{"type":"event_callback"}'; + // parseInt('not-a-number', 10) === NaN; NaN comparisons are always false, + // which would silently pass the age check without the isNaN() guard. + const signature = sign(body, 'not-a-number'); + + expect(channel.verifySignature( + { 'x-slack-request-timestamp': 'not-a-number', 'x-slack-signature': signature }, + body + )).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // Startup self-check + // --------------------------------------------------------------------------- + + describe('startup self-check (auth.test)', () => { + beforeEach(() => { + global.fetch = vi.fn(); + }); + + it('sets botUserId from a successful auth.test response', async () => { + const channel = new SlackChannel(baseConfig); + + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ + ok: true, + user_id: 'U_BOT123', + user: 'kael', + team: 'TOOLPACK', + url: 'https://toolpack.slack.com/', + }), + } as Response); + + // Call the private method directly via cast + await (channel as any).runStartupCheck(); + + expect(channel.botUserId).toBe('U_BOT123'); + }); + + it('leaves botUserId undefined when auth.test returns ok: false', async () => { + const channel = new SlackChannel(baseConfig); + + (global.fetch as ReturnType).mockResolvedValue({ + ok: true, + json: async () => ({ ok: false, error: 'invalid_auth' }), + } as Response); + + await (channel as any).runStartupCheck(); + + expect(channel.botUserId).toBeUndefined(); + }); + + it('does not throw when auth.test request fails (network error)', async () => { + const channel = new SlackChannel(baseConfig); + + (global.fetch as ReturnType).mockRejectedValue(new Error('Network error')); + + await expect((channel as any).runStartupCheck()).resolves.toBeUndefined(); + expect(channel.botUserId).toBeUndefined(); + }); + }); + + describe('URL verification', () => { + it('should handle Slack URL verification challenge', async () => { + const channel = new SlackChannel(baseConfig); + + // Simulate URL verification by calling handleRequest indirectly + // This tests that the channel responds with the challenge + const mockRes = { + writeHead: vi.fn(), + end: vi.fn(), + }; + + // We can't easily test this without exposing handleRequest + // But we've verified the implementation exists in the source + expect(true).toBe(true); + }); + }); + + describe('shouldProcessEvent', () => { + it('accepts human messages (no bot_id)', () => { + const channel = new SlackChannel(baseConfig); + expect(channel.shouldProcessEvent({ type: 'message', user: 'U_ALICE', text: 'hi', channel: '#support' })).toBe(true); + }); + + it('accepts app_mention events from humans', () => { + const channel = new SlackChannel(baseConfig); + expect(channel.shouldProcessEvent({ type: 'app_mention', user: 'U_ALICE', channel: '#support' })).toBe(true); + }); + + it('rejects unrelated event types even without bot_id', () => { + const channel = new SlackChannel(baseConfig); + expect(channel.shouldProcessEvent({ type: 'reaction_added', user: 'U_ALICE' })).toBe(false); + }); + + it('accepts bot messages by default when no allowlist is configured (Option B)', () => { + const channel = new SlackChannel(baseConfig); + expect(channel.shouldProcessEvent({ + type: 'message', + bot_id: 'B_RAM_DEV_BOT', + user: 'U_RAM_DEV', + channel: '#support', + })).toBe(true); + }); + + it('accepts bot messages when bot_id is whitelisted (B...)', () => { + const channel = new SlackChannel({ + ...baseConfig, + allowedBotIds: ['B_RAM_DEV_BOT'], + }); + expect(channel.shouldProcessEvent({ + type: 'message', + bot_id: 'B_RAM_DEV_BOT', + user: 'U_RAM_DEV', + channel: '#support', + })).toBe(true); + }); + + it('accepts bot messages when the user id is whitelisted (U...)', () => { + // The footgun fix: developers commonly have the peer agent's + // SlackChannel.botUserId (U...) but not its bot_id (B...). + const channel = new SlackChannel({ + ...baseConfig, + allowedBotIds: ['U_RAM_DEV'], + }); + expect(channel.shouldProcessEvent({ + type: 'message', + bot_id: 'B_RAM_DEV_BOT', + user: 'U_RAM_DEV', + channel: '#support', + })).toBe(true); + }); + + it('rejects bot messages when neither bot_id nor user is in whitelist', () => { + const channel = new SlackChannel({ + ...baseConfig, + allowedBotIds: ['B_SOMEONE_ELSE'], + }); + expect(channel.shouldProcessEvent({ + type: 'message', + bot_id: 'B_RAM_DEV_BOT', + user: 'U_RAM_DEV', + channel: '#support', + })).toBe(false); + }); + + it('handles bot messages that carry bot_id but no user field', () => { + const channel = new SlackChannel({ + ...baseConfig, + allowedBotIds: ['B_RAM_DEV_BOT'], + }); + expect(channel.shouldProcessEvent({ + type: 'message', + bot_id: 'B_RAM_DEV_BOT', + channel: '#support', + })).toBe(true); + }); + + describe('self-suppression (automatic via botUserId)', () => { + it('drops events originating from its own botUserId without any config', () => { + const channel = new SlackChannel(baseConfig); + channel.botUserId = 'U_SELF'; // simulates post-auth.test state + + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U_SELF', + bot_id: 'B_SELF', + channel: '#support', + })).toBe(false); + }); + + it('drops own message even when not accompanied by bot_id', () => { + const channel = new SlackChannel(baseConfig); + channel.botUserId = 'U_SELF'; + + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U_SELF', + channel: '#support', + })).toBe(false); + }); + + it('passes events from a different user even if botUserId is set', () => { + const channel = new SlackChannel(baseConfig); + channel.botUserId = 'U_SELF'; + + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U_ALICE', + channel: '#support', + })).toBe(true); + }); + + it('when botUserId is not yet discovered, non-self bots follow default-allow mode', () => { + const channel = new SlackChannel(baseConfig); + // botUserId not set — startup check not yet completed + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U_SOMEONE', + bot_id: 'B_SOMEONE', + channel: '#support', + })).toBe(true); + }); + + it('does not require self in allowedBotIds (self-suppression is automatic)', () => { + const channel = new SlackChannel({ + ...baseConfig, + allowedBotIds: [], // empty allowlist + }); + channel.botUserId = 'U_SELF'; + + // Self is still dropped despite empty allowlist + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U_SELF', + bot_id: 'B_SELF', + channel: '#support', + })).toBe(false); + }); + + it('in strict mode, empty allowedBotIds rejects all non-self bot messages', () => { + const channel = new SlackChannel({ + ...baseConfig, + allowedBotIds: [], + }); + + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U_OTHER_BOT', + bot_id: 'B_OTHER_BOT', + channel: '#support', + })).toBe(false); + }); + + it('blockedBotIds rejects matching bot even in default-allow mode', () => { + const channel = new SlackChannel({ + ...baseConfig, + blockedBotIds: ['B_GITHUB_BOT'], + }); + + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U_GITHUB_BOT', + bot_id: 'B_GITHUB_BOT', + channel: '#support', + })).toBe(false); + }); + + it('blockedBotIds takes precedence over allowedBotIds', () => { + const channel = new SlackChannel({ + ...baseConfig, + allowedBotIds: ['B_GITHUB_BOT'], + blockedBotIds: ['B_GITHUB_BOT'], + }); + + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U_GITHUB_BOT', + bot_id: 'B_GITHUB_BOT', + channel: '#support', + })).toBe(false); + }); + }); + + describe('channel allowlist filter', () => { + it('accepts events from any channel when channel config is omitted (null = listen everywhere)', () => { + const channel = new SlackChannel({ + token: 'xoxb-test', + signingSecret: 'secret', + // no channel set + }); + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U1', + channel: 'C_RANDOM', + })).toBe(true); + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U1', + channel: 'C_OTHER', + })).toBe(true); + }); + + it('accepts events from any channel when channel config is explicitly null', () => { + const channel = new SlackChannel({ + token: 'xoxb-test', + signingSecret: 'secret', + channel: null, + }); + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U1', + channel: 'C_ANY', + })).toBe(true); + }); + + it('accepts events only from the configured single channel', () => { + const channel = new SlackChannel({ + ...baseConfig, + channel: '#support', + }); + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U1', + channel: '#support', + })).toBe(true); + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U1', + channel: '#random', + })).toBe(false); + }); + + it('accepts events from any channel in the configured array', () => { + const channel = new SlackChannel({ + ...baseConfig, + channel: ['#general', '#project-kore'], + }); + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U1', + channel: '#general', + })).toBe(true); + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U1', + channel: '#project-kore', + })).toBe(true); + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U1', + channel: '#random', + })).toBe(false); + }); + + it('always allows DMs (channel_type=im) regardless of the channel allowlist', () => { + const channel = new SlackChannel({ + ...baseConfig, + channel: '#support', + }); + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U1', + channel: 'D_USER_DM', + channel_type: 'im', + })).toBe(true); + }); + + it('always allows multi-person DMs (channel_type=mpim)', () => { + const channel = new SlackChannel({ + ...baseConfig, + channel: ['#general'], + }); + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U1', + channel: 'G_GROUP_DM', + channel_type: 'mpim', + })).toBe(true); + }); + + it('rejects events missing the channel field when a filter is active', () => { + const channel = new SlackChannel({ + ...baseConfig, + channel: '#support', + }); + expect(channel.shouldProcessEvent({ + type: 'message', + user: 'U1', + // no channel field + })).toBe(false); + }); + }); + }); + + describe('send with multi-channel config', () => { + it('uses first array element when metadata.channelId absent and channel is an array', async () => { + const channel = new SlackChannel({ + ...baseConfig, + channel: ['#general', '#project-kore'], + }); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true }), + } as Response); + + await channel.send({ output: 'hello' }); + + const body = JSON.parse((fetch as ReturnType).mock.calls[0][1].body); + expect(body.channel).toBe('#general'); // first element + }); + + it('throws when channel is null and metadata.channelId is absent', async () => { + const channel = new SlackChannel({ + token: 'xoxb', + signingSecret: 'secret', + channel: null, + }); + + await expect(channel.send({ output: 'hello' })).rejects.toThrow( + /Cannot send: no channel configured/ + ); + }); + + it('uses metadata.channelId over array first element', async () => { + const channel = new SlackChannel({ + ...baseConfig, + channel: ['#general', '#project-kore'], + }); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true }), + } as Response); + + await channel.send({ + output: 'hello', + metadata: { channelId: 'C_SPECIFIC' }, + }); + + const body = JSON.parse((fetch as ReturnType).mock.calls[0][1].body); + expect(body.channel).toBe('C_SPECIFIC'); + }); + }); + + describe('normalize channelName label', () => { + it('uses config.channel as label when config is a single string', () => { + const channel = new SlackChannel({ ...baseConfig, channel: '#general' }); + const input = channel.normalize({ + text: 'hi', + user: 'U1', + channel: 'C_GENERAL', + ts: '1000.001', + }); + expect(input.context?.channelName).toBe('#general'); + }); + + it('falls back to event channel id when config is an array', () => { + const channel = new SlackChannel({ + ...baseConfig, + channel: ['#general', '#project-kore'], + }); + const input = channel.normalize({ + text: 'hi', + user: 'U1', + channel: 'C_PROJECT', + ts: '1000.001', + }); + expect(input.context?.channelName).toBe('C_PROJECT'); + }); + + it('falls back to event channel id when config is null', () => { + const channel = new SlackChannel({ + token: 'xoxb', + signingSecret: 'secret', + channel: null, + }); + const input = channel.normalize({ + text: 'hi', + user: 'U1', + channel: 'C_ANY', + ts: '1000.001', + }); + expect(input.context?.channelName).toBe('C_ANY'); + }); + }); +}); diff --git a/packages/toolpack-agents/src/channels/slack-channel.ts b/packages/toolpack-agents/src/channels/slack-channel.ts new file mode 100644 index 0000000..23b5ce8 --- /dev/null +++ b/packages/toolpack-agents/src/channels/slack-channel.ts @@ -0,0 +1,595 @@ +import { createHmac, timingSafeEqual } from 'crypto'; +import { BaseChannel } from './base-channel.js'; +import { AgentInput, AgentOutput, Participant } from '../agent/types.js'; + +/** + * Configuration options for SlackChannel. + */ +export interface SlackChannelConfig { + /** Optional name for the channel - required for sendTo() routing */ + name?: string; + + /** + * Which Slack channel(s) this instance listens to and replies into. + * + * - `string` (e.g. `'#support'` or `'C12345'`) — single channel (back-compat). + * - `string[]` — multiple channels; inbound events outside this list are dropped. + * - `null` / omitted — listen to every channel the bot is invited to. + * + * **Matching:** compared verbatim against `event.channel` from the Slack payload. + * Slack events carry channel IDs (`C...`), so pass IDs here for deterministic + * filtering. If you pass a display name like `'#general'`, it must match the + * raw string Slack sends — usually an ID, not a name. DMs (`im`/`mpim`) are + * always accepted regardless of this list. + * + * **Outbound:** when sending, `metadata.channelId` (set by `normalize()`) wins. + * If absent, the fallback is: `string` → itself; `string[]` → first element; + * `null` → error (must provide `metadata.channelId`). + */ + channel?: string | string[] | null; + + /** Slack bot token (starts with 'xoxb-') */ + token: string; + + /** Slack app signing secret for request verification */ + signingSecret: string; + + /** Optional port for the HTTP server (default: 3000) */ + port?: number; + + /** + * Allowlist of bot identities whose Slack messages should be processed when + * strict mode is desired. + * + * Behavior: + * - Omitted: non-self bot messages are accepted by default (Option B). + * - Provided (including empty array): only listed bots are accepted. + * + * Each entry is matched against **both** `event.bot_id` (a `B...` integration + * id) and `event.user` (a `U...` user id), since Slack events carry both and + * developers frequently know one but not the other. Pass whichever you have — + * typically the peer agent's `SlackChannel.botUserId` (a `U...` value). + * + * Note: for normal multi-agent teams you do **not** need to list peers here — + * non-self bot messages are allowed by default. Use this field only when you + * want strict, allowlist-only acceptance. To suppress specific noisy bots + * (e.g. GitHub, CI) while keeping the default-allow behavior, prefer + * {@link SlackChannelConfig.blockedBotIds}. + * + * Example (strict mode): `allowedBotIds: [ramDevAgent.slackChannel.botUserId, 'B_YALINA_BOT']` + */ + allowedBotIds?: string[]; + + /** + * Blocklist of bot identities that should always be ignored. + * + * Matched against both `event.bot_id` (B...) and `event.user` (U...). + * Takes precedence over `allowedBotIds` and the default allow behavior. + */ + blockedBotIds?: string[]; +} + +/** + * Slack channel for two-way Slack integration. + * Receives messages from users and replies in-thread. + */ +export class SlackChannel extends BaseChannel { + readonly isTriggerChannel = false; + private config: SlackChannelConfig; + private server?: any; // HTTP server instance + + /** + * Per-process cache of resolved participants keyed by Slack user id. + * Populated lazily by `resolveParticipant()`. Invalidated on `user_change` + * events via `invalidateParticipant()`. + */ + private participantCache: Map = new Map(); + + /** + * The bot's Slack user id (e.g. `'U_BOT123'`), populated by the startup + * self-check (`auth.test`) when `listen()` is called. + * + * Pass this to `AssemblerOptions.agentAliases` so the assembler's + * addressed-only mode can match `<@U_BOT123>` mentions against this agent: + * ```ts + * assemblePrompt(store, conversationId, agent.name, agent.name, { + * agentAliases: [slackChannel.botUserId].filter(Boolean) as string[], + * }); + * ``` + */ + botUserId?: string; + + /** + * Normalized allowlist of channel identifiers, or `null` to accept any channel. + * Derived from `config.channel` at construction time. + */ + private allowedChannels: string[] | null; + + constructor(config: SlackChannelConfig) { + super(); + this.config = { + port: 3000, + ...config, + }; + this.name = config.name; + + // Normalize channel config into a uniform allowlist (or null = any). + const c = config.channel; + this.allowedChannels = + c == null ? null + : Array.isArray(c) ? c + : [c]; + } + + /** + * Start listening for Slack events via HTTP webhook. + * + * Performs a startup self-check (`auth.test`) after the server is ready. + * The bot user id is stored on `this.botUserId` for use in `agentAliases`. + */ + listen(): void { + if (typeof process !== 'undefined') { + import('http').then((http) => { + this.server = http.createServer((req, res) => { + this.handleRequest(req, res); + }); + + this.server.listen(this.config.port, () => { + console.log(`[SlackChannel] Listening on port ${this.config.port}`); + // Run async — failure is logged but does not prevent the server from serving. + this.runStartupCheck().catch(() => {}); + }); + }).catch((err) => { + console.error('[SlackChannel] Failed to start HTTP server:', err); + }); + } + } + + /** + * Calls Slack's `auth.test` API to verify credentials and log the bot's + * identity. Stores `botUserId` for use in `AssemblerOptions.agentAliases`. + * Non-fatal — a failed check logs a warning but does not stop the server. + */ + private async runStartupCheck(): Promise { + try { + const response = await fetch('https://slack.com/api/auth.test', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.config.token}`, + 'Content-Type': 'application/json', + }, + }); + + const data = await response.json() as { + ok: boolean; + user_id?: string; + user?: string; + team?: string; + url?: string; + error?: string; + }; + + if (data.ok) { + this.botUserId = data.user_id; + console.log( + `[SlackChannel] Connected as @${data.user} (${data.user_id}) ` + + `in workspace "${data.team}" — ${data.url}` + ); + } else { + console.warn(`[SlackChannel] auth.test failed: ${data.error}. Check your bot token.`); + } + } catch (err) { + console.warn('[SlackChannel] Startup self-check failed (network error):', err); + } + } + + /** + * Verify a Slack request signature using HMAC-SHA256. + * + * Implements Slack's signing secret verification spec: + * https://api.slack.com/authentication/verifying-requests-from-slack + * + * - Rejects requests with a timestamp older than 5 minutes (replay protection). + * - Uses `timingSafeEqual` to prevent timing-oracle attacks. + * + * Returns `false` for any missing, malformed, or invalid input so that the + * caller can respond with 401 without leaking which check failed. + */ + verifySignature( + headers: Record, + rawBody: string + ): boolean { + const timestamp = headers['x-slack-request-timestamp']; + const signature = headers['x-slack-signature']; + + if (!timestamp || !signature || Array.isArray(timestamp) || Array.isArray(signature)) { + return false; + } + + // Reject stale or non-numeric timestamps (replay attack prevention). + // parseInt returns NaN for non-numeric strings; NaN comparisons are always + // false, which would incorrectly pass the check — guard explicitly. + const parsedTimestamp = parseInt(timestamp, 10); + const nowSeconds = Math.floor(Date.now() / 1000); + if (isNaN(parsedTimestamp) || Math.abs(nowSeconds - parsedTimestamp) > 300) { + return false; + } + + const sigBasestring = `v0:${timestamp}:${rawBody}`; + const hmac = createHmac('sha256', this.config.signingSecret) + .update(sigBasestring) + .digest('hex'); + const computedSig = `v0=${hmac}`; + + // timingSafeEqual requires equal-length buffers; length mismatch means + // the signature is definitely wrong (avoids the throw and leaks no timing info). + if (computedSig.length !== signature.length) { + return false; + } + + try { + return timingSafeEqual(Buffer.from(computedSig), Buffer.from(signature)); + } catch { + return false; + } + } + + /** + * Send a message back to Slack. + * @param output The agent output to send + */ + async send(output: AgentOutput): Promise { + // Post message to Slack using chat.postMessage API + // Use thread_ts from metadata for threaded replies (conversation continuity) + // Accept threadTs, thread_ts, or threadId (normalize sets threadId) + const threadTs = + (output.metadata?.threadTs as string | undefined) ?? + (output.metadata?.thread_ts as string | undefined) ?? + (output.metadata?.threadId as string | undefined); + + // Resolve target channel. Priority: + // 1. metadata.channelId (set by normalize via context propagation) + // 2. First entry of allowedChannels (or the single configured channel) + // 3. Error — cannot send without a destination. + const metaChannel = output.metadata?.channelId as string | undefined; + const targetChannel = + metaChannel ?? + (this.allowedChannels && this.allowedChannels.length > 0 + ? this.allowedChannels[0] + : undefined); + + if (!targetChannel) { + throw new Error( + '[SlackChannel] Cannot send: no channel configured and metadata.channelId is missing. ' + + 'Provide a target via SlackChannelConfig.channel or output.metadata.channelId.' + ); + } + + const payload: Record = { + channel: targetChannel, + text: output.output, + }; + + // Reply in thread if thread_ts is available + if (threadTs) { + payload.thread_ts = threadTs; + } + + const response = await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${this.config.token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Failed to send Slack message: ${response.statusText}`); + } + + const data = await response.json() as { ok: boolean; error?: string }; + if (!data.ok) { + throw new Error(`Slack API error: ${data.error}`); + } + } + + /** + * Normalize a Slack event into AgentInput. + * @param incoming Slack event payload + * @returns Normalized AgentInput + */ + normalize(incoming: unknown): AgentInput { + const event = incoming as Record; + + // Extract message text + const text = (event.text as string) || ''; + + // Extract timestamps. + // thread_ts is present only on replies — it's the parent message ts. + // ts is always present and identifies this specific message. + const ts = event.ts as string | undefined; + const rawThreadTs = event.thread_ts as string | undefined; + + // A message is a threaded reply when thread_ts is present AND differs from ts. + // Expose this as context.threadId so `defaultGetScope` can detect it. + const isThreadReply = rawThreadTs !== undefined && rawThreadTs !== ts; + + // Extract user info + const user = event.user as string | undefined; + + // First-class participant (id-only at this stage; displayName is resolved + // lazily via `resolveParticipant()` to keep capture cheap). + const participant: Participant | undefined = user + ? { kind: 'user', id: user } + : undefined; + + // Extract @-mention user ids from Slack's `<@UABC123>` tokens in the text. + // These populate `metadata.mentions` in the capture interceptor so the + // assembler's addressed-only mode can recognise which agents were addressed. + const mentionRegex = /<@([A-Z0-9]+)>/g; + const mentions: string[] = []; + let mentionMatch: RegExpExecArray | null; + while ((mentionMatch = mentionRegex.exec(text)) !== null) { + mentions.push(mentionMatch[1]); + } + + // For top-level channel/DM messages, conversationId = the channel ID so all + // messages in a channel are grouped under the same key. + // For thread replies, conversationId = thread_ts (the parent message ts) so + // all replies within a thread share one key, independent of the channel. + const slackChannelId = event.channel as string | undefined; + const conversationId = isThreadReply + ? (rawThreadTs as string) + : (slackChannelId || ts || ''); + + return { + message: text, + conversationId, + data: event, + participant, + context: { + user, + channel: slackChannelId, + team: event.team as string, + // Channel_type is 'im' for DMs, 'channel' / 'group' otherwise. + // Exposed so the address-check interceptor can treat DMs as direct. + channelType: event.channel_type as string | undefined, + // Set when this message is a reply inside a thread. + // Used by defaultGetScope to classify the message as scope: 'thread'. + threadId: isThreadReply ? rawThreadTs : undefined, + // @-mentioned user ids extracted from <@UABC123> tokens. Read by + // the capture interceptor's default getMentions() and written to + // StoredMessage.metadata.mentions for addressed-only mode. + mentions: mentions.length > 0 ? mentions : undefined, + // Platform channel id — always the Slack channel, even for threads + // (where conversationId is the thread root ts, not the channel id). + channelId: slackChannelId, + // Human-readable channel label. Prefer the configured value when the + // channel is pinned to a single string (classic single-room setup), since + // that is usually a friendly name like '#general'. For multi-channel or + // listen-everywhere configs, fall back to the event's channel id since + // we have no deterministic friendly label without an extra API call. + channelName: + typeof this.config.channel === 'string' + ? this.config.channel + : slackChannelId, + }, + }; + } + + /** + * Resolve a richer `Participant` (with `displayName`) for a normalized input. + * + * Uses Slack's `users.info` API and an in-process cache. Returns `undefined` + * if the input has no user id or if the lookup fails; callers fall back to + * the bare id. Never throws. + * + * Cache invalidation is handled externally via `invalidateParticipant()`, + * typically wired to the Slack `user_change` event. + */ + async resolveParticipant(input: AgentInput): Promise { + const userId = input.participant?.id ?? (input.context?.user as string | undefined); + if (!userId) return undefined; + + // Return cached copy if we've resolved this user before. + const cached = this.participantCache.get(userId); + if (cached) return cached; + + try { + const response = await fetch( + `https://slack.com/api/users.info?user=${encodeURIComponent(userId)}`, + { + method: 'GET', + headers: { 'Authorization': `Bearer ${this.config.token}` }, + } + ); + if (!response.ok) return { kind: 'user', id: userId }; + + const data = (await response.json()) as { + ok: boolean; + user?: { + name?: string; + real_name?: string; + profile?: { display_name?: string; real_name?: string }; + }; + }; + + if (!data.ok || !data.user) { + const fallback: Participant = { kind: 'user', id: userId }; + this.participantCache.set(userId, fallback); + return fallback; + } + + const displayName = + data.user.profile?.display_name || + data.user.profile?.real_name || + data.user.real_name || + data.user.name || + userId; + + const participant: Participant = { + kind: 'user', + id: userId, + displayName, + metadata: { slackUser: data.user }, + }; + this.participantCache.set(userId, participant); + return participant; + } catch { + // Network/parse errors must not crash the pipeline. + return { kind: 'user', id: userId }; + } + } + + /** + * Invalidate a cached participant. Call this from a `user_change` + * Slack event handler to force a refresh on the next lookup. + */ + invalidateParticipant(userId: string): void { + this.participantCache.delete(userId); + } + + /** + * Decide whether an incoming Slack event should be normalised and dispatched + * to the agent. + * + * Rules, applied in order: + * 1. Only `message` and `app_mention` events are processed; others are dropped. + * 2. Channel allowlist (from `config.channel`): events outside the allowlist + * are dropped. DMs (`im`/`mpim`) always pass because they are per-user, not + * per-channel. Skipped entirely when `config.channel` is null/omitted. + * 3. **Self-suppression (automatic):** events where `event.user` matches this + * channel's own `botUserId` are dropped. This prevents agents from looping + * on their own posts and requires no configuration — `botUserId` is + * discovered via `auth.test` at startup. + * 4. Events without `bot_id` (human messages) pass. + * 5. Explicit blocklist check (`blockedBotIds`) runs first for bot messages. + * 6. If `allowedBotIds` is provided, strict mode applies: only listed bots + * pass (matched against both `bot_id` and `user`). + * 7. If `allowedBotIds` is omitted, other bot messages pass by default. + * + * Exposed for direct unit testing; not intended as a public API. + */ + shouldProcessEvent(event: Record): boolean { + const type = event.type as string | undefined; + if (type !== 'message' && type !== 'app_mention') return false; + + // Channel allowlist filter. DMs (`im`/`mpim`) always pass because they + // are conceptually per-user, not per-channel. + if (this.allowedChannels !== null) { + const channelType = event.channel_type as string | undefined; + const isDM = channelType === 'im' || channelType === 'mpim'; + if (!isDM) { + const eventChannel = event.channel as string | undefined; + if (!eventChannel || !this.allowedChannels.includes(eventChannel)) { + return false; + } + } + } + + const userId = event.user as string | undefined; + + // Precise self-suppression: drop events originating from this bot's own + // user id. Prevents self-reply loops without needing any config entry. + if (this.botUserId && userId === this.botUserId) { + return false; + } + + const botId = event.bot_id as string | undefined; + if (!botId) return true; + + const blockedList = this.config.blockedBotIds ?? []; + if ( + blockedList.includes(botId) || + (userId !== undefined && blockedList.includes(userId)) + ) { + return false; + } + + // Strict mode when allowlist is explicitly provided. + if (this.config.allowedBotIds !== undefined) { + const allowedList = this.config.allowedBotIds; + return ( + allowedList.includes(botId) || + (userId !== undefined && allowedList.includes(userId)) + ); + } + + // Default mode (Option B): accept non-self bot messages unless blocked. + return true; + } + + /** + * Handle incoming HTTP requests from Slack. + */ + private handleRequest(req: any, res: any): void { + if (req.method !== 'POST') { + res.writeHead(405); + res.end('Method not allowed'); + return; + } + + let body = ''; + req.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); + + req.on('end', () => { + // Verify the request is genuinely from Slack before processing. + if (!this.verifySignature(req.headers, body)) { + res.writeHead(401); + res.end('Invalid signature'); + return; + } + + try { + const payload = JSON.parse(body); + + // Handle URL verification challenge + if (payload.type === 'url_verification') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ challenge: payload.challenge })); + return; + } + + // Handle event callbacks + if (payload.type === 'event_callback' && payload.event) { + const event = payload.event; + + // Apply inbound filters (event type, channel, self-suppression, bot policy). + // See shouldProcessEvent() for the full rule set. + if (this.shouldProcessEvent(event)) { + const input = this.normalize(event); + this.handleMessage(input); + } else if (event.type === 'user_change' && event.user) { + // Invalidate cached participant so the next lookup fetches fresh display-name. + this.invalidateParticipant((event.user as Record).id as string); + } + + res.writeHead(200); + res.end('OK'); + return; + } + + res.writeHead(200); + res.end('OK'); + } catch (error) { + console.error('[SlackChannel] Error handling request:', error); + res.writeHead(400); + res.end('Bad request'); + } + }); + } + + /** + * Stop the HTTP server. + */ + async stop(): Promise { + if (this.server) { + return new Promise((resolve) => { + this.server.close(resolve); + }); + } + } +} diff --git a/packages/toolpack-agents/src/channels/sms-channel.test.ts b/packages/toolpack-agents/src/channels/sms-channel.test.ts new file mode 100644 index 0000000..e559a6c --- /dev/null +++ b/packages/toolpack-agents/src/channels/sms-channel.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { SMSChannel } from './sms-channel.js'; + +describe('SMSChannel', () => { + describe('outbound-only configuration', () => { + let channel: SMSChannel; + + beforeEach(() => { + channel = new SMSChannel({ + name: 'test-sms-outbound', + accountSid: 'AC123', + authToken: 'token123', + from: '+1234567890', + to: '+0987654321', + }); + }); + + it('should have correct configuration', () => { + expect(channel.name).toBe('test-sms-outbound'); + expect(channel.isTriggerChannel).toBe(true); + }); + + it('should be a trigger channel when no webhookPath', () => { + expect(channel.isTriggerChannel).toBe(true); + }); + }); + + describe('two-way configuration', () => { + let channel: SMSChannel; + + beforeEach(() => { + channel = new SMSChannel({ + name: 'test-sms-twoway', + accountSid: 'AC123', + authToken: 'token123', + from: '+1234567890', + webhookPath: '/sms/webhook', + port: 3001, + }); + }); + + it('should not be a trigger channel when webhookPath is set', () => { + expect(channel.isTriggerChannel).toBe(false); + }); + + it('should normalize Twilio webhook payload', () => { + const payload = { + From: '+0987654321', + To: '+1234567890', + Body: 'Hello from SMS', + MessageSid: 'SM123', + }; + + const input = channel.normalize(payload); + + expect(input.message).toBe('Hello from SMS'); + expect(input.conversationId).toBe('+0987654321'); + expect(input.context?.from).toBe('+0987654321'); + expect(input.context?.messageSid).toBe('SM123'); + }); + }); + + it('should initialize without errors', () => { + const channel = new SMSChannel({ + accountSid: 'AC123', + authToken: 'token123', + from: '+1234567890', + to: '+0987654321', + }); + + expect(() => channel.listen()).not.toThrow(); + }); +}); diff --git a/packages/toolpack-agents/src/channels/sms-channel.ts b/packages/toolpack-agents/src/channels/sms-channel.ts new file mode 100644 index 0000000..734e42d --- /dev/null +++ b/packages/toolpack-agents/src/channels/sms-channel.ts @@ -0,0 +1,193 @@ +import { BaseChannel } from './base-channel.js'; +import { AgentInput, AgentOutput } from '../agent/types.js'; + +/** + * Configuration options for SMSChannel (Twilio). + */ +export interface SMSChannelConfig { + /** Optional name for the channel - required for sendTo() routing */ + name?: string; + + /** Twilio Account SID */ + accountSid: string; + + /** Twilio Auth Token */ + authToken: string; + + /** Twilio phone number (sender) */ + from: string; + + /** Recipient phone number - for outbound/scheduled SMS */ + to?: string; + + /** Optional webhook path for inbound SMS (e.g., '/sms/webhook') */ + webhookPath?: string; + + /** Optional port for the HTTP server (default: 3000) */ + port?: number; +} + +/** + * SMS channel for Twilio integration. + * Can be configured as: + * - Two-way: Set webhookPath to receive inbound SMS and reply + * - Outbound-only: Set 'to' without webhookPath for scheduled/triggered SMS + */ +export class SMSChannel extends BaseChannel { + private config: SMSChannelConfig; + private twilioClient?: any; + private server?: any; + + constructor(config: SMSChannelConfig) { + super(); + this.config = { + port: 3000, + ...config, + }; + this.name = config.name; + } + + /** + * Two-way when webhookPath is set, outbound-only otherwise. + */ + get isTriggerChannel(): boolean { + return !this.config.webhookPath; + } + + /** + * Start listening for inbound SMS via Twilio webhook (if webhookPath is set). + */ + listen(): void { + if (typeof process !== 'undefined') { + import('twilio').then((twilio) => { + this.twilioClient = twilio.default(this.config.accountSid, this.config.authToken); + console.log(`[SMSChannel] Twilio client initialized`); + + if (this.config.webhookPath) { + this.startWebhookServer(); + } + }).catch((err) => { + console.error('[SMSChannel] Failed to initialize Twilio client:', err); + console.error('[SMSChannel] Make sure to install twilio: npm install twilio'); + }); + } + } + + /** + * Send an SMS message. + * @param output The agent output to send + */ + async send(output: AgentOutput): Promise { + if (!this.twilioClient) { + throw new Error('Twilio client not initialized. Did you call listen()?'); + } + + const recipient = (output.metadata?.from as string) || this.config.to; + + if (!recipient) { + throw new Error('No recipient phone number specified. Set "to" in config or provide in output.metadata.from'); + } + + try { + const message = await this.twilioClient.messages.create({ + body: output.output, + from: this.config.from, + to: recipient, + }); + + console.log(`[SMSChannel] SMS sent: ${message.sid}`); + } catch (error) { + console.error('[SMSChannel] Failed to send SMS:', error); + throw new Error(`Failed to send SMS: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Normalize a Twilio webhook payload into AgentInput. + * @param incoming Twilio webhook payload + * @returns Normalized AgentInput + */ + normalize(incoming: unknown): AgentInput { + const payload = incoming as Record; + + const from = payload.From as string; + const body = payload.Body as string; + const messageSid = payload.MessageSid as string; + + return { + message: body, + conversationId: from, + data: payload, + context: { + from, + to: payload.To as string, + messageSid, + }, + }; + } + + /** + * Start HTTP server to receive Twilio webhooks. + */ + private startWebhookServer(): void { + import('http').then((http) => { + this.server = http.createServer((req, res) => { + this.handleWebhookRequest(req, res); + }); + + this.server.listen(this.config.port, () => { + console.log(`[SMSChannel] Webhook server listening on port ${this.config.port} at ${this.config.webhookPath}`); + }); + }).catch((err) => { + console.error('[SMSChannel] Failed to start webhook server:', err); + }); + } + + /** + * Handle incoming webhook requests from Twilio. + */ + private handleWebhookRequest(req: any, res: any): void { + if (req.method !== 'POST' || req.url !== this.config.webhookPath) { + res.writeHead(404); + res.end('Not found'); + return; + } + + let body = ''; + req.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); + + req.on('end', () => { + try { + const params = new URLSearchParams(body); + const payload: Record = {}; + + params.forEach((value, key) => { + payload[key] = value; + }); + + const input = this.normalize(payload); + this.handleMessage(input); + + res.writeHead(200, { 'Content-Type': 'text/xml' }); + res.end(''); + } catch (error) { + console.error('[SMSChannel] Error handling webhook:', error); + res.writeHead(400); + res.end('Bad request'); + } + }); + } + + /** + * Stop the webhook server. + */ + async stop(): Promise { + if (this.server) { + return new Promise((resolve) => { + this.server.close(resolve); + }); + } + } +} diff --git a/packages/toolpack-agents/src/channels/telegram-channel.test.ts b/packages/toolpack-agents/src/channels/telegram-channel.test.ts new file mode 100644 index 0000000..faa6b9a --- /dev/null +++ b/packages/toolpack-agents/src/channels/telegram-channel.test.ts @@ -0,0 +1,466 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TelegramChannel, TelegramChannelConfig } from './telegram-channel.js'; +import { AgentInput, AgentOutput } from '../agent/types.js'; + +describe('TelegramChannel', () => { + const baseConfig: TelegramChannelConfig = { + token: '123456789:ABCdefGHIjklMNOpqrsTUVwxyz', + }; + + describe('constructor', () => { + it('should create with required config', () => { + const channel = new TelegramChannel(baseConfig); + expect(channel).toBeDefined(); + }); + + it('should set name from config', () => { + const channel = new TelegramChannel({ ...baseConfig, name: 'telegram-bot' }); + expect(channel.name).toBe('telegram-bot'); + }); + + it('should have isTriggerChannel set to false', () => { + const channel = new TelegramChannel(baseConfig); + expect(channel.isTriggerChannel).toBe(false); + }); + }); + + describe('normalize', () => { + it('should map Telegram message to AgentInput', () => { + const channel = new TelegramChannel(baseConfig); + + const update = { + message: { + text: 'Hello bot', + chat: { + id: 123456789, + type: 'private', + }, + from: { + id: 987654321, + username: 'testuser', + first_name: 'Test', + last_name: 'User', + }, + message_id: 42, + }, + }; + + const input = channel.normalize(update); + + expect(input.message).toBe('Hello bot'); + expect(input.conversationId).toBe('123456789'); + expect(input.context?.chatId).toBe(123456789); + expect(input.context?.userId).toBe(987654321); + expect(input.context?.username).toBe('testuser'); + }); + + it('should handle edited_message', () => { + const channel = new TelegramChannel(baseConfig); + + const update = { + edited_message: { + text: 'Edited message', + chat: { id: 123456789 }, + from: { id: 987654321 }, + message_id: 43, + }, + }; + + const input = channel.normalize(update); + + expect(input.message).toBe('Edited message'); + }); + + it('should handle empty text', () => { + const channel = new TelegramChannel(baseConfig); + + const update = { + message: { + chat: { id: 123456789 }, + from: { id: 987654321 }, + message_id: 44, + }, + }; + + const input = channel.normalize(update); + + expect(input.message).toBe(''); + }); + + it('should include raw update in data', () => { + const channel = new TelegramChannel(baseConfig); + + const update = { + message: { + text: 'Test', + chat: { id: 123456789 }, + from: { id: 987654321 }, + message_id: 45, + }, + update_id: 123456789, + }; + + const input = channel.normalize(update); + + expect(input.data).toEqual(update); + }); + }); + + describe('normalize - participant', () => { + it('populates participant with stringified id and first_name as displayName', () => { + const channel = new TelegramChannel(baseConfig); + const input = channel.normalize({ + message: { + text: 'hi', + chat: { id: 123456789 }, + from: { id: 987654321, first_name: 'Alice', username: 'alice_tg' }, + message_id: 1, + }, + }); + expect(input.participant).toEqual({ + kind: 'user', + id: '987654321', // number coerced to string + displayName: 'Alice', // first_name takes precedence + }); + }); + + it('falls back to username when first_name is absent', () => { + const channel = new TelegramChannel(baseConfig); + const input = channel.normalize({ + message: { + text: 'hi', + chat: { id: 111 }, + from: { id: 222, username: 'bob_bot' }, + message_id: 2, + }, + }); + expect(input.participant?.displayName).toBe('bob_bot'); + }); + + it('falls back to stringified id when no name fields present', () => { + const channel = new TelegramChannel(baseConfig); + const input = channel.normalize({ + message: { + text: 'hi', + chat: { id: 111 }, + from: { id: 333 }, + message_id: 3, + }, + }); + expect(input.participant?.displayName).toBe('333'); + }); + + it('leaves participant undefined when from is absent', () => { + const channel = new TelegramChannel(baseConfig); + const input = channel.normalize({ + message: { + text: 'system message', + chat: { id: 111 }, + message_id: 4, + }, + }); + expect(input.participant).toBeUndefined(); + }); + }); + + describe('send', () => { + it('should call Telegram API with chatId from metadata', async () => { + const channel = new TelegramChannel(baseConfig); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true }), + } as Response); + + await channel.send({ + output: 'Hello from agent!', + metadata: { + chatId: 123456789, + }, + }); + + expect(fetch).toHaveBeenCalledWith( + 'https://api.telegram.org/bot123456789:ABCdefGHIjklMNOpqrsTUVwxyz/sendMessage', + expect.objectContaining({ + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: expect.stringContaining('Hello from agent!'), + }) + ); + + const body = JSON.parse((fetch as ReturnType).mock.calls[0][1].body); + expect(body.chat_id).toBe(123456789); + expect(body.text).toBe('Hello from agent!'); + expect(body.parse_mode).toBe('Markdown'); + }); + + it('should throw if chatId not provided', async () => { + const channel = new TelegramChannel(baseConfig); + + await expect(channel.send({ + output: 'Test', + metadata: {}, + })).rejects.toThrow('Telegram send requires chatId in metadata'); + }); + + it('should throw on API error', async () => { + const channel = new TelegramChannel(baseConfig); + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: false, description: 'Bad Request: chat not found' }), + } as Response); + + await expect(channel.send({ + output: 'Test', + metadata: { chatId: 123456789 }, + })).rejects.toThrow('Telegram API error: Bad Request: chat not found'); + }); + + it('should throw on HTTP error', async () => { + const channel = new TelegramChannel(baseConfig); + + global.fetch = vi.fn().mockResolvedValue({ + ok: false, + statusText: 'Unauthorized', + } as Response); + + await expect(channel.send({ + output: 'Test', + metadata: { chatId: 123456789 }, + })).rejects.toThrow('Failed to send Telegram message: Unauthorized'); + }); + }); + + describe('listen', () => { + it('should be callable for polling mode', () => { + const channel = new TelegramChannel(baseConfig); + + // Just verify channel is properly configured + // Actual polling/webhook startup is tested in integration + expect(channel).toBeDefined(); + }); + + it('should be callable for webhook mode', () => { + const channel = new TelegramChannel({ + ...baseConfig, + webhookUrl: 'https://example.com/webhook', + }); + + // Just verify channel is properly configured + expect(channel).toBeDefined(); + }); + }); + + describe('stop', () => { + it('should handle stop gracefully', async () => { + const channel = new TelegramChannel(baseConfig); + + // Should not throw even if not started + await expect(channel.stop()).resolves.not.toThrow(); + }); + + it('should close webhook server if present', async () => { + const channel = new TelegramChannel({ + ...baseConfig, + webhookUrl: 'https://example.com/webhook', + }); + + // Mock server without actually starting it + const mockClose = vi.fn((cb) => cb()); + (channel as unknown as { server: { close: typeof mockClose } }).server = { close: mockClose }; + + await channel.stop(); + + expect(mockClose).toHaveBeenCalled(); + }); + + it('should close server when configured with webhook', async () => { + const channel = new TelegramChannel({ + ...baseConfig, + webhookUrl: 'https://example.com/webhook', + }); + + // Mock server to avoid errors - set it before calling stop + const mockClose = vi.fn((cb) => cb && cb()); + (channel as unknown as { server: { close: typeof mockClose } }).server = { close: mockClose }; + + // Mock fetch for deleteWebhook + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ ok: true }), + } as unknown as Response); + + // Stop should complete without hanging + await channel.stop(); + + // Verify server was closed + expect(mockClose).toHaveBeenCalled(); + }); + }); + + describe('polling', () => { + it('should start polling when listen is called', () => { + const channel = new TelegramChannel(baseConfig); + + // Just verify that listen() doesn't throw when starting polling mode + expect(() => channel.listen()).not.toThrow(); + + // Cleanup + channel.stop().catch(() => {}); // Ignore any errors during cleanup + }); + + it('advances offset correctly — no double-increment that would skip updates', async () => { + const channel = new TelegramChannel(baseConfig); + + global.fetch = vi.fn() + // First poll: returns update_id 1 and 3 + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + ok: true, + result: [ + { update_id: 1, message: { text: 'a', chat: { id: 100 }, from: { id: 1 }, message_id: 1 } }, + { update_id: 3, message: { text: 'b', chat: { id: 100 }, from: { id: 1 }, message_id: 2 } }, + ], + }), + } as Response) + // Second poll — offset must be 4 (last update_id + 1), not 5 (which would skip update_id=4) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ ok: true, result: [] }), + } as Response); + + await (channel as any).pollUpdates(); + await (channel as any).pollUpdates(); + + const secondCallUrl = (fetch as ReturnType).mock.calls[1][0] as string; + expect(secondCallUrl).toContain('offset=4'); + }); + }); + + describe('normalize — channel context', () => { + it('sets channelType to "private" for DM chats so defaultGetScope returns dm scope', () => { + const channel = new TelegramChannel(baseConfig); + const input = channel.normalize({ + message: { + text: 'private message', + chat: { id: 555, type: 'private' }, + from: { id: 100 }, + message_id: 1, + }, + }); + expect(input.context?.channelType).toBe('private'); + }); + + it('sets channelType to "group" for group chats', () => { + const channel = new TelegramChannel(baseConfig); + const input = channel.normalize({ + message: { + text: 'group message', + chat: { id: 777, type: 'group', title: 'Project Kore' }, + from: { id: 100 }, + message_id: 2, + }, + }); + expect(input.context?.channelType).toBe('group'); + }); + + it('sets channelName from chat.title for group chats', () => { + const channel = new TelegramChannel(baseConfig); + const input = channel.normalize({ + message: { + text: 'hi team', + chat: { id: 777, type: 'group', title: 'Project Kore' }, + from: { id: 100 }, + message_id: 3, + }, + }); + expect(input.context?.channelName).toBe('Project Kore'); + }); + + it('sets channelName to undefined for private chats (DMs have no title)', () => { + const channel = new TelegramChannel(baseConfig); + const input = channel.normalize({ + message: { + text: 'hey', + chat: { id: 555, type: 'private' }, + from: { id: 100, first_name: 'Alice' }, + message_id: 4, + }, + }); + expect(input.context?.channelName).toBeUndefined(); + }); + + it('produces empty string conversationId when chat.id is null', () => { + const channel = new TelegramChannel(baseConfig); + const input = channel.normalize({ + message: { + text: 'msg', + chat: { id: null, type: 'group' }, + from: { id: 100 }, + message_id: 1, + }, + }); + expect(input.conversationId).toBe(''); + }); + + it('sets channelId to the stringified chat id', () => { + const channel = new TelegramChannel(baseConfig); + const input = channel.normalize({ + message: { + text: 'msg', + chat: { id: 12345, type: 'group' }, + from: { id: 100 }, + message_id: 5, + }, + }); + expect(input.context?.channelId).toBe('12345'); + }); + }); + + describe('normalize — mentions', () => { + it('extracts text_mention entity user ids into context.mentions', () => { + const channel = new TelegramChannel(baseConfig); + const input = channel.normalize({ + message: { + text: 'Hey can you help?', + chat: { id: 100 }, + from: { id: 200 }, + entities: [ + { type: 'text_mention', user: { id: 42 } }, + { type: 'text_mention', user: { id: 99 } }, + ], + }, + }); + expect(input.context?.mentions).toEqual(['42', '99']); + }); + + it('sets context.mentions to undefined when no text_mention entities exist', () => { + const channel = new TelegramChannel(baseConfig); + const input = channel.normalize({ + message: { + text: 'plain message', + chat: { id: 100 }, + from: { id: 200 }, + entities: [{ type: 'mention', offset: 0, length: 5 }], // @username — no user id + }, + }); + expect(input.context?.mentions).toBeUndefined(); + }); + + it('sets context.mentions to undefined when entities array is absent', () => { + const channel = new TelegramChannel(baseConfig); + const input = channel.normalize({ + message: { + text: 'no entities here', + chat: { id: 100 }, + from: { id: 200 }, + }, + }); + expect(input.context?.mentions).toBeUndefined(); + }); + }); +}); diff --git a/packages/toolpack-agents/src/channels/telegram-channel.ts b/packages/toolpack-agents/src/channels/telegram-channel.ts new file mode 100644 index 0000000..5d73564 --- /dev/null +++ b/packages/toolpack-agents/src/channels/telegram-channel.ts @@ -0,0 +1,384 @@ +import { BaseChannel } from './base-channel.js'; +import { AgentInput, AgentOutput, Participant } from '../agent/types.js'; + +/** + * Configuration options for TelegramChannel. + */ +export interface TelegramChannelConfig { + /** Optional name for the channel - required for sendTo() routing */ + name?: string; + + /** Telegram bot token (from @BotFather) */ + token: string; + + /** Optional webhook URL for receiving updates (if not using polling) */ + webhookUrl?: string; +} + +/** + * Telegram channel for two-way Telegram bot integration. + * Receives messages from users and sends replies. + */ +export class TelegramChannel extends BaseChannel { + readonly isTriggerChannel = false; + private config: TelegramChannelConfig; + private offset: number = 0; + private pollingInterval?: NodeJS.Timeout; + private server?: any; // HTTP server for webhook mode + + /** + * The bot's Telegram user id (numeric, as a string), populated by the + * startup self-check (`getMe`) when `listen()` is called. + * + * Pass this to `AssemblerOptions.agentAliases` so the assembler's + * addressed-only mode can match `text_mention` entities whose user id + * equals this value. + */ + botUserId?: string; + + /** The bot's @username (without the @), populated by the `getMe` check. */ + botUsername?: string; + + constructor(config: TelegramChannelConfig) { + super(); + this.name = config.name; + this.config = config; + } + + /** + * Start listening for Telegram updates. + * Uses either webhook or polling mode depending on configuration. + */ + listen(): void { + // Run async — failure is logged but does not prevent the channel from listening. + this.runStartupCheck().catch(() => {}); + if (this.config.webhookUrl) { + this.startWebhook(); + } else { + this.startPolling(); + } + } + + /** + * Calls Telegram's `getMe` API to verify the token and log the bot's + * identity. Stores `botUserId` and `botUsername` for use in + * `AssemblerOptions.agentAliases`. Non-fatal — a failed check logs a + * warning but does not stop the channel. + */ + private async runStartupCheck(): Promise { + try { + const response = await fetch( + `https://api.telegram.org/bot${this.config.token}/getMe` + ); + + const data = await response.json() as { + ok: boolean; + result?: { + id?: number; + username?: string; + first_name?: string; + }; + description?: string; + }; + + if (data.ok && data.result) { + const bot = data.result; + this.botUserId = bot.id != null ? String(bot.id) : undefined; + this.botUsername = bot.username; + console.log( + `[TelegramChannel] Connected as @${bot.username} (id: ${bot.id}, name: ${bot.first_name})` + ); + } else { + console.warn(`[TelegramChannel] getMe failed: ${data.description ?? 'unknown error'}. Check your bot token.`); + } + } catch (err) { + console.warn('[TelegramChannel] Startup self-check failed (network error):', err); + } + } + + /** + * Send a message back to Telegram. + * @param output The agent output to send + */ + async send(output: AgentOutput): Promise { + // Get chat ID from metadata (set during normalize) + const chatId = output.metadata?.chatId as string | number | undefined; + + if (!chatId) { + throw new Error('Telegram send requires chatId in metadata'); + } + + const response = await fetch(`https://api.telegram.org/bot${this.config.token}/sendMessage`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + chat_id: chatId, + text: output.output, + parse_mode: 'Markdown', + }), + }); + + if (!response.ok) { + throw new Error(`Failed to send Telegram message: ${response.statusText}`); + } + + const data = await response.json() as { ok: boolean; description?: string }; + if (!data.ok) { + throw new Error(`Telegram API error: ${data.description}`); + } + } + + /** + * Normalize a Telegram update into AgentInput. + * @param incoming Telegram update object + * @returns Normalized AgentInput + */ + normalize(incoming: unknown): AgentInput { + const update = incoming as Record; + + // Get message from update (handles both message and edited_message) + const message = (update.message as Record) || + (update.edited_message as Record) || + {}; + + const text = (message.text as string) || ''; + const chat = (message.chat as Record) || {}; + const from = (message.from as Record) || {}; + + // Telegram's user IDs are numbers, convert to string for Participant.id + const userId = from.id != null ? String(from.id) : undefined; + const displayName = + (from.first_name as string | undefined) || + (from.username as string | undefined) || + userId; + + const participant: Participant | undefined = userId + ? { kind: 'user', id: userId, displayName: displayName ?? userId } + : undefined; + + // Extract @-mentions from Telegram message entities. + // `text_mention` entities carry a `user` object with a numeric id — these + // are the only mention type where we can resolve the user id without an + // additional API call. Regular `mention` entities (by @username) are logged + // but not yet resolved to user ids in v1. + const entities = (message.entities as Array> | undefined) ?? []; + const mentions: string[] = []; + for (const entity of entities) { + if (entity.type === 'text_mention' && entity.user) { + const mentionUser = entity.user as Record; + if (mentionUser.id != null) { + mentions.push(String(mentionUser.id)); + } + } + } + + // chat.type: 'private' (DM), 'group', 'supergroup', 'channel' + const chatType = chat.type as string | undefined; + const chatIdStr = chat.id != null ? String(chat.id) : ''; + + return { + message: text, + conversationId: chatIdStr, + data: update, + participant, + context: { + chatId: chat.id, + userId: from.id, + username: from.username, + firstName: from.first_name, + lastName: from.last_name, + messageId: message.message_id, + // 'private' maps to scope: 'dm'; 'group'/'supergroup' map to scope: 'channel'. + // Read by defaultGetScope in capture-history. + channelType: chatType, + // Platform channel id — same as conversationId for Telegram (chat.id). + channelId: chatIdStr, + // Human-readable group/channel name. Absent for private (DM) chats. + channelName: chat.title as string | undefined, + // Mention user ids extracted from text_mention entities. Read by the + // capture interceptor's default getMentions() and written to + // StoredMessage.metadata.mentions for addressed-only mode. + mentions: mentions.length > 0 ? mentions : undefined, + }, + }; + } + + /** + * Start polling for updates. + */ + private startPolling(): void { + console.log('[TelegramChannel] Starting polling mode'); + + // Poll every 5 seconds + this.pollingInterval = setInterval(async () => { + try { + await this.pollUpdates(); + } catch (error) { + console.error('[TelegramChannel] Polling error:', error); + } + }, 5000); + } + + /** + * Poll for updates from Telegram. + */ + private async pollUpdates(): Promise { + const url = `https://api.telegram.org/bot${this.config.token}/getUpdates?offset=${this.offset}&limit=100`; + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Telegram getUpdates failed: ${response.statusText}`); + } + + const data = await response.json() as { + ok: boolean; + result: Array>; + }; + + if (!data.ok) { + throw new Error('Telegram getUpdates returned not ok'); + } + + for (const update of data.result) { + // Update offset + const updateId = update.update_id as number; + if (updateId >= this.offset) { + this.offset = updateId + 1; + } + + // Process the update + try { + const input = this.normalize(update); + await this.handleMessage(input); + } catch (error) { + console.error('[TelegramChannel] Error processing update:', error); + } + } + } + + /** + * Start webhook server for receiving updates. + */ + private startWebhook(): void { + if (typeof process === 'undefined') return; + + console.log('[TelegramChannel] Starting webhook mode'); + + import('http').then((http) => { + this.server = http.createServer((req, res) => { + this.handleWebhookRequest(req, res); + }); + + // Extract port from webhook URL or use default + const url = new URL(this.config.webhookUrl || 'http://localhost:3000'); + const port = parseInt(url.port, 10) || 3000; + + this.server.listen(port, () => { + console.log(`[TelegramChannel] Webhook server listening on port ${port}`); + }); + + // Set webhook with Telegram + this.setWebhook(); + }).catch((err) => { + console.error('[TelegramChannel] Failed to start webhook server:', err); + }); + } + + /** + * Set webhook URL with Telegram. + */ + private async setWebhook(): Promise { + if (!this.config.webhookUrl) return; + + const response = await fetch( + `https://api.telegram.org/bot${this.config.token}/setWebhook`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + url: this.config.webhookUrl, + }), + } + ); + + if (!response.ok) { + console.error('[TelegramChannel] Failed to set webhook'); + return; + } + + const data = await response.json() as { ok: boolean; description?: string }; + if (data.ok) { + console.log('[TelegramChannel] Webhook set successfully'); + } else { + console.error('[TelegramChannel] Failed to set webhook:', data.description); + } + } + + /** + * Handle incoming webhook requests from Telegram. + */ + private handleWebhookRequest(req: any, res: any): void { + if (req.method !== 'POST') { + res.writeHead(405); + res.end('Method not allowed'); + return; + } + + let body = ''; + req.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); + + req.on('end', () => { + try { + const update = JSON.parse(body); + + // Process the update asynchronously + this.handleMessage(this.normalize(update)).catch((error) => { + console.error('[TelegramChannel] Error processing webhook:', error); + }); + + res.writeHead(200); + res.end('OK'); + } catch (error) { + console.error('[TelegramChannel] Error parsing webhook:', error); + res.writeHead(400); + res.end('Bad request'); + } + }); + } + + /** + * Stop the channel (polling or webhook). + */ + async stop(): Promise { + // Stop polling + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = undefined; + } + + // Stop webhook server + if (this.server) { + return new Promise((resolve) => { + this.server.close(resolve); + }); + } + + // Delete webhook if set + if (this.config.webhookUrl) { + try { + await fetch( + `https://api.telegram.org/bot${this.config.token}/deleteWebhook`, + { method: 'POST' } + ); + } catch (error) { + console.error('[TelegramChannel] Failed to delete webhook:', error); + } + } + } +} diff --git a/packages/toolpack-agents/src/channels/webhook-channel.test.ts b/packages/toolpack-agents/src/channels/webhook-channel.test.ts new file mode 100644 index 0000000..ec9f9f4 --- /dev/null +++ b/packages/toolpack-agents/src/channels/webhook-channel.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { WebhookChannel, WebhookChannelConfig } from './webhook-channel.js'; +import { AgentInput, AgentOutput } from '../agent/types.js'; + +describe('WebhookChannel', () => { + const baseConfig: WebhookChannelConfig = { + path: '/agent/support', + port: 3102, // Unique port for Webhook tests + }; + + describe('constructor', () => { + it('should create with required config', () => { + const channel = new WebhookChannel({ path: '/webhook' }); + expect(channel).toBeDefined(); + }); + + it('should set name from config', () => { + const channel = new WebhookChannel({ ...baseConfig, name: 'webhook-support' }); + expect(channel.name).toBe('webhook-support'); + }); + + it('should use default port if not specified', () => { + const channel = new WebhookChannel({ path: '/webhook' }); + expect(channel).toBeDefined(); + }); + + it('should have isTriggerChannel set to false', () => { + const channel = new WebhookChannel(baseConfig); + expect(channel.isTriggerChannel).toBe(false); + }); + }); + + describe('normalize', () => { + it('should map HTTP body to AgentInput', () => { + const channel = new WebhookChannel(baseConfig); + + const body = { + message: 'Help needed', + intent: 'support', + userId: 'user-123', + }; + + const input = channel.normalize(body); + + expect(input.message).toBe('Help needed'); + expect(input.intent).toBe('support'); + expect(input.data).toEqual(body); + }); + + it('should use text field as fallback for message', () => { + const channel = new WebhookChannel(baseConfig); + + const body = { + text: 'Text message', + }; + + const input = channel.normalize(body); + + expect(input.message).toBe('Text message'); + }); + + it('should extract sessionId from x-session-id header', () => { + const channel = new WebhookChannel(baseConfig); + + const body = { + message: 'Test', + headers: { + 'x-session-id': 'session-123', + }, + }; + + const input = channel.normalize(body); + + expect(input.conversationId).toBe('session-123'); + expect(input.context?.sessionId).toBe('session-123'); + }); + + it('should extract sessionId from X-Session-Id header (case insensitive)', () => { + const channel = new WebhookChannel(baseConfig); + + const body = { + message: 'Test', + headers: { + 'X-Session-Id': 'session-456', + }, + }; + + const input = channel.normalize(body); + + expect(input.conversationId).toBe('session-456'); + }); + + it('should fall back to body sessionId if no header', () => { + const channel = new WebhookChannel(baseConfig); + + const body = { + message: 'Test', + sessionId: 'session-789', + }; + + const input = channel.normalize(body); + + expect(input.conversationId).toBe('session-789'); + }); + + it('should fall back to body conversationId if no sessionId', () => { + const channel = new WebhookChannel(baseConfig); + + const body = { + message: 'Test', + conversationId: 'conv-abc', + }; + + const input = channel.normalize(body); + + expect(input.conversationId).toBe('conv-abc'); + }); + + it('should auto-generate sessionId if not provided', () => { + const channel = new WebhookChannel(baseConfig); + + const body = { + message: 'Test', + }; + + const input = channel.normalize(body); + + expect(input.conversationId).toMatch(/^webhook-/); + }); + }); + + describe('send', () => { + it('should resolve pending response by conversationId', async () => { + const channel = new WebhookChannel(baseConfig); + + // Simulate a pending response + const mockResolve = vi.fn(); + const mockReject = vi.fn(); + (channel as unknown as { pendingResponses: Map }).pendingResponses.set('session-123', { + resolve: mockResolve, + reject: mockReject, + }); + + await channel.send({ + output: 'Response to user', + metadata: { + conversationId: 'session-123', + }, + }); + + expect(mockResolve).toHaveBeenCalledWith({ + output: 'Response to user', + metadata: { + conversationId: 'session-123', + }, + }); + }); + + it('should not throw if no pending response found', async () => { + const channel = new WebhookChannel(baseConfig); + + // Should not throw + await expect(channel.send({ + output: 'Orphaned response', + metadata: { + conversationId: 'unknown-session', + }, + })).resolves.not.toThrow(); + }); + + it('should handle missing metadata', async () => { + const channel = new WebhookChannel(baseConfig); + + await expect(channel.send({ + output: 'No metadata', + })).resolves.not.toThrow(); + }); + }); + + describe('request handling flow', () => { + it('should store sessionId in pending responses during handleRequest', () => { + const channel = new WebhookChannel(baseConfig); + + // Verify the pendingResponses map exists and works + const testInput: AgentInput = { + message: 'Test', + conversationId: 'test-session', + }; + + // Set up handler + const handler = vi.fn().mockResolvedValue(undefined); + channel.onMessage(handler); + + // The actual request flow is tested in integration + // This verifies the channel structure supports the flow + expect(channel).toBeDefined(); + }); + }); + + describe('listen', () => { + it('should create HTTP server', () => { + const channel = new WebhookChannel({ ...baseConfig, name: 'webhook-support' }); + + // Just verify the channel was created successfully + // Actual server startup is tested in integration tests + expect(channel).toBeDefined(); + expect(channel.name).toBe('webhook-support'); + }); + }); + + describe('stop', () => { + it('should close server if running', async () => { + const channel = new WebhookChannel(baseConfig); + + // Mock server + const mockClose = vi.fn((cb) => cb()); + (channel as unknown as { server: { close: typeof mockClose } }).server = { close: mockClose }; + + await channel.stop(); + + expect(mockClose).toHaveBeenCalled(); + }); + + it('should handle missing server gracefully', async () => { + const channel = new WebhookChannel(baseConfig); + + // Should not throw when server is undefined + await expect(channel.stop()).resolves.not.toThrow(); + }); + }); +}); diff --git a/packages/toolpack-agents/src/channels/webhook-channel.ts b/packages/toolpack-agents/src/channels/webhook-channel.ts new file mode 100644 index 0000000..78318b0 --- /dev/null +++ b/packages/toolpack-agents/src/channels/webhook-channel.ts @@ -0,0 +1,200 @@ +import { BaseChannel } from './base-channel.js'; +import { AgentInput, AgentOutput } from '../agent/types.js'; + +/** + * Configuration options for WebhookChannel. + */ +export interface WebhookChannelConfig { + /** Optional name for the channel - required for sendTo() routing */ + name?: string; + + /** HTTP path to listen on (e.g., '/agent/support') */ + path: string; + + /** Optional port for the HTTP server (default: 3000) */ + port?: number; +} + +/** + * Pending response for webhook requests. + */ +interface PendingResponse { + resolve: (value: unknown) => void; + reject: (reason: Error) => void; +} + +/** + * Webhook channel that exposes an HTTP endpoint. + * Receives HTTP requests and responds with agent output. + */ +export class WebhookChannel extends BaseChannel { + readonly isTriggerChannel = false; + private config: WebhookChannelConfig; + private server?: any; // HTTP server instance + private pendingResponses: Map = new Map(); + + constructor(config: WebhookChannelConfig) { + super(); + this.name = config.name; + this.config = { + port: config.port ?? 3000, + path: config.path ?? '/webhook', + }; + } + + /** + * Start listening for HTTP requests. + */ + listen(): void { + if (typeof process !== 'undefined') { + import('http').then((http) => { + this.server = http.createServer((req, res) => { + this.handleRequest(req, res); + }); + + this.server.listen(this.config.port, () => { + console.log(`[WebhookChannel] Listening on port ${this.config.port}${this.config.path}`); + }); + }).catch((err) => { + console.error('[WebhookChannel] Failed to start HTTP server:', err); + }); + } + } + + /** + * Send the agent output as an HTTP response. + * @param output The agent output to send + */ + async send(output: AgentOutput): Promise { + // In webhook mode, we need to find the pending response for this request + // The conversationId from the input is used as the lookup key + const conversationId = output.metadata?.conversationId as string | undefined; + + if (conversationId && this.pendingResponses.has(conversationId)) { + const pending = this.pendingResponses.get(conversationId)!; + this.pendingResponses.delete(conversationId); + + pending.resolve({ + output: output.output, + metadata: output.metadata, + }); + } + } + + /** + * Normalize an HTTP request into AgentInput. + * @param incoming HTTP request body with headers + * @returns Normalized AgentInput + */ + normalize(incoming: unknown): AgentInput { + const body = incoming as Record; + + // Extract session ID from x-session-id header, body, or auto-generate + const headers = (body.headers as Record) || {}; + const sessionId = headers['x-session-id'] || + headers['X-Session-Id'] || + (body.sessionId as string) || + (body.conversationId as string) || + this.generateSessionId(); + + return { + message: (body.message as string) || (body.text as string) || '', + intent: body.intent as string | undefined, + conversationId: sessionId, + data: body, + context: { + headers: body.headers, + method: body.method, + sessionId, // Store for reference + }, + }; + } + + /** + * Handle incoming HTTP requests. + */ + private handleRequest(req: any, res: any): void { + // Check path match + if (req.url !== this.config.path) { + res.writeHead(404); + res.end('Not found'); + return; + } + + if (req.method !== 'POST') { + res.writeHead(405); + res.end('Method not allowed'); + return; + } + + let body = ''; + req.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); + + req.on('end', () => { + try { + const payload = JSON.parse(body); + const input = this.normalize(payload); + + // Store the response resolver for later + const sessionId = input.conversationId || this.generateSessionId(); + + const responsePromise = new Promise((resolve, reject) => { + this.pendingResponses.set(sessionId, { resolve, reject }); + + // Set a timeout to reject if no response comes + setTimeout(() => { + if (this.pendingResponses.has(sessionId)) { + this.pendingResponses.delete(sessionId); + reject(new Error('Agent response timeout')); + } + }, 30000); // 30 second timeout + }); + + // Ensure conversationId is in metadata for send() to find the response + this.handleMessage({ + ...input, + conversationId: sessionId, + context: { + ...input.context, + sessionId, + }, + }); + + // Wait for the agent response + responsePromise + .then((result) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + }) + .catch((error) => { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: error.message })); + }); + } catch (error) { + console.error('[WebhookChannel] Error handling request:', error); + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Bad request' })); + } + }); + } + + /** + * Generate a unique session ID. + */ + private generateSessionId(): string { + return `webhook-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + } + + /** + * Stop the HTTP server. + */ + async stop(): Promise { + if (this.server) { + return new Promise((resolve) => { + this.server.close(resolve); + }); + } + } +} diff --git a/packages/toolpack-agents/src/history/assembler.ts b/packages/toolpack-agents/src/history/assembler.ts new file mode 100644 index 0000000..6f3e381 --- /dev/null +++ b/packages/toolpack-agents/src/history/assembler.ts @@ -0,0 +1,302 @@ +import { randomUUID } from 'crypto'; +import type { ConversationStore, StoredMessage, AssemblerOptions, AssembledPrompt, PromptMessage } from 'toolpack-sdk'; +import type { SummarizerAgent, SummarizerOutput, HistoryTurn } from '../capabilities/summarizer-agent.js'; + +/** + * Estimate the token count of a string (characters / 4). + * Fast and good enough for budget enforcement; not exact. + */ +function estimateTokens(text: string): number { + return Math.ceil(text.length / 4); +} + +/** + * Convert a `StoredMessage` to the summarizer's `HistoryTurn` format. + * The assembler calls this before passing history to `SummarizerAgent`. + */ +function toHistoryTurn(message: StoredMessage): HistoryTurn { + return { + id: message.id, + participant: message.participant, + content: message.content, + timestamp: message.timestamp, + }; +} + +/** + * Project a `StoredMessage` into a `PromptMessage` from the perspective of a + * specific agent (identified by `agentId`). + * + * Projection table (per plan doc): + * | Stored participant | role | content | + * |------------------------------------------|-----------|----------------------------------| + * | kind: 'system' | system | as-is | + * | kind: 'user' | user | "{displayName}: {content}" | + * | kind: 'agent', id === agentId | assistant | as-is | + * | kind: 'agent', id !== agentId | user | "{name} (agent): {content}" | + */ +function project(message: StoredMessage, agentId: string): PromptMessage { + const { participant, content } = message; + + if (participant.kind === 'system') { + return { role: 'system', content }; + } + + if (participant.kind === 'agent') { + if (participant.id === agentId) { + // Current agent's own turn → assistant. + return { role: 'assistant', content }; + } + // Peer agent → user with label. + const name = participant.displayName ?? participant.id; + return { role: 'user', content: `${name} (agent): ${content}` }; + } + + // kind === 'user' + const displayName = participant.displayName ?? participant.id; + return { role: 'user', content: `${displayName}: ${content}` }; +} + +/** + * Check whether an agent was "involved" in a message. + * Used for addressed-only mode filtering. + * + * An agent is considered involved if: + * - The message was sent by the agent itself (authorship matches `agentId`), OR + * - Any id in `agentAllIds` appears in the message's `metadata.mentions` list. + * + * Two distinct id sets are used because: + * - **Authorship** — the capture interceptor always writes the stable agent name + * (`agentId`) as `participant.id`, regardless of platform. + * - **Mentions** — platforms store mentions as their own user ids (e.g. Slack's + * `'U_BOT123'`), which may differ from the stable agent name. `agentAllIds` + * covers both the stable name and any platform aliases. + * + * @param message The stored message to test. + * @param agentId The agent's stable internal id (its registered name). + * @param agentAllIds Set of all ids considered "this agent" for mention matching + * (includes agentId itself plus any platform aliases). + */ +function agentIsInvolved( + message: StoredMessage, + agentId: string, + agentAllIds: ReadonlySet +): boolean { + // Authorship: the capture interceptor writes the stable agent name. + if (message.participant.id === agentId) return true; + // @-mention: check against the full alias set (name + platform ids). + if (message.metadata?.mentions?.some(m => agentAllIds.has(m))) return true; + return false; +} + +/** + * Assemble a prompt slice from conversation history for a specific agent. + * + * This is the function that actually controls token cost. It: + * 1. Loads a scoped, time-windowed slice of history from the store. + * 2. Optionally filters to turns where the agent was involved (addressed-only mode). + * 3. Triggers rolling summarisation via `SummarizerAgent` when the turn count + * exceeds `options.rollingSummaryThreshold`. + * 4. Projects each `StoredMessage` into the LLM's role-based format from the + * current agent's point of view. + * 5. Enforces a hard token budget, filling priority slots top-down. + * + * @param store The conversation store to read from. + * @param conversationId The conversation to load. + * @param agentId The current agent's stable id (its registered name). + * @param agentName The current agent's display name. + * @param options Tuning knobs — scope, budget, addressed-only mode, etc. + * @param summarizer Optional `SummarizerAgent` instance for rolling summaries. + * When omitted, old turns are simply dropped when the + * threshold is exceeded. + * + * @example + * ```ts + * const prompt = await assemblePrompt(store, conversationId, agent.name, agent.name, { + * scope: 'channel', + * tokenBudget: 3000, + * addressedOnlyMode: true, + * rollingSummaryThreshold: 40, + * }, summarizerAgent); + * + * const response = await llm.chat([ + * { role: 'system', content: agent.systemPrompt }, + * ...prompt.messages, + * { role: 'user', content: triggeringMessage }, + * ]); + * ``` + */ +export async function assemblePrompt( + store: ConversationStore, + conversationId: string, + agentId: string, + agentName: string, + options: AssemblerOptions = {}, + summarizer?: SummarizerAgent +): Promise { + const { + scope, + addressedOnlyMode = true, + tokenBudget = 3000, + rollingSummaryThreshold = 40, + timeWindowMinutes, + maxTurnsToLoad = 100, + agentAliases, + } = options; + + // Unified set of ids that mean "this agent" for involvement checking. + // Always includes the stable agentId; callers add platform-specific ids + // (e.g. Slack bot user id 'U_BOT123') via agentAliases so that @-mentions + // stored as platform ids still trigger the addressed-only filter correctly. + const agentAllIds: ReadonlySet = new Set([agentId, ...(agentAliases ?? [])]); + + // --- 1. Load a scoped, time-windowed slice from the store --- + + const sinceTimestamp = timeWindowMinutes !== undefined + ? new Date(Date.now() - timeWindowMinutes * 60 * 1000).toISOString() + : undefined; + + let messages = await store.get(conversationId, { + scope, + sinceTimestamp, + limit: maxTurnsToLoad, + }); + + const turnsLoaded = messages.length; + + // --- 2. Addressed-only mode: keep agent-involved turns + direct messages --- + + if (addressedOnlyMode) { + // Build a set of involved message ids covering three criteria: + // a) The agent authored the message. + // b) The agent's id appears in the message's @-mention list. + // c) "Replied next" — the message immediately after this one is an + // agent-authored turn (i.e., this message was what the agent replied to). + const involvedIds = new Set(); + + for (let i = 0; i < messages.length; i++) { + const m = messages[i]; + + if (agentIsInvolved(m, agentId, agentAllIds)) { + involvedIds.add(m.id); + } + + // Criterion (c): if the very next turn was authored by this agent, + // include the current turn as the message that triggered the reply. + // Authorship uses agentId (the stable name) — aliases are not needed here + // because the capture interceptor always writes the stable name. + if (i < messages.length - 1) { + const next = messages[i + 1]; + if (next.participant.kind === 'agent' && next.participant.id === agentId) { + involvedIds.add(m.id); + } + } + } + + // Always include the most recent message — it's the triggering message and + // must always be in context regardless of whether the agent was addressed. + const mostRecent = messages[messages.length - 1]; + if (mostRecent) { + involvedIds.add(mostRecent.id); + } + + messages = messages.filter(m => involvedIds.has(m.id)); + } + + // --- 3. Rolling summary: compress oldest turns when over threshold --- + + let hasSummary = false; + + if (messages.length > rollingSummaryThreshold && summarizer) { + // Split: summarise the oldest portion, keep the recent tail verbatim. + const splitPoint = Math.floor(messages.length / 2); + const toSummarise = messages.slice(0, splitPoint); + const recent = messages.slice(splitPoint); + + // Gap #8: exclude existing summary turns from the summariser input — + // they are already compressed and feeding them back would double-summarise. + // The summariser receives only the raw turns; the summary text itself is + // preserved in the store as part of the new summary's content. + const rawTurnsToSummarise = toSummarise.filter(m => !m.metadata?.isSummary); + + try { + const summarizerResult = await summarizer.invokeAgent({ + message: 'summarize', + data: { + turns: rawTurnsToSummarise.map(toHistoryTurn), + agentName, + agentId, + maxTokens: Math.floor(tokenBudget * 0.25), // summary gets ≤25% of budget + extractDecisions: true, + }, + }); + + const parsed = JSON.parse(summarizerResult.output) as SummarizerOutput; + + const summaryMessage: StoredMessage = { + id: `summary-${randomUUID()}`, + conversationId, + participant: { kind: 'system', id: 'summarizer' }, + content: `[Summary of ${parsed.turnsSummarized} earlier turns]: ${parsed.summary}`, + timestamp: toSummarise[0].timestamp, + scope: scope ?? 'channel', + metadata: { isSummary: true }, + }; + + messages = [summaryMessage, ...recent]; + hasSummary = true; + + // Gap #2: persist the summary to the store and delete the turns it covers. + // This prevents the same turns from being re-summarised on subsequent calls, + // eliminating redundant LLM cost and making the isSummary flag meaningful. + // Errors here must not crash the pipeline — the in-memory result is still valid. + try { + await store.append(summaryMessage); + await store.deleteMessages(conversationId, toSummarise.map(m => m.id)); + } catch { + // Persistence failure is non-fatal: the in-memory assembled prompt is + // still correct for this call; the turns will simply be re-summarised + // on the next invocation. + } + } catch { + // Summarisation failure must not crash the pipeline. + // Fall through with the full (unsummarised) recent slice. + messages = messages.slice(-rollingSummaryThreshold); + } + } else if (messages.length > rollingSummaryThreshold) { + // No summariser available — just keep the most recent turns. + messages = messages.slice(-rollingSummaryThreshold); + } + + // --- 4. Project each message into the LLM's role-based format --- + + const projected = messages.map(m => project(m, agentId)); + + // --- 5. Enforce token budget (fill top-down, drop oldest when over) --- + + if (projected.length === 0) { + return { messages: [], estimatedTokens: 0, turnsLoaded, hasSummary }; + } + + // The most recent message is the triggering message — always include it + // regardless of budget so the agent is never completely blind. + const lastMsg = projected[projected.length - 1]; + const budgetedMessages: PromptMessage[] = [lastMsg]; + let tokenCount = estimateTokens(lastMsg.content); + + // Walk older turns newest-first and fill whatever budget remains. + for (let i = projected.length - 2; i >= 0; i--) { + const msg = projected[i]; + const tokens = estimateTokens(msg.content); + if (tokenCount + tokens > tokenBudget) break; + budgetedMessages.unshift(msg); + tokenCount += tokens; + } + + return { + messages: budgetedMessages, + estimatedTokens: tokenCount, + turnsLoaded, + hasSummary, + }; +} diff --git a/packages/toolpack-agents/src/history/history.test.ts b/packages/toolpack-agents/src/history/history.test.ts new file mode 100644 index 0000000..390b6a1 --- /dev/null +++ b/packages/toolpack-agents/src/history/history.test.ts @@ -0,0 +1,743 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { InMemoryConversationStore } from './store.js'; +import { assemblePrompt } from './assembler.js'; +import { createConversationSearchTool } from './search-tool.js'; +import type { StoredMessage } from 'toolpack-sdk'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeMessage(overrides: Partial & { id: string }): StoredMessage { + return { + conversationId: 'conv-1', + participant: { kind: 'user', id: 'u1', displayName: 'Alice' }, + content: 'hello', + timestamp: new Date().toISOString(), + scope: 'channel', + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// InMemoryConversationStore +// --------------------------------------------------------------------------- + +describe('InMemoryConversationStore', () => { + describe('append / get', () => { + it('stores and retrieves a message', async () => { + const store = new InMemoryConversationStore(); + const msg = makeMessage({ id: 'm1' }); + + await store.append(msg); + const result = await store.get('conv-1'); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe('m1'); + }); + + it('is idempotent — duplicate id is silently ignored', async () => { + const store = new InMemoryConversationStore(); + const msg = makeMessage({ id: 'm1' }); + + await store.append(msg); + await store.append(msg); + + const result = await store.get('conv-1'); + expect(result).toHaveLength(1); + }); + + it('returns messages in ascending timestamp order', async () => { + const store = new InMemoryConversationStore(); + + await store.append(makeMessage({ id: 'm3', timestamp: '2024-01-01T00:00:03Z' })); + await store.append(makeMessage({ id: 'm1', timestamp: '2024-01-01T00:00:01Z' })); + await store.append(makeMessage({ id: 'm2', timestamp: '2024-01-01T00:00:02Z' })); + + const result = await store.get('conv-1'); + expect(result.map(m => m.id)).toEqual(['m1', 'm2', 'm3']); + }); + + it('returns empty array for unknown conversationId', async () => { + const store = new InMemoryConversationStore(); + const result = await store.get('no-such-conversation'); + expect(result).toEqual([]); + }); + + it('isolates messages by conversationId', async () => { + const store = new InMemoryConversationStore(); + + await store.append(makeMessage({ id: 'm1', conversationId: 'conv-1' })); + await store.append(makeMessage({ id: 'm2', conversationId: 'conv-2' })); + + expect(await store.get('conv-1')).toHaveLength(1); + expect(await store.get('conv-2')).toHaveLength(1); + }); + }); + + describe('get — filtering', () => { + it('filters by scope', async () => { + const store = new InMemoryConversationStore(); + + await store.append(makeMessage({ id: 'm1', scope: 'channel' })); + await store.append(makeMessage({ id: 'm2', scope: 'thread' })); + await store.append(makeMessage({ id: 'm3', scope: 'dm' })); + + const result = await store.get('conv-1', { scope: 'channel' }); + expect(result.map(m => m.id)).toEqual(['m1']); + }); + + it('filters by sinceTimestamp', async () => { + const store = new InMemoryConversationStore(); + + await store.append(makeMessage({ id: 'm1', timestamp: '2024-01-01T00:00:01Z' })); + await store.append(makeMessage({ id: 'm2', timestamp: '2024-01-01T00:00:03Z' })); + await store.append(makeMessage({ id: 'm3', timestamp: '2024-01-01T00:00:05Z' })); + + const result = await store.get('conv-1', { sinceTimestamp: '2024-01-01T00:00:03Z' }); + expect(result.map(m => m.id)).toEqual(['m2', 'm3']); + }); + + it('filters by participantIds', async () => { + const store = new InMemoryConversationStore(); + + await store.append(makeMessage({ id: 'm1', participant: { kind: 'user', id: 'u1' } })); + await store.append(makeMessage({ id: 'm2', participant: { kind: 'user', id: 'u2' } })); + await store.append(makeMessage({ id: 'm3', participant: { kind: 'agent', id: 'kael' } })); + + const result = await store.get('conv-1', { participantIds: ['u1', 'kael'] }); + expect(result.map(m => m.id)).toEqual(['m1', 'm3']); + }); + + it('respects limit (most recent N)', async () => { + const store = new InMemoryConversationStore(); + + for (let i = 1; i <= 5; i++) { + await store.append(makeMessage({ + id: `m${i}`, + timestamp: `2024-01-01T00:00:0${i}Z`, + })); + } + + const result = await store.get('conv-1', { limit: 3 }); + expect(result.map(m => m.id)).toEqual(['m3', 'm4', 'm5']); + }); + }); + + describe('search', () => { + it('finds messages containing the query (case-insensitive)', async () => { + const store = new InMemoryConversationStore(); + + await store.append(makeMessage({ id: 'm1', content: 'Hello world' })); + await store.append(makeMessage({ id: 'm2', content: 'Deploy to production' })); + await store.append(makeMessage({ id: 'm3', content: 'Say hello again' })); + + const result = await store.search('conv-1', 'hello'); + const ids = result.map(m => m.id); + expect(ids).toContain('m1'); + expect(ids).toContain('m3'); + expect(ids).not.toContain('m2'); + }); + + it('returns empty array when no messages match', async () => { + const store = new InMemoryConversationStore(); + await store.append(makeMessage({ id: 'm1', content: 'Hello world' })); + + const result = await store.search('conv-1', 'zxqwerty'); + expect(result).toHaveLength(0); + }); + + it('respects the limit option', async () => { + const store = new InMemoryConversationStore(); + + for (let i = 1; i <= 5; i++) { + await store.append(makeMessage({ id: `m${i}`, content: `match ${i}` })); + } + + const result = await store.search('conv-1', 'match', { limit: 3 }); + expect(result).toHaveLength(3); + }); + + it('never crosses conversationId boundaries', async () => { + const store = new InMemoryConversationStore(); + + await store.append(makeMessage({ id: 'm1', conversationId: 'conv-1', content: 'hello' })); + await store.append(makeMessage({ id: 'm2', conversationId: 'conv-2', content: 'hello' })); + + const result = await store.search('conv-1', 'hello'); + expect(result.every(m => m.conversationId === 'conv-1')).toBe(true); + }); + }); + + describe('memory bounds', () => { + it('evicts oldest conversation when maxConversations is exceeded', async () => { + const store = new InMemoryConversationStore({ maxConversations: 2 }); + + await store.append(makeMessage({ id: 'm1', conversationId: 'conv-1' })); + await store.append(makeMessage({ id: 'm2', conversationId: 'conv-2' })); + // conv-1 is now LRU + await store.append(makeMessage({ id: 'm3', conversationId: 'conv-3' })); + + // conv-1 should have been evicted + expect(store.conversationCount).toBe(2); + const conv1 = await store.get('conv-1'); + expect(conv1).toHaveLength(0); + }); + + it('drops oldest messages when maxMessagesPerConversation is exceeded', async () => { + const store = new InMemoryConversationStore({ maxMessagesPerConversation: 3 }); + + for (let i = 1; i <= 5; i++) { + await store.append(makeMessage({ + id: `m${i}`, + timestamp: `2024-01-01T00:00:0${i}Z`, + })); + } + + const result = await store.get('conv-1'); + // Only the 3 most recent should remain + expect(result).toHaveLength(3); + expect(result.map(m => m.id)).toEqual(['m3', 'm4', 'm5']); + }); + }); + + describe('clearConversation', () => { + it('removes all messages for a conversation', async () => { + const store = new InMemoryConversationStore(); + + await store.append(makeMessage({ id: 'm1' })); + store.clearConversation('conv-1'); + + const result = await store.get('conv-1'); + expect(result).toHaveLength(0); + }); + }); + + describe('deleteMessages', () => { + it('removes messages by id, leaving others intact', async () => { + const store = new InMemoryConversationStore(); + await store.append(makeMessage({ id: 'm1' })); + await store.append(makeMessage({ id: 'm2' })); + await store.append(makeMessage({ id: 'm3' })); + + await store.deleteMessages('conv-1', ['m1', 'm3']); + + const result = await store.get('conv-1'); + expect(result.map(m => m.id)).toEqual(['m2']); + }); + + it('is a no-op for ids that do not exist', async () => { + const store = new InMemoryConversationStore(); + await store.append(makeMessage({ id: 'm1' })); + + await store.deleteMessages('conv-1', ['no-such-id']); + + const result = await store.get('conv-1'); + expect(result).toHaveLength(1); + }); + + it('is a no-op for an unknown conversationId', async () => { + const store = new InMemoryConversationStore(); + // Should not throw + await expect(store.deleteMessages('no-such-conv', ['m1'])).resolves.toBeUndefined(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// assemblePrompt +// --------------------------------------------------------------------------- + +describe('assemblePrompt', () => { + let store: InMemoryConversationStore; + + beforeEach(() => { + store = new InMemoryConversationStore(); + }); + + it('returns empty messages when the store is empty', async () => { + const result = await assemblePrompt(store, 'conv-1', 'kael', 'Kael'); + expect(result.messages).toHaveLength(0); + expect(result.turnsLoaded).toBe(0); + expect(result.hasSummary).toBe(false); + }); + + describe('per-agent projection', () => { + it('renders system participant as system role', async () => { + await store.append(makeMessage({ + id: 'm1', + participant: { kind: 'system', id: 'system' }, + content: 'Channel topic: support', + })); + + const result = await assemblePrompt(store, 'conv-1', 'kael', 'Kael', { addressedOnlyMode: false }); + expect(result.messages[0]).toEqual({ role: 'system', content: 'Channel topic: support' }); + }); + + it('renders user participant as user role with displayName prefix', async () => { + await store.append(makeMessage({ + id: 'm1', + participant: { kind: 'user', id: 'u1', displayName: 'Alice' }, + content: 'Hey kael', + })); + + const result = await assemblePrompt(store, 'conv-1', 'kael', 'Kael', { addressedOnlyMode: false }); + expect(result.messages[0]).toEqual({ role: 'user', content: 'Alice: Hey kael' }); + }); + + it('falls back to participant.id when displayName is absent', async () => { + await store.append(makeMessage({ + id: 'm1', + participant: { kind: 'user', id: 'u1' }, + content: 'Hello', + })); + + const result = await assemblePrompt(store, 'conv-1', 'kael', 'Kael', { addressedOnlyMode: false }); + expect(result.messages[0].content).toBe('u1: Hello'); + }); + + it('renders current agent turns as assistant role', async () => { + await store.append(makeMessage({ + id: 'm1', + participant: { kind: 'agent', id: 'kael', displayName: 'Kael' }, + content: 'Sure, I can help with that.', + })); + + const result = await assemblePrompt(store, 'conv-1', 'kael', 'Kael', { addressedOnlyMode: false }); + expect(result.messages[0]).toEqual({ role: 'assistant', content: 'Sure, I can help with that.' }); + }); + + it('renders peer agent turns as user role with "(agent)" label', async () => { + await store.append(makeMessage({ + id: 'm1', + participant: { kind: 'agent', id: 'nova', displayName: 'Nova' }, + content: 'I handled the first part.', + })); + + const result = await assemblePrompt(store, 'conv-1', 'kael', 'Kael', { addressedOnlyMode: false }); + expect(result.messages[0]).toEqual({ role: 'user', content: 'Nova (agent): I handled the first part.' }); + }); + }); + + describe('addressed-only mode', () => { + it('includes messages sent by the agent', async () => { + await store.append(makeMessage({ + id: 'm1', + participant: { kind: 'agent', id: 'kael' }, + content: 'I replied earlier.', + })); + + const result = await assemblePrompt(store, 'conv-1', 'kael', 'Kael', { addressedOnlyMode: true }); + expect(result.messages).toHaveLength(1); + }); + + it('includes messages that @-mention the agent by stable name', async () => { + await store.append(makeMessage({ + id: 'm1', + participant: { kind: 'user', id: 'u1' }, + content: 'hey kael can you help?', + metadata: { mentions: ['kael'] }, + })); + + const result = await assemblePrompt(store, 'conv-1', 'kael', 'Kael', { addressedOnlyMode: true }); + expect(result.messages).toHaveLength(1); + }); + + it('includes messages that @-mention the agent by platform id (agentAliases)', async () => { + // Slack stores mentions as platform user ids like 'U_BOT123', not the + // stable agent name 'kael'. Without agentAliases this message would be + // excluded from the addressed-only filter even though it is directed at + // the bot. + await store.append(makeMessage({ + id: 'm1', + participant: { kind: 'user', id: 'u1' }, + content: 'hey <@U_BOT123> can you deploy?', + metadata: { mentions: ['U_BOT123'] }, + })); + + // Without the alias → message is not included (demonstrates the bug). + const withoutAlias = await assemblePrompt(store, 'conv-1', 'kael', 'Kael', { + addressedOnlyMode: true, + maxTurnsToLoad: 100, + }); + // m1 is the most-recent (triggering) message so it's always included even + // without the alias; add a second unrelated message first to show the difference. + // + // Rebuild with a proper setup: + + const store2 = new InMemoryConversationStore(); + // Side conversation (no mention of bot) + await store2.append(makeMessage({ id: 'side', conversationId: 'c2', participant: { kind: 'user', id: 'u2' }, content: 'lunch?' })); + // Bot mention by platform id + await store2.append(makeMessage({ id: 'bot-msg', conversationId: 'c2', participant: { kind: 'user', id: 'u1' }, content: 'hey can you deploy?', metadata: { mentions: ['U_BOT123'] } })); + // More recent unrelated message so bot-msg is NOT the triggering message + await store2.append(makeMessage({ id: 'after', conversationId: 'c2', participant: { kind: 'user', id: 'u2' }, content: 'never mind' })); + + // Without alias: bot-msg is excluded (only 'after' is the triggering message) + const noAlias = await assemblePrompt(store2, 'c2', 'kael', 'Kael', { addressedOnlyMode: true }); + expect(noAlias.messages.some(m => m.content.includes('deploy'))).toBe(false); + + // With alias: bot-msg is included because 'U_BOT123' is in agentAllIds + const withAlias = await assemblePrompt(store2, 'c2', 'kael', 'Kael', { + addressedOnlyMode: true, + agentAliases: ['U_BOT123'], + }); + expect(withAlias.messages.some(m => m.content.includes('deploy'))).toBe(true); + }); + + it('excludes side conversations the agent was not involved in', async () => { + // Alice and Bob chatting — no mention of kael + await store.append(makeMessage({ id: 'm1', participant: { kind: 'user', id: 'u1' }, content: 'lunch?' })); + await store.append(makeMessage({ id: 'm2', participant: { kind: 'user', id: 'u2' }, content: 'sure!' })); + // Carol mentions kael in the last message (triggering message always included) + await store.append(makeMessage({ + id: 'm3', + participant: { kind: 'user', id: 'u3' }, + content: 'kael can you help?', + metadata: { mentions: ['kael'] }, + })); + + const result = await assemblePrompt(store, 'conv-1', 'kael', 'Kael', { addressedOnlyMode: true }); + // Only m3 (involved) should be in the prompt + const contents = result.messages.map(m => m.content); + expect(contents.some(c => c.includes('lunch'))).toBe(false); + expect(contents.some(c => c.includes('kael can you help'))).toBe(true); + }); + + it('always includes the most recent message even if agent is not involved', async () => { + // A random message with no mention of kael — but it's the most recent + await store.append(makeMessage({ + id: 'm1', + participant: { kind: 'user', id: 'u1' }, + content: 'hey everyone', + })); + + const result = await assemblePrompt(store, 'conv-1', 'kael', 'Kael', { addressedOnlyMode: true }); + // The triggering (most recent) message is always included + expect(result.messages).toHaveLength(1); + }); + }); + + describe('token budget', () => { + it('drops oldest messages when budget is exceeded', async () => { + // Each message is ~100 chars = ~25 tokens; budget of 40 tokens fits ~1-2 messages + for (let i = 1; i <= 5; i++) { + await store.append(makeMessage({ + id: `m${i}`, + participant: { kind: 'user', id: 'u1', displayName: 'Alice' }, + content: `${'x'.repeat(80)} message ${i}`, + timestamp: `2024-01-01T00:00:0${i}Z`, + })); + } + + const result = await assemblePrompt(store, 'conv-1', 'kael', 'Kael', { + addressedOnlyMode: false, + tokenBudget: 30, + }); + + // Only the most recent message(s) that fit in 30 tokens should appear + expect(result.messages.length).toBeLessThan(5); + // The most recent message must be included + const lastContent = result.messages[result.messages.length - 1]?.content ?? ''; + expect(lastContent).toContain('message 5'); + }); + + it('always includes the triggering (most recent) message even if it alone exceeds the budget', async () => { + await store.append(makeMessage({ + id: 'm1', + participant: { kind: 'user', id: 'u1', displayName: 'Alice' }, + content: 'x'.repeat(2000), // ~500 tokens — far over any small budget + timestamp: '2024-01-01T00:00:01Z', + })); + + const result = await assemblePrompt(store, 'conv-1', 'kael', 'Kael', { + addressedOnlyMode: false, + tokenBudget: 10, // tiny budget + }); + + // Must still include the single message, not return empty + expect(result.messages).toHaveLength(1); + }); + }); + + describe('rolling summary', () => { + it('calls the summariser when turn count exceeds threshold', async () => { + for (let i = 1; i <= 6; i++) { + await store.append(makeMessage({ + id: `m${i}`, + participant: { kind: 'user', id: 'u1', displayName: 'Alice' }, + content: `message ${i}`, + timestamp: `2024-01-01T00:00:0${i}Z`, + })); + } + + const mockSummariser = { + invokeAgent: vi.fn().mockResolvedValue({ + output: JSON.stringify({ + summary: 'Earlier messages discussed general topics.', + turnsSummarized: 3, + hasDecisions: false, + estimatedTokens: 20, + }), + }), + } as any; + + const result = await assemblePrompt( + store, + 'conv-1', + 'kael', + 'Kael', + { addressedOnlyMode: false, rollingSummaryThreshold: 4 }, + mockSummariser + ); + + expect(mockSummariser.invokeAgent).toHaveBeenCalled(); + expect(result.hasSummary).toBe(true); + // The assembled messages should include the summary entry + const summaryMsg = result.messages.find(m => m.content.includes('[Summary of')); + expect(summaryMsg).toBeDefined(); + expect(summaryMsg?.role).toBe('system'); + }); + + it('does not call summariser when turn count is under threshold', async () => { + await store.append(makeMessage({ id: 'm1', content: 'hello' })); + await store.append(makeMessage({ id: 'm2', content: 'world' })); + + const mockSummariser = { invokeAgent: vi.fn() } as any; + + const result = await assemblePrompt( + store, + 'conv-1', + 'kael', + 'Kael', + { addressedOnlyMode: false, rollingSummaryThreshold: 10 }, + mockSummariser + ); + + expect(mockSummariser.invokeAgent).not.toHaveBeenCalled(); + expect(result.hasSummary).toBe(false); + }); + + it('falls back gracefully when summariser throws', async () => { + for (let i = 1; i <= 6; i++) { + await store.append(makeMessage({ + id: `m${i}`, + content: `message ${i}`, + timestamp: `2024-01-01T00:00:0${i}Z`, + })); + } + + const mockSummariser = { + invokeAgent: vi.fn().mockRejectedValue(new Error('LLM unavailable')), + } as any; + + // Should not throw even if summariser fails + const result = await assemblePrompt( + store, + 'conv-1', + 'kael', + 'Kael', + { addressedOnlyMode: false, rollingSummaryThreshold: 4 }, + mockSummariser + ); + + expect(result.hasSummary).toBe(false); + // Still returns some messages + expect(result.messages.length).toBeGreaterThan(0); + }); + + it('persists the summary to the store and deletes summarised turns (gap #2)', async () => { + for (let i = 1; i <= 6; i++) { + await store.append(makeMessage({ + id: `m${i}`, + content: `message ${i}`, + timestamp: `2024-01-01T00:00:0${i}Z`, + })); + } + + const mockSummariser = { + invokeAgent: vi.fn().mockResolvedValue({ + output: JSON.stringify({ + summary: 'Earlier messages covered general topics.', + turnsSummarized: 3, + hasDecisions: false, + estimatedTokens: 20, + }), + }), + } as any; + + await assemblePrompt( + store, + 'conv-1', + 'kael', + 'Kael', + { addressedOnlyMode: false, rollingSummaryThreshold: 4 }, + mockSummariser + ); + + const stored = await store.get('conv-1'); + + // The summarised turns (m1–m3, the first half) should have been removed. + const ids = stored.map(m => m.id); + expect(ids).not.toContain('m1'); + expect(ids).not.toContain('m2'); + expect(ids).not.toContain('m3'); + + // A persisted summary entry should be present. + const summaryEntry = stored.find(m => m.metadata?.isSummary); + expect(summaryEntry).toBeDefined(); + expect(summaryEntry?.participant.kind).toBe('system'); + + // The recent turns (m4–m6, the second half) must still be there. + expect(ids).toContain('m4'); + expect(ids).toContain('m5'); + expect(ids).toContain('m6'); + }); + + it('does not re-summarise an existing isSummary turn (gap #8)', async () => { + // Prime the store with an already-persisted summary + some new turns. + await store.append(makeMessage({ + id: 'summary-old', + participant: { kind: 'system', id: 'summarizer' }, + content: '[Summary of 3 earlier turns]: key context.', + timestamp: '2024-01-01T00:00:01Z', + metadata: { isSummary: true }, + })); + for (let i = 2; i <= 6; i++) { + await store.append(makeMessage({ + id: `m${i}`, + content: `message ${i}`, + timestamp: `2024-01-01T00:00:0${i}Z`, + })); + } + + const mockSummariser = { + invokeAgent: vi.fn().mockResolvedValue({ + output: JSON.stringify({ + summary: 'New summary.', + turnsSummarized: 2, + hasDecisions: false, + estimatedTokens: 10, + }), + }), + } as any; + + await assemblePrompt( + store, + 'conv-1', + 'kael', + 'Kael', + { addressedOnlyMode: false, rollingSummaryThreshold: 4 }, + mockSummariser + ); + + // The summariser should have been called, but only with the raw turns — + // not with the existing isSummary entry. + expect(mockSummariser.invokeAgent).toHaveBeenCalled(); + const calledWith = mockSummariser.invokeAgent.mock.calls[0][0]; + const turns = calledWith.data.turns as Array<{ id: string }>; + expect(turns.some(t => t.id === 'summary-old')).toBe(false); + }); + }); + + describe('addressed-only mode — replied-next criterion (gap #3)', () => { + it('includes a message that was immediately replied to by the agent', async () => { + // u1 sends a message (no mention of kael) + await store.append(makeMessage({ + id: 'm1', + participant: { kind: 'user', id: 'u1' }, + content: 'anyone know the deploy status?', + timestamp: '2024-01-01T00:00:01Z', + })); + // kael replies next — so m1 should be included via "replied next" + await store.append(makeMessage({ + id: 'm2', + participant: { kind: 'agent', id: 'kael' }, + content: 'Deploy is done.', + timestamp: '2024-01-01T00:00:02Z', + })); + // unrelated side conversation + await store.append(makeMessage({ + id: 'm3', + participant: { kind: 'user', id: 'u2' }, + content: 'cool, thanks', + timestamp: '2024-01-01T00:00:03Z', + })); + + const result = await assemblePrompt(store, 'conv-1', 'kael', 'Kael', { + addressedOnlyMode: true, + }); + + const contents = result.messages.map(m => m.content); + // m1 ("anyone know…") must be included because kael replied right after it + expect(contents.some(c => c.includes('deploy status'))).toBe(true); + // m2 (kael's reply) is included as the agent's own turn + expect(contents.some(c => c.includes('Deploy is done'))).toBe(true); + // m3 is the triggering (most recent) message — always included + expect(contents.some(c => c.includes('cool, thanks'))).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// createConversationSearchTool +// --------------------------------------------------------------------------- + +describe('createConversationSearchTool', () => { + let store: InMemoryConversationStore; + + beforeEach(async () => { + store = new InMemoryConversationStore(); + await store.append(makeMessage({ id: 'm1', content: 'deploy the service' })); + await store.append(makeMessage({ id: 'm2', content: 'the deployment failed' })); + await store.append(makeMessage({ id: 'm3', content: 'let\'s discuss lunch plans' })); + }); + + it('has the correct tool name and schema', () => { + const tool = createConversationSearchTool(store, 'conv-1'); + expect(tool.name).toBe('search_conversation_history'); + expect(tool.input_schema.required).toContain('query'); + }); + + it('returns formatted results for matching messages', async () => { + const tool = createConversationSearchTool(store, 'conv-1'); + const result = await tool.execute({ query: 'deploy' }); + + expect(result).toContain('deploy the service'); + expect(result).toContain('deployment failed'); + expect(result).not.toContain('lunch'); + }); + + it('returns a "not found" message when no results match', async () => { + const tool = createConversationSearchTool(store, 'conv-1'); + const result = await tool.execute({ query: 'zxqwerty' }); + + expect(result).toContain('No messages found'); + }); + + it('returns an error message for an empty query', async () => { + const tool = createConversationSearchTool(store, 'conv-1'); + const result = await tool.execute({ query: '' }); + + expect(result).toContain('Error'); + }); + + it('is scope-locked — only searches the given conversationId', async () => { + await store.append(makeMessage({ id: 'm4', conversationId: 'conv-2', content: 'deploy from conv-2' })); + + const tool = createConversationSearchTool(store, 'conv-1'); + const result = await tool.execute({ query: 'deploy' }); + + // conv-2 message must not appear + expect(result).not.toContain('conv-2'); + }); + + it('respects maxResults config', async () => { + const tool = createConversationSearchTool(store, 'conv-1', { maxResults: 1 }); + const result = await tool.execute({ query: 'deploy' }); + + // With maxResults: 1, only one result should appear + const lines = result.split('\n').filter(l => l.startsWith('[')); + expect(lines).toHaveLength(1); + }); +}); diff --git a/packages/toolpack-agents/src/history/index.ts b/packages/toolpack-agents/src/history/index.ts new file mode 100644 index 0000000..0dc6633 --- /dev/null +++ b/packages/toolpack-agents/src/history/index.ts @@ -0,0 +1,23 @@ +export type { + ConversationScope, + StoredMessage, + GetOptions, + SearchOptions, + AssemblerOptions, + PromptMessage, + AssembledPrompt, + ConversationStore, +} from './types.js'; + +export { + InMemoryConversationStore, + type InMemoryConversationStoreConfig, +} from './store.js'; + +export { assemblePrompt } from './assembler.js'; + +export { + createConversationSearchTool, + type ConversationSearchTool, + type ConversationSearchToolConfig, +} from './search-tool.js'; diff --git a/packages/toolpack-agents/src/history/search-tool.ts b/packages/toolpack-agents/src/history/search-tool.ts new file mode 100644 index 0000000..79cbc49 --- /dev/null +++ b/packages/toolpack-agents/src/history/search-tool.ts @@ -0,0 +1,138 @@ +import type { ConversationStore, ConversationSearchOptions as SearchOptions } from 'toolpack-sdk'; + +/** + * Configuration for the conversation search tool. + */ +export interface ConversationSearchToolConfig { + /** + * Maximum number of results the tool can return. + * Prevents the model from expanding context unboundedly. + * Default: 10. + */ + maxResults?: number; + + /** + * Rough token cap for the total search results returned. + * Results are dropped (whole messages, newest-first) once the running + * token count would exceed this cap. The first matching message is + * always included even if it alone exceeds the cap. + * Default: 2000. + */ + tokenCap?: number; +} + +/** + * A tool definition compatible with the Toolpack / Anthropic tool-use format. + */ +export interface ConversationSearchTool { + name: string; + description: string; + input_schema: { + type: 'object'; + properties: Record; + required: string[]; + }; + /** + * Execute the tool with the given input. + * Returns a plain-text result ready to pass back as a tool result message. + */ + execute: (input: { query: string }) => Promise; +} + +/** + * Creates a conversation search tool that the LLM can call during reasoning. + * + * The tool is **scope-locked** — it only searches the provided `conversationId` + * and never crosses conversation boundaries. + * + * Results are **token-capped** — the store truncates content to fit within + * `tokenCap` so the model cannot expand its context window by searching + * repeatedly. + * + * @param store The conversation store to search against. + * @param conversationId The current conversation id (scope lock). + * @param config Optional tuning (maxResults, tokenCap). + * + * @example + * ```ts + * const tool = createConversationSearchTool(store, input.conversationId, { + * maxResults: 5, + * tokenCap: 1500, + * }); + * + * // Pass to the LLM as a tool definition: + * const response = await llm.chat(messages, { tools: [tool] }); + * + * // When the model calls the tool: + * if (response.toolCall?.name === tool.name) { + * const result = await tool.execute(response.toolCall.input); + * // Feed result back as a tool result message... + * } + * ``` + */ +export function createConversationSearchTool( + store: ConversationStore, + conversationId: string, + config: ConversationSearchToolConfig = {} +): ConversationSearchTool { + const maxResults = config.maxResults ?? 10; + const tokenCap = config.tokenCap ?? 2000; + + const searchOptions: SearchOptions = { limit: maxResults, tokenCap }; + + return { + name: 'search_conversation_history', + + description: [ + 'Search the conversation history for messages matching a query.', + 'Use this when you need to recall something specific that was said earlier', + 'in this conversation but is not in your immediate context.', + 'Results are limited to this conversation only.', + ].join(' '), + + input_schema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'The search query — keywords or phrases to look for in the conversation history.', + }, + }, + required: ['query'], + }, + + execute: async ({ query }: { query: string }): Promise => { + if (!query || query.trim() === '') { + return 'Error: query must not be empty.'; + } + + const results = await store.search( + conversationId, + query.trim(), + searchOptions + ); + + if (results.length === 0) { + return `No messages found matching "${query}".`; + } + + const lines = results.map(msg => { + const name = msg.participant.displayName ?? msg.participant.id; + const label = msg.participant.kind === 'agent' ? `${name} (agent)` : name; + const date = new Date(msg.timestamp).toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + return `[${date}] ${label}: ${msg.content}`; + }); + + return [ + `Found ${results.length} result(s) for "${query}":`, + '', + ...lines, + ].join('\n'); + }, + }; +} diff --git a/packages/toolpack-agents/src/history/store.ts b/packages/toolpack-agents/src/history/store.ts new file mode 100644 index 0000000..7f82361 --- /dev/null +++ b/packages/toolpack-agents/src/history/store.ts @@ -0,0 +1,5 @@ +// Re-exported from toolpack-sdk. +export { + InMemoryConversationStore, + type InMemoryConversationStoreConfig, +} from 'toolpack-sdk'; diff --git a/packages/toolpack-agents/src/history/types.ts b/packages/toolpack-agents/src/history/types.ts new file mode 100644 index 0000000..c1091e1 --- /dev/null +++ b/packages/toolpack-agents/src/history/types.ts @@ -0,0 +1,12 @@ +// Re-exported from toolpack-sdk so consumers of toolpack-agents can import +// directly from this package without knowing the types moved to the SDK. +export type { + ConversationScope, + StoredMessage, + GetOptions, + ConversationSearchOptions as SearchOptions, + AssemblerOptions, + PromptMessage, + AssembledPrompt, + ConversationStore, +} from 'toolpack-sdk'; diff --git a/packages/toolpack-agents/src/index.ts b/packages/toolpack-agents/src/index.ts new file mode 100644 index 0000000..abd6442 --- /dev/null +++ b/packages/toolpack-agents/src/index.ts @@ -0,0 +1,115 @@ +// Agent layer for Toolpack SDK +// Build, compose, and deploy AI agents with a consistent, extensible pattern + +// Core agent types and classes +export { + AgentInput, + AgentResult, + AgentOutput, + AgentRunOptions, + BaseAgentOptions, + WorkflowStep, + IAgentRegistry, + AgentInstance, + ChannelInterface, + PendingAsk, + Participant, +} from './agent/types.js'; + +export { BaseAgent, AgentEvents } from './agent/base-agent.js'; +export { AgentRegistry } from './agent/agent-registry.js'; +export { AgentError } from './agent/errors.js'; + +// Built-in agents +export { ResearchAgent } from './agents/research-agent.js'; +export { CodingAgent } from './agents/coding-agent.js'; +export { DataAgent } from './agents/data-agent.js'; +export { BrowserAgent } from './agents/browser-agent.js'; + +// Channel base class and implementations +export { BaseChannel } from './channels/base-channel.js'; +export { SlackChannel, SlackChannelConfig } from './channels/slack-channel.js'; +export { WebhookChannel, WebhookChannelConfig } from './channels/webhook-channel.js'; +export { ScheduledChannel, ScheduledChannelConfig } from './channels/scheduled-channel.js'; +export { TelegramChannel, TelegramChannelConfig } from './channels/telegram-channel.js'; +export { DiscordChannel, DiscordChannelConfig } from './channels/discord-channel.js'; +export { EmailChannel, EmailChannelConfig } from './channels/email-channel.js'; +export { SMSChannel, SMSChannelConfig } from './channels/sms-channel.js'; + +// Transport layer for agent-to-agent communication +export { + AgentTransport, + AgentRegistryTransportOptions, + LocalTransport, + JsonRpcTransport, + AgentJsonRpcServer, +} from './transport/index.js'; + +// Capability agents for cross-cutting concerns (interceptors, summarization) +export { + IntentClassifierAgent, + IntentClassifierInput, + IntentClassification, + SummarizerAgent, + SummarizerInput, + SummarizerOutput, + HistoryTurn, +} from './capabilities/index.js'; +// Participant is now a core type in agent/types.ts (exported above). + +// Conversation history — storage, assembly, and retrieval +export { + type ConversationScope, + type StoredMessage, + type GetOptions, + type SearchOptions, + type AssemblerOptions, + type PromptMessage, + type AssembledPrompt, + type ConversationStore, + InMemoryConversationStore, + type InMemoryConversationStoreConfig, + assemblePrompt, + createConversationSearchTool, + type ConversationSearchTool, + type ConversationSearchToolConfig, +} from './history/index.js'; + +// Interceptor system for composable middleware +export { + SKIP_SENTINEL, + type InterceptorResult, + type InterceptorContext, + type NextFunction, + type Interceptor, + type InterceptorChainConfig, + type ComposedChain, + isSkipSentinel, + skip, + InvocationDepthExceededError, + composeChain, + executeChain, + // Built-in interceptors + createEventDedupInterceptor, + type EventDedupConfig, + createNoiseFilterInterceptor, + type NoiseFilterConfig, + createSelfFilterInterceptor, + type SelfFilterConfig, + createRateLimitInterceptor, + type RateLimitConfig, + createParticipantResolverInterceptor, + type ParticipantResolverConfig, + createCaptureInterceptor, + type CaptureHistoryConfig, + createAddressCheckInterceptor, + type AddressCheckConfig, + type AddressCheckResult, + createIntentClassifierInterceptor, + type IntentClassifierInterceptorConfig, + createDepthGuardInterceptor, + type DepthGuardConfig, + DepthExceededError, + createTracerInterceptor, + type TracerConfig, +} from './interceptors/index.js'; diff --git a/packages/toolpack-agents/src/interceptors/builtins/address-check.ts b/packages/toolpack-agents/src/interceptors/builtins/address-check.ts new file mode 100644 index 0000000..a60a4d9 --- /dev/null +++ b/packages/toolpack-agents/src/interceptors/builtins/address-check.ts @@ -0,0 +1,226 @@ +import type { AgentInput } from '../../agent/types.js'; +import type { Interceptor, InterceptorContext, InterceptorResult, NextFunction } from '../types.js'; + +/** + * Check if the agent's name appears only inside code regions (fenced blocks + * ``` ``` ``` ``` or inline backticks `` ` ``). + * + * Returns true if: + * - The agent name is inside at least one code region AND + * - The agent name does NOT appear outside code regions + * + * Returns false if: + * - The agent name is not present at all + * - There are no code regions + * - The agent name also appears outside code regions + */ +export function isAgentNameOnlyInCodeBlocks(message: string, agentName: string): boolean { + const agentNameLower = agentName.toLowerCase(); + const messageLower = message.toLowerCase(); + + // Agent name must be present at all + if (!messageLower.includes(agentNameLower)) { + return false; + } + + // Find all code regions: fenced ``` ``` blocks first (multiline), then inline `…`. + // We collect [start, end) ranges so we can strip by position (handles duplicates). + const ranges: Array<[number, number]> = []; + + const fencedRegex = /```[\s\S]*?```/g; + let m: RegExpExecArray | null; + while ((m = fencedRegex.exec(message)) !== null) { + ranges.push([m.index, m.index + m[0].length]); + } + + const inlineRegex = /`[^`\n]*`/g; + while ((m = inlineRegex.exec(message)) !== null) { + const start = m.index; + const end = start + m[0].length; + // Skip if already covered by a fenced block + const coveredByFence = ranges.some(([s, e]) => start >= s && end <= e); + if (!coveredByFence) { + ranges.push([start, end]); + } + } + + // No code regions - not an "only in code" case + if (ranges.length === 0) { + return false; + } + + // Build text outside code regions by stripping ranges in order. + // Sort ranges ascending and walk through the message collecting gaps. + ranges.sort((a, b) => a[0] - b[0]); + let outsideText = ''; + let cursor = 0; + for (const [start, end] of ranges) { + if (start > cursor) { + outsideText += message.slice(cursor, start); + } + cursor = Math.max(cursor, end); + } + if (cursor < message.length) { + outsideText += message.slice(cursor); + } + + const outsideCodeBlock = outsideText.toLowerCase().includes(agentNameLower); + + // If it's anywhere outside code regions, it's not "only in code" + if (outsideCodeBlock) { + return false; + } + + // Confirm it's actually inside at least one code region + const inCodeBlock = ranges.some(([s, e]) => + message.slice(s, e).toLowerCase().includes(agentNameLower) + ); + + return inCodeBlock; +} + +/** + * Classification result from address checking. + */ +export type AddressCheckResult = + | 'direct' // Clearly addressed to agent (@mention, name in greeting) + | 'indirect' // Mentioned but unclear + | 'passive' // Not addressed, should listen only + | 'ignore' // Definitely not for agent + | 'ambiguous'; // Needs LLM classification + +/** + * Configuration for the address-check rules interceptor. + */ +export interface AddressCheckConfig { + /** The agent's display name (e.g., "Assistant") */ + agentName: string; + + /** The agent's ID/slack ID (e.g., "U123456") */ + agentId?: string; + + /** Function to extract message text from input */ + getMessageText: (input: AgentInput) => string | undefined; + + /** Optional: Check if input is a direct message (DM) */ + isDirectMessage?: (input: AgentInput) => boolean; + + /** Optional: Extract mentioned user IDs from message */ + getMentions?: (input: AgentInput) => string[]; + + /** Optional callback when classification is made */ + onClassified?: (result: AddressCheckResult, input: AgentInput) => void; +} + +/** + * Creates an address-check rules interceptor. + * + * Stage-3 rule-based classifier: + * - Vocative detection ("Hey Assistant...") + * - Possessive patterns ("my Assistant...") + * - Code/URL detection (likely not addressing) + * - Co-mention detection + * + * Returns 'ambiguous' for cases that need LLM classification by intent-classifier. + * + * @example + * ```ts + * const registry = new AgentRegistry([ + * { + * agent: MyAgent, + * channels: [slackChannel], + * interceptors: [ + * createAddressCheckInterceptor({ + * agentName: 'Assistant', + * agentId: 'U123456', + * getMessageText: (input) => input.message || '' + * }) + * ] + * } + * ]); + * ``` + */ +export function createAddressCheckInterceptor(config: AddressCheckConfig): Interceptor { + return async (input: AgentInput, ctx: InterceptorContext, next: NextFunction): Promise => { + const messageText = config.getMessageText(input) ?? ''; + + // Check for direct message (always direct) + if (config.isDirectMessage?.(input)) { + const enrichedInput: AgentInput = { + ...input, + context: { + ...input.context, + _addressCheck: 'direct' as AddressCheckResult, + _isDM: true, + }, + }; + config.onClassified?.('direct', input); + return await next(enrichedInput); + } + + // Rule-based classification + let result: AddressCheckResult = 'ambiguous'; + + const lowerMessage = messageText.toLowerCase(); + const agentNameLower = config.agentName.toLowerCase(); + // Escape regex metacharacters so names like "agent.v2" or "c++" work correctly. + const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escapedName = escapeRegex(agentNameLower); + const idPart = config.agentId ? `|^@${escapeRegex(config.agentId)}\\b` : ''; + + // Check 1: @mention or explicit name in greeting (direct) + const vocativePattern = new RegExp(`^(hey\\s+)?@?${escapedName}\\b${idPart}`, 'i'); + if (vocativePattern.test(lowerMessage)) { + result = 'direct'; + } + // Check 2: Possessive patterns (ambiguous - talking ABOUT the agent, not necessarily TO it) + // Examples: "the assistant mentioned earlier", "our assistant logged that" + else if (new RegExp(`\\b(my|our|the)\\s+${escapedName}\\b`, 'i').test(lowerMessage)) { + result = 'ambiguous'; + } + // Check 3: Code blocks — only ignore if agent name is EXCLUSIVELY inside code blocks + // Example: "check this: ```error in kael system```" → ignore (name only in code) + // Example: "hey kael, here's my error: ```stack trace```" → continue (name outside code) + else if (isAgentNameOnlyInCodeBlocks(messageText, config.agentName)) { + result = 'ignore'; + } + // Check 4: URLs as entire message (ignore) + else if (/^https?:\/\//.test(messageText)) { + result = 'ignore'; + } + // Check 5: Co-mention check (indirect if others mentioned) + else if (config.getMentions) { + const mentions = config.getMentions(input); + const agentMentioned = mentions.some( + m => m.toLowerCase() === agentNameLower || m === config.agentId + ); + if (agentMentioned && mentions.length > 1) { + result = 'indirect'; + } else if (agentMentioned) { + result = 'ambiguous'; + } + } + // Check 6: Simple name mention (ambiguous - needs LLM) + else if (lowerMessage.includes(agentNameLower)) { + result = 'ambiguous'; + } + // No mention detected (passive) + else { + result = 'passive'; + } + + // Enrich input with classification result + const enrichedInput: AgentInput = { + ...input, + context: { + ...input.context, + _addressCheck: result, + }, + }; + + config.onClassified?.(result, input); + ctx.logger?.debug(`Address check classified as: ${result}`, { result }); + + return await next(enrichedInput); + }; +} diff --git a/packages/toolpack-agents/src/interceptors/builtins/builtins.test.ts b/packages/toolpack-agents/src/interceptors/builtins/builtins.test.ts new file mode 100644 index 0000000..da0af11 --- /dev/null +++ b/packages/toolpack-agents/src/interceptors/builtins/builtins.test.ts @@ -0,0 +1,1096 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { AgentInput, AgentResult, AgentInstance, ChannelInterface, IAgentRegistry } from '../../agent/types.js'; +import { composeChain, executeChain } from '../chain.js'; +import { SKIP_SENTINEL, isSkipSentinel, type Interceptor } from '../types.js'; + +import { createEventDedupInterceptor } from './event-dedup.js'; +import { createNoiseFilterInterceptor } from './noise-filter.js'; +import { createSelfFilterInterceptor } from './self-filter.js'; +import { createRateLimitInterceptor } from './rate-limit.js'; +import { createParticipantResolverInterceptor } from './participant-resolver.js'; +import { createAddressCheckInterceptor, isAgentNameOnlyInCodeBlocks, type AddressCheckResult } from './address-check.js'; +import { createDepthGuardInterceptor, DepthExceededError } from './depth-guard.js'; +import { createTracerInterceptor } from './tracer.js'; +import { createIntentClassifierInterceptor } from './intent-classifier.js'; + +// ---------- Test helpers ---------- + +function createMockAgent(name: string, result: AgentResult = { output: 'ok' }): AgentInstance { + return { + name, + description: `Mock ${name}`, + mode: 'chat', + invokeAgent: vi.fn().mockResolvedValue(result), + } as unknown as AgentInstance; +} + +function createMockChannel(name: string = 'test-channel'): ChannelInterface { + return { + name, + isTriggerChannel: false, + listen: vi.fn(), + send: vi.fn().mockResolvedValue(undefined), + normalize: vi.fn(), + onMessage: vi.fn(), + }; +} + +function createMockRegistry(agents: Map = new Map()): IAgentRegistry { + return { + start: vi.fn(), + sendTo: vi.fn().mockResolvedValue(undefined), + getAgent: vi.fn((name: string) => agents.get(name)), + getAllAgents: vi.fn(() => Array.from(agents.values())), + getPendingAsk: vi.fn(), + addPendingAsk: vi.fn(), + resolvePendingAsk: vi.fn().mockResolvedValue(undefined), + hasPendingAsks: vi.fn(), + incrementRetries: vi.fn(), + cleanupExpiredAsks: vi.fn().mockReturnValue(0), + } as unknown as IAgentRegistry; +} + +/** + * Run a single interceptor with a minimal chain setup and return the chain's result. + */ +async function runInterceptor( + interceptor: Interceptor, + input: AgentInput, + agentResult: AgentResult = { output: 'agent-ran' } +) { + const agent = createMockAgent('test-agent', agentResult); + const channel = createMockChannel(); + const registry = createMockRegistry(); + const chain = composeChain([interceptor], agent, channel, registry); + const result = await executeChain(chain, input); + return { result, agent, channel, registry }; +} + +// ---------- event-dedup ---------- + +describe('createEventDedupInterceptor', () => { + it('allows first occurrence of an event through', async () => { + const interceptor = createEventDedupInterceptor(); + const input: AgentInput = { + message: 'hi', + conversationId: 'c1', + context: { eventId: 'evt-1' }, + }; + const { result, agent } = await runInterceptor(interceptor, input); + + expect(agent.invokeAgent).toHaveBeenCalledTimes(1); + expect(result).not.toBeNull(); + }); + + it('skips duplicate events', async () => { + const interceptor = createEventDedupInterceptor(); + const onDuplicate = vi.fn(); + const dedupWithCb = createEventDedupInterceptor({ onDuplicate }); + + const agent = createMockAgent('test-agent'); + const chain = composeChain([dedupWithCb], agent, createMockChannel(), createMockRegistry()); + const input: AgentInput = { + message: 'hi', + conversationId: 'c1', + context: { eventId: 'evt-1' }, + }; + + const first = await executeChain(chain, input); + const second = await executeChain(chain, input); + + expect(first).not.toBeNull(); + expect(second).toBeNull(); // skipped + expect(agent.invokeAgent).toHaveBeenCalledTimes(1); + expect(onDuplicate).toHaveBeenCalledWith('evt-1', input); + }); + + it('treats missing eventId as always fresh', async () => { + const interceptor = createEventDedupInterceptor(); + const input: AgentInput = { message: 'hi', conversationId: 'c1' }; + + const { result: r1 } = await runInterceptor(interceptor, input); + const { result: r2 } = await runInterceptor(interceptor, input); + + expect(r1).not.toBeNull(); + expect(r2).not.toBeNull(); + }); + + it('evicts oldest entries when maxCacheSize reached (LRU)', async () => { + const interceptor = createEventDedupInterceptor({ maxCacheSize: 2 }); + const agent = createMockAgent('test-agent'); + const chain = composeChain([interceptor], agent, createMockChannel(), createMockRegistry()); + + const make = (id: string): AgentInput => ({ + message: 'hi', + conversationId: 'c1', + context: { eventId: id }, + }); + + // Fill cache to capacity with a and b, then c should evict a. + await executeChain(chain, make('a')); + await executeChain(chain, make('b')); + await executeChain(chain, make('c')); // evicts 'a', cache now {b, c} + + // b and c are cached → duplicates should skip + const reB = await executeChain(chain, make('b')); + const reC = await executeChain(chain, make('c')); + expect(reB).toBeNull(); + expect(reC).toBeNull(); + + // a was evicted → should be allowed through as fresh + const reA = await executeChain(chain, make('a')); + expect(reA).not.toBeNull(); + }); + + it('supports custom getEventId', async () => { + const interceptor = createEventDedupInterceptor({ + getEventId: (input) => input.message, + }); + const agent = createMockAgent('test-agent'); + const chain = composeChain([interceptor], agent, createMockChannel(), createMockRegistry()); + + const r1 = await executeChain(chain, { message: 'hello', conversationId: 'c1' }); + const r2 = await executeChain(chain, { message: 'hello', conversationId: 'c1' }); + + expect(r1).not.toBeNull(); + expect(r2).toBeNull(); + }); +}); + +// ---------- noise-filter ---------- + +describe('createNoiseFilterInterceptor', () => { + it('drops messages with denied subtype', async () => { + const onFiltered = vi.fn(); + const interceptor = createNoiseFilterInterceptor({ + denySubtypes: ['message_changed', 'bot_message'], + onFiltered, + }); + + const input: AgentInput = { + message: 'edited', + conversationId: 'c1', + context: { subtype: 'message_changed' }, + }; + const { result, agent } = await runInterceptor(interceptor, input); + + expect(result).toBeNull(); + expect(agent.invokeAgent).not.toHaveBeenCalled(); + expect(onFiltered).toHaveBeenCalledWith('message_changed', input); + }); + + it('passes through messages without denied subtype', async () => { + const interceptor = createNoiseFilterInterceptor({ + denySubtypes: ['message_changed'], + }); + const input: AgentInput = { + message: 'hi', + conversationId: 'c1', + context: { subtype: 'regular' }, + }; + const { result, agent } = await runInterceptor(interceptor, input); + + expect(result).not.toBeNull(); + expect(agent.invokeAgent).toHaveBeenCalledTimes(1); + }); + + it('passes through when subtype is missing', async () => { + const interceptor = createNoiseFilterInterceptor({ denySubtypes: ['x'] }); + const { result, agent } = await runInterceptor(interceptor, { + message: 'hi', + conversationId: 'c1', + }); + expect(result).not.toBeNull(); + expect(agent.invokeAgent).toHaveBeenCalledTimes(1); + }); + + it('supports custom getSubtype', async () => { + const interceptor = createNoiseFilterInterceptor({ + denySubtypes: ['noisy'], + getSubtype: (input) => input.intent, + }); + const { result } = await runInterceptor(interceptor, { + message: 'hi', + conversationId: 'c1', + intent: 'noisy', + }); + expect(result).toBeNull(); + }); +}); + +// ---------- self-filter ---------- + +describe('createSelfFilterInterceptor', () => { + it('drops messages where sender is agent itself (via agentId config)', async () => { + const onSelfMessage = vi.fn(); + const interceptor = createSelfFilterInterceptor({ + agentId: 'U0123456', + getSenderId: (input) => input.context?.senderId as string | undefined, + onSelfMessage, + }); + const input: AgentInput = { + message: 'loop?', + conversationId: 'c1', + context: { senderId: 'U0123456' }, + }; + const { result, agent } = await runInterceptor(interceptor, input); + + expect(result).toBeNull(); + expect(agent.invokeAgent).not.toHaveBeenCalled(); + expect(onSelfMessage).toHaveBeenCalledWith('U0123456', input); + }); + + it('falls back to agent name when agentId not provided', async () => { + const interceptor = createSelfFilterInterceptor({ + getSenderId: (input) => input.context?.senderId as string | undefined, + }); + const agent = createMockAgent('my-bot'); + const chain = composeChain([interceptor], agent, createMockChannel(), createMockRegistry()); + const input: AgentInput = { + message: 'loop?', + conversationId: 'c1', + context: { senderId: 'my-bot' }, + }; + const result = await executeChain(chain, input); + expect(result).toBeNull(); + }); + + it('passes through messages from other senders', async () => { + const interceptor = createSelfFilterInterceptor({ + agentId: 'U0123456', + getSenderId: (input) => input.context?.senderId as string | undefined, + }); + const { result, agent } = await runInterceptor(interceptor, { + message: 'hi', + conversationId: 'c1', + context: { senderId: 'U9999999' }, + }); + + expect(result).not.toBeNull(); + expect(agent.invokeAgent).toHaveBeenCalledTimes(1); + }); + + it('passes through when sender ID is missing', async () => { + const interceptor = createSelfFilterInterceptor({ + agentId: 'U0123456', + getSenderId: () => undefined, + }); + const { result } = await runInterceptor(interceptor, { message: 'hi', conversationId: 'c1' }); + expect(result).not.toBeNull(); + }); +}); + +// ---------- rate-limit ---------- + +describe('createRateLimitInterceptor', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('allows requests up to the token limit', async () => { + const interceptor = createRateLimitInterceptor({ + getKey: () => 'user-1', + tokensPerInterval: 3, + interval: 60000, + }); + const agent = createMockAgent('test-agent'); + const chain = composeChain([interceptor], agent, createMockChannel(), createMockRegistry()); + + const input: AgentInput = { message: 'hi', conversationId: 'c1' }; + + const r1 = await executeChain(chain, input); + const r2 = await executeChain(chain, input); + const r3 = await executeChain(chain, input); + + expect(r1).not.toBeNull(); + expect(r2).not.toBeNull(); + expect(r3).not.toBeNull(); + expect(agent.invokeAgent).toHaveBeenCalledTimes(3); + }); + + it('skips requests after tokens exhausted (onExceeded=skip)', async () => { + const onRateLimited = vi.fn(); + const interceptor = createRateLimitInterceptor({ + getKey: () => 'user-1', + tokensPerInterval: 2, + interval: 60000, + onExceeded: 'skip', + onRateLimited, + }); + const agent = createMockAgent('test-agent'); + const chain = composeChain([interceptor], agent, createMockChannel(), createMockRegistry()); + + await executeChain(chain, { message: 'a', conversationId: 'c1' }); + await executeChain(chain, { message: 'b', conversationId: 'c1' }); + const r3 = await executeChain(chain, { message: 'c', conversationId: 'c1' }); + + expect(r3).toBeNull(); + expect(agent.invokeAgent).toHaveBeenCalledTimes(2); + expect(onRateLimited).toHaveBeenCalledTimes(1); + expect(onRateLimited.mock.calls[0][0]).toBe('user-1'); + }); + + it('throws when exceeded (onExceeded=reject)', async () => { + const interceptor = createRateLimitInterceptor({ + getKey: () => 'user-1', + tokensPerInterval: 1, + interval: 60000, + onExceeded: 'reject', + }); + const agent = createMockAgent('test-agent'); + const chain = composeChain([interceptor], agent, createMockChannel(), createMockRegistry()); + + await executeChain(chain, { message: 'a', conversationId: 'c1' }); + await expect( + executeChain(chain, { message: 'b', conversationId: 'c1' }) + ).rejects.toThrow(/rate limit/i); + }); + + it('refills tokens after interval elapses', async () => { + const interceptor = createRateLimitInterceptor({ + getKey: () => 'user-1', + tokensPerInterval: 2, + interval: 60000, + }); + const agent = createMockAgent('test-agent'); + const chain = composeChain([interceptor], agent, createMockChannel(), createMockRegistry()); + + await executeChain(chain, { message: 'a', conversationId: 'c1' }); + await executeChain(chain, { message: 'b', conversationId: 'c1' }); + const exhausted = await executeChain(chain, { message: 'c', conversationId: 'c1' }); + expect(exhausted).toBeNull(); + + // Advance time by one full interval so the bucket refills. + vi.advanceTimersByTime(60000); + + const afterRefill = await executeChain(chain, { message: 'd', conversationId: 'c1' }); + expect(afterRefill).not.toBeNull(); + }); + + it('tracks separate buckets per key', async () => { + const interceptor = createRateLimitInterceptor({ + getKey: (input) => input.context?.userId as string, + tokensPerInterval: 1, + interval: 60000, + }); + const agent = createMockAgent('test-agent'); + const chain = composeChain([interceptor], agent, createMockChannel(), createMockRegistry()); + + const u1 = await executeChain(chain, { + message: 'hi', + conversationId: 'c1', + context: { userId: 'u1' }, + }); + const u2 = await executeChain(chain, { + message: 'hi', + conversationId: 'c1', + context: { userId: 'u2' }, + }); + const u1Again = await executeChain(chain, { + message: 'hi', + conversationId: 'c1', + context: { userId: 'u1' }, + }); + + expect(u1).not.toBeNull(); + expect(u2).not.toBeNull(); + expect(u1Again).toBeNull(); // u1 bucket exhausted + }); + + it('respects maxBuckets via LRU eviction', async () => { + // With maxBuckets=2, the third key should evict the least-recently-used bucket. + const interceptor = createRateLimitInterceptor({ + getKey: (input) => input.context?.userId as string, + tokensPerInterval: 1, + interval: 60000, + maxBuckets: 2, + }); + const agent = createMockAgent('test-agent'); + const chain = composeChain([interceptor], agent, createMockChannel(), createMockRegistry()); + + const call = (userId: string) => + executeChain(chain, { + message: 'hi', + conversationId: 'c1', + context: { userId }, + }); + + await call('u1'); // u1 bucket created, 1 token consumed + await call('u2'); // u2 bucket created + // u1 exhausted locally: second call should skip + const u1Second = await call('u1'); + expect(u1Second).toBeNull(); + + // Now add u3. This evicts the LRU bucket. 'u1' was used most recently (via u1Second). + // So u2 should be evicted. + await call('u3'); + + // u2 was evicted, so its next request gets a fresh bucket and succeeds. + const u2Fresh = await call('u2'); + expect(u2Fresh).not.toBeNull(); + }); +}); + +// ---------- participant-resolver ---------- + +describe('createParticipantResolverInterceptor', () => { + /** Capture the input that reaches downstream for assertions. */ + function captureDownstream() { + let captured: AgentInput | undefined; + const downstream: Interceptor = async (input, _ctx, next) => { + captured = input; + return await next(); + }; + return { downstream, get: () => captured }; + } + + it('enriches input with first-class participant field when explicit resolver returns one', async () => { + const onResolved = vi.fn(); + const participant = { kind: 'user' as const, id: 'u1', displayName: 'Alice' }; + + const interceptor = createParticipantResolverInterceptor({ + resolveParticipant: () => participant, + onResolved, + }); + + const { downstream, get } = captureDownstream(); + const agent = createMockAgent('test-agent'); + const chain = composeChain([interceptor, downstream], agent, createMockChannel(), createMockRegistry()); + await executeChain(chain, { message: 'hi', conversationId: 'c1' }); + + expect(get()?.participant).toEqual(participant); + // Legacy context slot is also populated for back-compat + expect(get()?.context?._participant).toEqual(participant); + expect(onResolved).toHaveBeenCalled(); + }); + + it('awaits async explicit resolver', async () => { + const participant = { kind: 'user' as const, id: 'u2', displayName: 'Bob' }; + const interceptor = createParticipantResolverInterceptor({ + resolveParticipant: async () => participant, + }); + + const { downstream, get } = captureDownstream(); + const agent = createMockAgent('test-agent'); + const chain = composeChain([interceptor, downstream], agent, createMockChannel(), createMockRegistry()); + await executeChain(chain, { message: 'hi', conversationId: 'c1' }); + + expect(get()?.participant).toEqual(participant); + }); + + it("falls back to ctx.channel.resolveParticipant when no explicit resolver is provided", async () => { + const resolved = { kind: 'user' as const, id: 'u3', displayName: 'Carol' }; + const interceptor = createParticipantResolverInterceptor(); // no config + + const channel = createMockChannel(); + channel.resolveParticipant = vi.fn().mockResolvedValue(resolved); + + const { downstream, get } = captureDownstream(); + const agent = createMockAgent('test-agent'); + const chain = composeChain([interceptor, downstream], agent, channel, createMockRegistry()); + await executeChain(chain, { message: 'hi', conversationId: 'c1' }); + + expect(channel.resolveParticipant).toHaveBeenCalled(); + expect(get()?.participant).toEqual(resolved); + }); + + it("explicit resolver takes precedence over channel.resolveParticipant", async () => { + const channelResolved = { kind: 'user' as const, id: 'channel-id', displayName: 'FromChannel' }; + const explicitResolved = { kind: 'user' as const, id: 'explicit-id', displayName: 'FromConfig' }; + + const channel = createMockChannel(); + channel.resolveParticipant = vi.fn().mockResolvedValue(channelResolved); + + const interceptor = createParticipantResolverInterceptor({ + resolveParticipant: () => explicitResolved, + }); + + const { downstream, get } = captureDownstream(); + const agent = createMockAgent('test-agent'); + const chain = composeChain([interceptor, downstream], agent, channel, createMockRegistry()); + await executeChain(chain, { message: 'hi', conversationId: 'c1' }); + + expect(channel.resolveParticipant).not.toHaveBeenCalled(); + expect(get()?.participant).toEqual(explicitResolved); + }); + + it('preserves existing input.participant from normalize() when no resolver available', async () => { + const fromNormalize = { kind: 'user' as const, id: 'u-norm' }; + const interceptor = createParticipantResolverInterceptor(); // no config + + const { downstream, get } = captureDownstream(); + const agent = createMockAgent('test-agent'); + // Channel has no resolveParticipant hook + const chain = composeChain([interceptor, downstream], agent, createMockChannel(), createMockRegistry()); + await executeChain(chain, { + message: 'hi', + conversationId: 'c1', + participant: fromNormalize, + }); + + expect(get()?.participant).toEqual(fromNormalize); + expect(get()?.context?._participant).toEqual(fromNormalize); + }); + + it('channel-resolved participant overrides normalize-provided participant', async () => { + const fromNormalize = { kind: 'user' as const, id: 'u-norm' }; + const fromChannel = { kind: 'user' as const, id: 'u-norm', displayName: 'Resolved Name' }; + + const channel = createMockChannel(); + channel.resolveParticipant = vi.fn().mockResolvedValue(fromChannel); + + const interceptor = createParticipantResolverInterceptor(); + + const { downstream, get } = captureDownstream(); + const agent = createMockAgent('test-agent'); + const chain = composeChain([interceptor, downstream], agent, channel, createMockRegistry()); + await executeChain(chain, { + message: 'hi', + conversationId: 'c1', + participant: fromNormalize, + }); + + expect(get()?.participant).toEqual(fromChannel); + }); + + it('falls back to normalize-provided participant when channel resolver throws', async () => { + const fromNormalize = { kind: 'user' as const, id: 'u-norm' }; + const channel = createMockChannel(); + channel.resolveParticipant = vi.fn().mockRejectedValue(new Error('api down')); + + const interceptor = createParticipantResolverInterceptor(); + + const { downstream, get } = captureDownstream(); + const agent = createMockAgent('test-agent'); + const chain = composeChain([interceptor, downstream], agent, channel, createMockRegistry()); + await executeChain(chain, { + message: 'hi', + conversationId: 'c1', + participant: fromNormalize, + }); + + expect(get()?.participant).toEqual(fromNormalize); + }); + + it('passes through unchanged when nothing is available', async () => { + const interceptor = createParticipantResolverInterceptor({ + resolveParticipant: () => undefined, + }); + + const { downstream, get } = captureDownstream(); + const agent = createMockAgent('test-agent'); + const chain = composeChain([interceptor, downstream], agent, createMockChannel(), createMockRegistry()); + await executeChain(chain, { message: 'hi', conversationId: 'c1' }); + + expect(get()?.participant).toBeUndefined(); + expect(get()?.context?._participant).toBeUndefined(); + }); +}); + +// ---------- address-check ---------- + +describe('isAgentNameOnlyInCodeBlocks', () => { + it('returns false when name is not in message at all', () => { + expect(isAgentNameOnlyInCodeBlocks('hello world', 'kael')).toBe(false); + }); + + it('returns false when there are no code regions', () => { + expect(isAgentNameOnlyInCodeBlocks('hey kael, how are you?', 'kael')).toBe(false); + }); + + it('returns true when name is only inside a fenced block', () => { + expect(isAgentNameOnlyInCodeBlocks('check this: ```error in kael system```', 'kael')).toBe(true); + }); + + it('returns false when name appears both inside and outside code', () => { + expect( + isAgentNameOnlyInCodeBlocks('hey kael, here is the issue: ```kael crashed```', 'kael') + ).toBe(false); + }); + + it('returns true with multiple fenced blocks, name only inside', () => { + const message = 'look: ```kael log 1``` also ```kael log 2``` please'; + expect(isAgentNameOnlyInCodeBlocks(message, 'kael')).toBe(true); + }); + + it('handles duplicate identical fenced blocks correctly', () => { + const message = '```kael``` and again ```kael```'; + expect(isAgentNameOnlyInCodeBlocks(message, 'kael')).toBe(true); + }); + + it('treats inline backticks as code', () => { + expect(isAgentNameOnlyInCodeBlocks('check `kael` output', 'kael')).toBe(true); + }); + + it('returns false when inline code contains name but name also outside', () => { + expect(isAgentNameOnlyInCodeBlocks('hey kael, see `kael` in logs', 'kael')).toBe(false); + }); + + it('is case-insensitive', () => { + expect(isAgentNameOnlyInCodeBlocks('```KAEL output```', 'kael')).toBe(true); + }); +}); + +describe('createAddressCheckInterceptor', () => { + const baseConfig = { + agentName: 'kael', + agentId: 'U123', + getMessageText: (input: AgentInput) => input.message, + }; + + async function classify(input: AgentInput, extraConfig: Partial = {}) { + let captured: AddressCheckResult | undefined; + const interceptor = createAddressCheckInterceptor({ + ...baseConfig, + ...extraConfig, + onClassified: (result) => { + captured = result; + }, + }); + await runInterceptor(interceptor, input); + return captured; + } + + it('classifies DM as direct', async () => { + const result = await classify( + { message: 'hello', conversationId: 'c1' }, + { isDirectMessage: () => true } as Partial & { isDirectMessage: (i: AgentInput) => boolean } + ); + expect(result).toBe('direct'); + }); + + it('classifies vocative (greeting start) as direct', async () => { + expect(await classify({ message: 'hey kael, help me', conversationId: 'c1' })).toBe('direct'); + expect(await classify({ message: '@kael fix this', conversationId: 'c1' })).toBe('direct'); + expect(await classify({ message: 'kael, do it', conversationId: 'c1' })).toBe('direct'); + }); + + it('classifies possessive patterns as ambiguous (not direct)', async () => { + expect(await classify({ message: 'the kael mentioned earlier', conversationId: 'c1' })).toBe('ambiguous'); + expect(await classify({ message: 'our kael logged that', conversationId: 'c1' })).toBe('ambiguous'); + expect(await classify({ message: 'my kael is broken', conversationId: 'c1' })).toBe('ambiguous'); + }); + + it('classifies agent name only inside code block as ignore', async () => { + expect( + await classify({ message: 'see this: ```error in kael system```', conversationId: 'c1' }) + ).toBe('ignore'); + }); + + it('does NOT classify as ignore when name is addressed outside code', async () => { + // "hey kael" at the start is vocative → direct, regardless of code block below + expect( + await classify({ message: 'hey kael, here is: ```stack trace```', conversationId: 'c1' }) + ).toBe('direct'); + }); + + it('classifies URL-only messages as ignore', async () => { + expect(await classify({ message: 'https://example.com/doc', conversationId: 'c1' })).toBe('ignore'); + }); + + it('classifies simple name mention as ambiguous', async () => { + expect(await classify({ message: 'I was thinking about kael yesterday', conversationId: 'c1' })).toBe('ambiguous'); + }); + + it('classifies no-mention as passive', async () => { + expect(await classify({ message: 'just some chatter here', conversationId: 'c1' })).toBe('passive'); + }); + + it('classifies co-mentions as indirect', async () => { + // Message must not start with agent name (would match vocative rule as 'direct'). + const result = await classify( + { message: 'please loop in kael and bob on this', conversationId: 'c1' }, + { getMentions: () => ['kael', 'bob'] } as Partial & { getMentions: (i: AgentInput) => string[] } + ); + expect(result).toBe('indirect'); + }); + + it('enriches input context with _addressCheck', async () => { + const interceptor = createAddressCheckInterceptor(baseConfig); + + let capturedInput: AgentInput | undefined; + const downstream: Interceptor = async (input, _ctx, next) => { + capturedInput = input; + return await next(); + }; + + const agent = createMockAgent('kael'); + const chain = composeChain([interceptor, downstream], agent, createMockChannel(), createMockRegistry()); + await executeChain(chain, { message: 'hey kael', conversationId: 'c1' }); + + expect(capturedInput?.context?._addressCheck).toBe('direct'); + }); +}); + +// ---------- depth-guard ---------- + +describe('createDepthGuardInterceptor', () => { + it('allows invocations at or below maxDepth', async () => { + const interceptor = createDepthGuardInterceptor({ maxDepth: 5 }); + const { result, agent } = await runInterceptor(interceptor, { + message: 'hi', + conversationId: 'c1', + }); + expect(result).not.toBeNull(); + expect(agent.invokeAgent).toHaveBeenCalledTimes(1); + }); + + it('DepthExceededError carries current and max depth', () => { + const err = new DepthExceededError(7, 5); + expect(err.currentDepth).toBe(7); + expect(err.maxDepth).toBe(5); + expect(err.name).toBe('DepthExceededError'); + expect(err.message).toContain('7'); + expect(err.message).toContain('5'); + }); +}); + +// ---------- tracer ---------- + +describe('createTracerInterceptor', () => { + it('forwards input to next and preserves result', async () => { + const interceptor = createTracerInterceptor(); + const { result, agent } = await runInterceptor(interceptor, { + message: 'hi', + conversationId: 'c1', + }); + expect(result).not.toBeNull(); + expect(agent.invokeAgent).toHaveBeenCalledTimes(1); + }); + + it('propagates skip sentinel from downstream', async () => { + const tracer = createTracerInterceptor(); + const skipper: Interceptor = async (_input, ctx, _next) => ctx.skip(); + + const agent = createMockAgent('test-agent'); + const chain = composeChain([tracer, skipper], agent, createMockChannel(), createMockRegistry()); + const result = await executeChain(chain, { message: 'hi', conversationId: 'c1' }); + + expect(result).toBeNull(); + expect(agent.invokeAgent).not.toHaveBeenCalled(); + }); + + it('re-throws errors from downstream after logging', async () => { + const tracer = createTracerInterceptor(); + const thrower: Interceptor = async () => { + throw new Error('boom'); + }; + + const agent = createMockAgent('test-agent'); + const chain = composeChain([tracer, thrower], agent, createMockChannel(), createMockRegistry()); + + await expect( + executeChain(chain, { message: 'hi', conversationId: 'c1' }) + ).rejects.toThrow('boom'); + }); + + it('skips tracing when shouldTrace returns false', async () => { + const shouldTrace = vi.fn(() => false); + const tracer = createTracerInterceptor({ shouldTrace }); + const { result, agent } = await runInterceptor(tracer, { + message: 'hi', + conversationId: 'c1', + }); + expect(shouldTrace).toHaveBeenCalled(); + expect(result).not.toBeNull(); + expect(agent.invokeAgent).toHaveBeenCalledTimes(1); + }); +}); + +// ---------- intent-classifier ---------- + +describe('createIntentClassifierInterceptor', () => { + const baseConfig = { + agentName: 'kael', + agentId: 'U123', + getMessageText: (input: AgentInput) => input.message, + getSenderName: () => 'alice', + getChannelName: () => 'general', + }; + + /** Build a chain with the classifier interceptor and a registry containing a mock classifier agent. */ + function setup( + classifierResult: AgentResult, + configOverrides: Partial[0]> = {}, + classifierName = 'intent-classifier' + ) { + const classifierAgent = createMockAgent(classifierName, classifierResult); + const agents = new Map([[classifierName, classifierAgent]]); + + const interceptor = createIntentClassifierInterceptor({ ...baseConfig, ...configOverrides }); + const agent = createMockAgent('kael'); + const channel = createMockChannel(); + const registry = createMockRegistry(agents); + const chain = composeChain([interceptor], agent, channel, registry); + + return { chain, agent, classifierAgent, registry }; + } + + it('short-circuits when address-check is direct (no LLM call)', async () => { + const { chain, agent, classifierAgent } = setup({ output: 'direct' }); + const result = await executeChain(chain, { + message: 'hello', + conversationId: 'c1', + context: { _addressCheck: 'direct' }, + }); + + expect(result).not.toBeNull(); + expect(agent.invokeAgent).toHaveBeenCalledTimes(1); + expect(classifierAgent.invokeAgent).not.toHaveBeenCalled(); + }); + + it('short-circuits with skip when address-check is passive (no LLM call)', async () => { + const onClassified = vi.fn(); + const { chain, agent, classifierAgent } = setup({ output: 'direct' }, { onClassified }); + const result = await executeChain(chain, { + message: 'just chatter', + conversationId: 'c1', + context: { _addressCheck: 'passive' }, + }); + + expect(result).toBeNull(); + expect(agent.invokeAgent).not.toHaveBeenCalled(); + expect(classifierAgent.invokeAgent).not.toHaveBeenCalled(); + expect(onClassified).toHaveBeenCalledWith('passive', expect.anything()); + }); + + it('short-circuits with skip when address-check is ignore (no LLM call)', async () => { + const onClassified = vi.fn(); + const { chain, agent, classifierAgent } = setup({ output: 'direct' }, { onClassified }); + const result = await executeChain(chain, { + message: 'https://example.com', + conversationId: 'c1', + context: { _addressCheck: 'ignore' }, + }); + + expect(result).toBeNull(); + expect(agent.invokeAgent).not.toHaveBeenCalled(); + expect(classifierAgent.invokeAgent).not.toHaveBeenCalled(); + expect(onClassified).toHaveBeenCalledWith('ignore', expect.anything()); + }); + + it('delegates to classifier and continues when classification=direct', async () => { + const onClassified = vi.fn(); + const { chain, agent, classifierAgent } = setup({ output: 'direct' }, { onClassified }); + const result = await executeChain(chain, { + message: 'the kael issue', + conversationId: 'c1', + context: { _addressCheck: 'ambiguous' }, + }); + + expect(classifierAgent.invokeAgent).toHaveBeenCalledTimes(1); + expect(agent.invokeAgent).toHaveBeenCalledTimes(1); + expect(result).not.toBeNull(); + expect(onClassified).toHaveBeenCalledWith('direct', expect.anything()); + }); + + it('delegates to classifier and skips when classification=passive', async () => { + const { chain, agent, classifierAgent } = setup({ output: 'passive' }); + const result = await executeChain(chain, { + message: 'the kael issue', + conversationId: 'c1', + context: { _addressCheck: 'ambiguous' }, + }); + + expect(classifierAgent.invokeAgent).toHaveBeenCalledTimes(1); + expect(agent.invokeAgent).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it('delegates to classifier and skips when classification=ignore', async () => { + const { chain, agent } = setup({ output: 'ignore' }); + const result = await executeChain(chain, { + message: 'the kael issue', + conversationId: 'c1', + context: { _addressCheck: 'indirect' }, + }); + expect(agent.invokeAgent).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it('delegates to classifier and skips when classification=indirect', async () => { + const { chain, agent } = setup({ output: 'indirect' }); + const result = await executeChain(chain, { + message: 'the kael issue', + conversationId: 'c1', + context: { _addressCheck: 'ambiguous' }, + }); + expect(agent.invokeAgent).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it('enriches input with _intentClassification before calling agent', async () => { + const classifierAgent = createMockAgent('intent-classifier', { output: 'direct' }); + const agents = new Map([['intent-classifier', classifierAgent]]); + + const interceptor = createIntentClassifierInterceptor(baseConfig); + const agent = createMockAgent('kael'); + const registry = createMockRegistry(agents); + const chain = composeChain([interceptor], agent, createMockChannel(), registry); + + await executeChain(chain, { + message: 'the kael issue', + conversationId: 'c1', + context: { _addressCheck: 'ambiguous' }, + }); + + expect(agent.invokeAgent).toHaveBeenCalledTimes(1); + const forwarded = (agent.invokeAgent as ReturnType).mock.calls[0][0] as AgentInput; + expect(forwarded.context?._intentClassification).toBe('direct'); + // Original address-check context is preserved too + expect(forwarded.context?._addressCheck).toBe('ambiguous'); + }); + + it('falls back to allowing the message when classifier throws', async () => { + const brokenClassifier = createMockAgent('intent-classifier'); + (brokenClassifier.invokeAgent as ReturnType).mockRejectedValue(new Error('llm down')); + const agents = new Map([['intent-classifier', brokenClassifier]]); + + const interceptor = createIntentClassifierInterceptor(baseConfig); + const agent = createMockAgent('kael'); + const registry = createMockRegistry(agents); + const chain = composeChain([interceptor], agent, createMockChannel(), registry); + + const result = await executeChain(chain, { + message: 'the kael issue', + conversationId: 'c1', + context: { _addressCheck: 'ambiguous' }, + }); + + expect(brokenClassifier.invokeAgent).toHaveBeenCalledTimes(1); + expect(agent.invokeAgent).toHaveBeenCalledTimes(1); + expect(result).not.toBeNull(); + }); + + it('skips empty-message classification and passes through', async () => { + const { chain, agent, classifierAgent } = setup({ output: 'direct' }); + const result = await executeChain(chain, { + message: ' ', + conversationId: 'c1', + context: { _addressCheck: 'ambiguous' }, + }); + // Empty text path does NOT delegate, just calls next + expect(classifierAgent.invokeAgent).not.toHaveBeenCalled(); + expect(agent.invokeAgent).toHaveBeenCalledTimes(1); + expect(result).not.toBeNull(); + }); + + it('uses custom classifierAgentName when provided', async () => { + const custom = createMockAgent('my-classifier', { output: 'direct' }); + const agents = new Map([['my-classifier', custom]]); + + const interceptor = createIntentClassifierInterceptor({ + ...baseConfig, + classifierAgentName: 'my-classifier', + }); + const agent = createMockAgent('kael'); + const registry = createMockRegistry(agents); + const chain = composeChain([interceptor], agent, createMockChannel(), registry); + + await executeChain(chain, { + message: 'the kael issue', + conversationId: 'c1', + context: { _addressCheck: 'ambiguous' }, + }); + + expect(custom.invokeAgent).toHaveBeenCalledTimes(1); + }); + + it('passes message text, agent identity and sender/channel context to classifier', async () => { + const classifierAgent = createMockAgent('intent-classifier', { output: 'direct' }); + const agents = new Map([['intent-classifier', classifierAgent]]); + + const interceptor = createIntentClassifierInterceptor({ + ...baseConfig, + isDirectMessage: () => false, + }); + const agent = createMockAgent('kael'); + const registry = createMockRegistry(agents); + const chain = composeChain([interceptor], agent, createMockChannel(), registry); + + await executeChain(chain, { + message: 'hello kael team', + conversationId: 'conv-xyz', + context: { _addressCheck: 'ambiguous' }, + }); + + const delegated = (classifierAgent.invokeAgent as ReturnType).mock.calls[0][0] as AgentInput; + expect(delegated.conversationId).toBe('conv-xyz'); + expect(delegated.data).toMatchObject({ + message: 'hello kael team', + agentName: 'kael', + agentId: 'U123', + senderName: 'alice', + channelName: 'general', + isDirectMessage: false, + }); + }); +}); + +// ---------- regex-escape regression for address-check ---------- + +describe('createAddressCheckInterceptor - regex escaping', () => { + async function classify(agentName: string, message: string) { + let captured: AddressCheckResult | undefined; + const interceptor = createAddressCheckInterceptor({ + agentName, + agentId: 'U123', + getMessageText: (input) => input.message, + onClassified: (r) => { + captured = r; + }, + }); + await runInterceptor(interceptor, { message, conversationId: 'c1' }); + return captured; + } + + it('handles agent name with dot ("agent.v2") without throwing', async () => { + expect(await classify('agent.v2', 'hey agent.v2, help')).toBe('direct'); + // Dot should not match arbitrary char: "agentXv2" must not classify as direct + expect(await classify('agent.v2', 'hey agentXv2 how are you')).not.toBe('direct'); + }); + + it('does not treat "+" as regex quantifier for name "c++"', async () => { + // Without escaping, "c++" would be a regex meaning one+ 'c's. With escaping, + // the literal "ccc" must NOT match the literal name "c++". + expect(await classify('c++', 'hey ccc please')).not.toBe('direct'); + }); + + it('does not throw when agent name contains parentheses ("bot(dev)")', async () => { + // Unescaped, "bot(dev)" creates an invalid/misinterpreted capture group. + // We only assert construction + execution doesn't crash here. + await expect(classify('bot(dev)', 'unrelated message')).resolves.toBeDefined(); + await expect(classify('bot(dev)', 'the bot(dev) said hello')).resolves.toBeDefined(); + }); + + it('handles possessive pattern with special chars in name', async () => { + expect(await classify('agent.v2', 'the agent.v2 logged an issue')).toBe('ambiguous'); + }); + + it('does not throw when agentId contains regex metacharacters', async () => { + const interceptor = createAddressCheckInterceptor({ + agentName: 'kael', + agentId: 'U+special.id', + getMessageText: (input) => input.message, + }); + // Should not throw during RegExp construction + const { result } = await runInterceptor(interceptor, { + message: '@U+special.id help', + conversationId: 'c1', + }); + expect(result).not.toBeNull(); + }); +}); + +// ---------- sanity: SKIP_SENTINEL helpers cross-check ---------- + +describe('skip sentinel integration', () => { + it('isSkipSentinel identifies the skip symbol', () => { + expect(isSkipSentinel(SKIP_SENTINEL)).toBe(true); + expect(isSkipSentinel({ output: 'x' })).toBe(false); + }); +}); diff --git a/packages/toolpack-agents/src/interceptors/builtins/capture-history.test.ts b/packages/toolpack-agents/src/interceptors/builtins/capture-history.test.ts new file mode 100644 index 0000000..073d229 --- /dev/null +++ b/packages/toolpack-agents/src/interceptors/builtins/capture-history.test.ts @@ -0,0 +1,406 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createCaptureInterceptor } from './capture-history.js'; +import { InMemoryConversationStore } from '../../history/store.js'; +import { composeChain, executeChain } from '../chain.js'; +import { skip } from '../types.js'; +import type { Interceptor } from '../types.js'; + +// --------------------------------------------------------------------------- +// Shared test helpers (inline to avoid cross-file deps) +// --------------------------------------------------------------------------- + +function createMockAgent(name = 'kael') { + return { name, description: 'test agent', mode: 'chat' } as any; +} + +function createMockChannel() { + return { isTriggerChannel: false, name: 'test', send: vi.fn(), listen: vi.fn(), onMessage: vi.fn(), normalize: vi.fn() } as any; +} + +function createMockRegistry() { + return {} as any; +} + +/** Terminal agent invoker (replaces the real agent in tests). */ +function makeTerminal(output = 'agent reply'): Interceptor { + return async (_input, _ctx, _next) => ({ output, metadata: {} }); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('createCaptureInterceptor', () => { + it('writes the inbound message to the store before calling downstream', async () => { + const store = new InMemoryConversationStore(); + const interceptor = createCaptureInterceptor({ store }); + + const chain = composeChain( + [interceptor, makeTerminal()], + createMockAgent(), + createMockChannel(), + createMockRegistry() + ); + + await executeChain(chain, { + message: 'hello', + conversationId: 'conv-1', + participant: { kind: 'user', id: 'u1', displayName: 'Alice' }, + }); + + const stored = await store.get('conv-1'); + const userMsg = stored.find(m => m.participant.kind === 'user'); + expect(userMsg).toBeDefined(); + expect(userMsg?.content).toBe('hello'); + expect(userMsg?.participant.id).toBe('u1'); + }); + + it('writes the agent reply to the store after the chain resolves', async () => { + const store = new InMemoryConversationStore(); + const interceptor = createCaptureInterceptor({ store }); + + const chain = composeChain( + [interceptor, makeTerminal('agent reply text')], + createMockAgent('kael'), + createMockChannel(), + createMockRegistry() + ); + + await executeChain(chain, { + message: 'hello', + conversationId: 'conv-1', + participant: { kind: 'user', id: 'u1', displayName: 'Alice' }, + }); + + const stored = await store.get('conv-1'); + // Should have user message + agent reply + expect(stored).toHaveLength(2); + const agentMsg = stored.find(m => m.participant.kind === 'agent'); + expect(agentMsg?.content).toBe('agent reply text'); + expect(agentMsg?.participant.id).toBe('kael'); + }); + + it('does NOT write an agent reply when the chain returns a skip sentinel', async () => { + const store = new InMemoryConversationStore(); + const interceptor = createCaptureInterceptor({ store }); + + // Downstream skips (e.g. address-check decided to ignore) + const skipInterceptor: Interceptor = async (_input, _ctx, _next) => skip(); + + const chain = composeChain( + [interceptor, skipInterceptor], + createMockAgent(), + createMockChannel(), + createMockRegistry() + ); + + await executeChain(chain, { + message: 'Alice and Bob discussing lunch', + conversationId: 'conv-1', + participant: { kind: 'user', id: 'u1' }, + }); + + const stored = await store.get('conv-1'); + // Inbound message captured, but no agent reply + expect(stored).toHaveLength(1); + expect(stored[0].participant.kind).toBe('user'); + }); + + it('skips capture (but does not crash) when input has no conversationId', async () => { + const store = new InMemoryConversationStore(); + const interceptor = createCaptureInterceptor({ store }); + + const chain = composeChain( + [interceptor, makeTerminal()], + createMockAgent(), + createMockChannel(), + createMockRegistry() + ); + + // No conversationId — the interceptor should still call next() + const result = await executeChain(chain, { + message: 'orphan message', + participant: { kind: 'user', id: 'u1' }, + }); + + // Chain completed (result is not null) + expect(result).not.toBeNull(); + // Nothing written to any conversation + expect(await store.get('conv-1')).toHaveLength(0); + }); + + it('skips capture when input has no participant', async () => { + const store = new InMemoryConversationStore(); + const interceptor = createCaptureInterceptor({ store }); + + const chain = composeChain( + [interceptor, makeTerminal()], + createMockAgent(), + createMockChannel(), + createMockRegistry() + ); + + await executeChain(chain, { + message: 'anonymous message', + conversationId: 'conv-1', + // No participant + }); + + // With no participant we cannot attribute the message, so nothing written + const stored = await store.get('conv-1'); + const userMessages = stored.filter(m => m.participant.kind !== 'agent'); + expect(userMessages).toHaveLength(0); + }); + + it('calls onCaptured after each successful write', async () => { + const store = new InMemoryConversationStore(); + const onCaptured = vi.fn(); + const interceptor = createCaptureInterceptor({ store, onCaptured }); + + const chain = composeChain( + [interceptor, makeTerminal('reply')], + createMockAgent(), + createMockChannel(), + createMockRegistry() + ); + + await executeChain(chain, { + message: 'hello', + conversationId: 'conv-1', + participant: { kind: 'user', id: 'u1' }, + }); + + // Called once for inbound, once for agent reply + expect(onCaptured).toHaveBeenCalledTimes(2); + }); + + it('captureAgentReplies: false suppresses the agent reply write', async () => { + const store = new InMemoryConversationStore(); + const interceptor = createCaptureInterceptor({ store, captureAgentReplies: false }); + + const chain = composeChain( + [interceptor, makeTerminal('reply')], + createMockAgent(), + createMockChannel(), + createMockRegistry() + ); + + await executeChain(chain, { + message: 'hello', + conversationId: 'conv-1', + participant: { kind: 'user', id: 'u1' }, + }); + + const stored = await store.get('conv-1'); + expect(stored).toHaveLength(1); + expect(stored[0].participant.kind).toBe('user'); + }); + + it('infers scope as "dm" from channelType "im"', async () => { + const store = new InMemoryConversationStore(); + const interceptor = createCaptureInterceptor({ store }); + + const chain = composeChain( + [interceptor, makeTerminal()], + createMockAgent(), + createMockChannel(), + createMockRegistry() + ); + + await executeChain(chain, { + message: 'hey', + conversationId: 'conv-1', + participant: { kind: 'user', id: 'u1' }, + context: { channelType: 'im' }, + }); + + const stored = await store.get('conv-1'); + expect(stored[0].scope).toBe('dm'); + }); + + it('infers scope as "dm" from channelType "private" (Telegram DM)', async () => { + const store = new InMemoryConversationStore(); + const interceptor = createCaptureInterceptor({ store }); + + const chain = composeChain( + [interceptor, makeTerminal()], + createMockAgent(), + createMockChannel(), + createMockRegistry() + ); + + await executeChain(chain, { + message: 'hey', + conversationId: 'conv-1', + participant: { kind: 'user', id: 'u1' }, + context: { channelType: 'private' }, + }); + + const stored = await store.get('conv-1'); + expect(stored[0].scope).toBe('dm'); + }); + + it('infers scope as "dm" from channelType "dm" (Discord DM)', async () => { + const store = new InMemoryConversationStore(); + const interceptor = createCaptureInterceptor({ store }); + + const chain = composeChain( + [interceptor, makeTerminal()], + createMockAgent(), + createMockChannel(), + createMockRegistry() + ); + + await executeChain(chain, { + message: 'hey', + conversationId: 'conv-1', + participant: { kind: 'user', id: 'u1' }, + context: { channelType: 'dm' }, + }); + + const stored = await store.get('conv-1'); + expect(stored[0].scope).toBe('dm'); + }); + + it('infers scope as "channel" by default', async () => { + const store = new InMemoryConversationStore(); + const interceptor = createCaptureInterceptor({ store }); + + const chain = composeChain( + [interceptor, makeTerminal()], + createMockAgent(), + createMockChannel(), + createMockRegistry() + ); + + await executeChain(chain, { + message: 'hey', + conversationId: 'conv-1', + participant: { kind: 'user', id: 'u1' }, + }); + + const stored = await store.get('conv-1'); + expect(stored[0].scope).toBe('channel'); + }); + + it('infers scope as "thread" when context.threadId is present', async () => { + const store = new InMemoryConversationStore(); + const interceptor = createCaptureInterceptor({ store }); + + const chain = composeChain( + [interceptor, makeTerminal()], + createMockAgent(), + createMockChannel(), + createMockRegistry() + ); + + await executeChain(chain, { + message: 'threaded reply', + conversationId: 'thread-root-ts', + participant: { kind: 'user', id: 'u1' }, + context: { threadId: 'thread-root-ts' }, + }); + + const stored = await store.get('thread-root-ts'); + expect(stored[0].scope).toBe('thread'); + }); + + it('uses a custom getMessageId when provided', async () => { + const store = new InMemoryConversationStore(); + const interceptor = createCaptureInterceptor({ + store, + getMessageId: () => 'fixed-id', + }); + + const chain = composeChain( + [interceptor, makeTerminal()], + createMockAgent(), + createMockChannel(), + createMockRegistry() + ); + + await executeChain(chain, { + message: 'hello', + conversationId: 'conv-1', + participant: { kind: 'user', id: 'u1' }, + }); + + const stored = await store.get('conv-1'); + expect(stored[0].id).toBe('fixed-id'); + }); + + it('writes channelName and channelId into stored message metadata', async () => { + const store = new InMemoryConversationStore(); + const interceptor = createCaptureInterceptor({ store }); + + const chain = composeChain( + [interceptor, makeTerminal()], + createMockAgent(), + createMockChannel(), + createMockRegistry() + ); + + await executeChain(chain, { + message: 'hello', + conversationId: 'conv-1', + participant: { kind: 'user', id: 'u1' }, + context: { channelName: '#general', channelId: 'C123' }, + }); + + const stored = await store.get('conv-1'); + expect(stored[0].metadata?.channelName).toBe('#general'); + expect(stored[0].metadata?.channelId).toBe('C123'); + // agent reply also inherits channel metadata + expect(stored[1].metadata?.channelName).toBe('#general'); + expect(stored[1].metadata?.channelId).toBe('C123'); + }); + + it('captures agent reply with empty string output', async () => { + const store = new InMemoryConversationStore(); + const interceptor = createCaptureInterceptor({ store }); + + const chain = composeChain( + [interceptor, makeTerminal('')], + createMockAgent(), + createMockChannel(), + createMockRegistry() + ); + + await executeChain(chain, { + message: 'hello', + conversationId: 'conv-1', + participant: { kind: 'user', id: 'u1' }, + }); + + const stored = await store.get('conv-1'); + // Both inbound and reply should be stored + expect(stored).toHaveLength(2); + expect(stored[1].content).toBe(''); + expect(stored[1].participant.kind).toBe('agent'); + }); + + it('does not crash when the store throws on append', async () => { + const brokenStore = { + append: vi.fn().mockRejectedValue(new Error('DB error')), + get: vi.fn().mockResolvedValue([]), + search: vi.fn().mockResolvedValue([]), + }; + const interceptor = createCaptureInterceptor({ store: brokenStore }); + + const chain = composeChain( + [interceptor, makeTerminal()], + createMockAgent(), + createMockChannel(), + createMockRegistry() + ); + + // Should not throw even if the store is broken + await expect( + executeChain(chain, { + message: 'hello', + conversationId: 'conv-1', + participant: { kind: 'user', id: 'u1' }, + }) + ).resolves.not.toThrow(); + }); +}); diff --git a/packages/toolpack-agents/src/interceptors/builtins/capture-history.ts b/packages/toolpack-agents/src/interceptors/builtins/capture-history.ts new file mode 100644 index 0000000..29a7608 --- /dev/null +++ b/packages/toolpack-agents/src/interceptors/builtins/capture-history.ts @@ -0,0 +1,223 @@ +import { randomUUID } from 'crypto'; +import type { AgentInput } from '../../agent/types.js'; +import type { Interceptor, InterceptorContext, InterceptorResult, NextFunction } from '../types.js'; +import { isSkipSentinel } from '../types.js'; +import type { ConversationStore, ConversationScope, StoredMessage } from '../../history/types.js'; + +/** + * Configuration for the capture-history interceptor. + */ +export interface CaptureHistoryConfig { + /** + * The conversation store to write messages into. + * Typically `new InMemoryConversationStore()` for single-process deployments, + * or a database-backed adapter for production. + */ + store: ConversationStore; + + /** + * Derive the scope of an incoming message. + * Default: reads `input.context?.channelType` — `'im'` → `'dm'`, + * presence of `context?.threadId` → `'thread'`, otherwise `'channel'`. + */ + getScope?: (input: AgentInput) => ConversationScope; + + /** + * Derive a stable message id for dedup. + * Default: `input.context?.messageId ?? input.context?.eventId ?? randomUUID()`. + * Supply this if your channel puts the platform message id somewhere else. + */ + getMessageId?: (input: AgentInput) => string; + + /** + * Derive explicit @-mention ids from the message for addressed-only filtering. + * Default: `(input.context?.mentions as string[] | undefined) ?? []`. + */ + getMentions?: (input: AgentInput) => string[]; + + /** + * Called after a message is successfully written to the store. + * Useful for metrics or debug logging. + */ + onCaptured?: (message: StoredMessage) => void; + + /** + * When true, the interceptor also writes the agent's reply to the store + * as a `kind: 'agent'` turn after `next()` returns. + * Default: true. + */ + captureAgentReplies?: boolean; +} + +/** + * Resolve the scope of an incoming message from its context. + */ +function defaultGetScope(input: AgentInput): ConversationScope { + const ctx = input.context ?? {}; + + // Platform DM signals: + // Slack: channelType === 'im' + // Telegram: channelType === 'private' + // Discord: channelType === 'dm' + const channelType = ctx.channelType as string | undefined; + if (channelType === 'im' || channelType === 'private' || channelType === 'dm') { + return 'dm'; + } + + // If there is a threadId in context, treat this as a thread-scoped message. + // Channel adapters (e.g. SlackChannel.normalize) are responsible for only + // setting threadId when it is distinct from the conversationId, so no + // additional equality check is needed here. + if (ctx.threadId !== undefined) { + return 'thread'; + } + + return 'channel'; +} + +/** + * Creates a capture-history interceptor. + * + * **Purpose:** The capture stage runs for *every* allowed inbound message, + * regardless of whether the agent ends up replying. It writes the message to + * the `ConversationStore` so it is available as future context for the assembler. + * + * The interceptor wraps `next()`: + * 1. Before calling downstream: write the inbound user message. + * 2. After `next()` resolves (and the result is not a skip sentinel): write the + * agent's reply as a `kind: 'agent'` turn. This keeps the reply in the log + * automatically without any changes to agent code. + * + * **Placement:** Put this interceptor *after* `createParticipantResolverInterceptor` + * (so `input.participant` is already enriched) and *before* + * `createAddressCheckInterceptor` (so even ignored messages are captured). + * + * @example + * ```ts + * const store = new InMemoryConversationStore(); + * + * interceptors: [ + * createParticipantResolverInterceptor(), + * createCaptureInterceptor({ store }), // ← before address-check + * createAddressCheckInterceptor({ agentName: 'kael', ... }), + * createIntentClassifierInterceptor({ ... }), + * ] + * ``` + */ +/** + * Symbol stamped onto every interceptor function returned by `createCaptureInterceptor`. + * Used by `BaseAgent._bindChannel` to detect whether a capture interceptor has already + * been wired — preventing the auto-inserted one from duplicating an explicit one. + */ +export const CAPTURE_INTERCEPTOR_MARKER = Symbol.for('toolpack:capture-history'); + +export function createCaptureInterceptor(config: CaptureHistoryConfig): Interceptor { + // Resolve config options once at factory time — not per invocation. + const captureAgentReplies = config.captureAgentReplies ?? true; + const getScope = config.getScope ?? defaultGetScope; + const getMessageId = config.getMessageId ?? ((inp: AgentInput) => + (inp.context?.messageId as string | undefined) ?? + (inp.context?.eventId as string | undefined) ?? + randomUUID() + ); + const getMentions = config.getMentions ?? ((inp: AgentInput) => + (inp.context?.mentions as string[] | undefined) ?? [] + ); + + const interceptorFn = async (input: AgentInput, ctx: InterceptorContext, next: NextFunction): Promise => { + // --- Capture the inbound message --- + + const conversationId = input.conversationId; + + if (!conversationId) { + // No conversationId means we cannot key the message; skip capture + // but still let the chain continue. + ctx.logger?.warn('[capture-history] Message has no conversationId — skipping capture'); + return await next(); + } + + const participant = input.participant; + + if (participant) { + const inboundMessage: StoredMessage = { + id: getMessageId(input), + conversationId, + participant, + content: input.message ?? '', + timestamp: new Date().toISOString(), + scope: getScope(input), + metadata: { + channelType: input.context?.channelType as string | undefined, + threadId: input.context?.threadId as string | undefined, + messageId: input.context?.messageId as string | undefined, + mentions: getMentions(input), + channelName: input.context?.channelName as string | undefined, + channelId: input.context?.channelId as string | undefined, + }, + }; + + try { + await config.store.append(inboundMessage); + config.onCaptured?.(inboundMessage); + ctx.logger?.debug('[capture-history] Captured inbound message', { + messageId: inboundMessage.id, + participantId: participant.id, + conversationId, + }); + } catch (error) { + // Storage errors must never crash the pipeline. + ctx.logger?.warn('[capture-history] Failed to store inbound message', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + // --- Call downstream (trigger, address-check, agent, etc.) --- + + const result = await next(); + + // --- Capture the agent's reply (if it produced one) --- + + if (captureAgentReplies && !isSkipSentinel(result) && result.output != null) { + const agentParticipant = { + kind: 'agent' as const, + id: ctx.agent.name, + displayName: ctx.agent.name, + }; + + const replyMessage: StoredMessage = { + id: randomUUID(), + conversationId, + participant: agentParticipant, + content: result.output, + timestamp: new Date().toISOString(), + scope: getScope(input), + metadata: { + channelType: input.context?.channelType as string | undefined, + threadId: input.context?.threadId as string | undefined, + channelName: input.context?.channelName as string | undefined, + channelId: input.context?.channelId as string | undefined, + }, + }; + + try { + await config.store.append(replyMessage); + config.onCaptured?.(replyMessage); + ctx.logger?.debug('[capture-history] Captured agent reply', { + messageId: replyMessage.id, + agentId: ctx.agent.name, + conversationId, + }); + } catch (error) { + ctx.logger?.warn('[capture-history] Failed to store agent reply', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return result; + }; + + (interceptorFn as unknown as Record)[CAPTURE_INTERCEPTOR_MARKER] = true; + return interceptorFn; +} diff --git a/packages/toolpack-agents/src/interceptors/builtins/depth-guard.ts b/packages/toolpack-agents/src/interceptors/builtins/depth-guard.ts new file mode 100644 index 0000000..e80dcd9 --- /dev/null +++ b/packages/toolpack-agents/src/interceptors/builtins/depth-guard.ts @@ -0,0 +1,73 @@ +import type { AgentInput } from '../../agent/types.js'; +import type { Interceptor, InterceptorContext, InterceptorResult, NextFunction } from '../types.js'; + +/** + * Configuration for the depth guard interceptor. + */ +export interface DepthGuardConfig { + /** Maximum allowed invocation depth (default: 5) */ + maxDepth?: number; + + /** Optional callback when depth limit is exceeded */ + onDepthExceeded?: (currentDepth: number, maxDepth: number, input: AgentInput) => void; +} + +/** + * Error thrown when invocation depth exceeds the configured maximum. + */ +export class DepthExceededError extends Error { + constructor( + public readonly currentDepth: number, + public readonly maxDepth: number + ) { + super(`Maximum invocation depth exceeded: ${currentDepth} > ${maxDepth}`); + this.name = 'DepthExceededError'; + } +} + +/** + * Creates a depth guard interceptor. + * + * Enforces maximum invocation depth on delegate chains. + * This provides an early check before the actual delegation happens, + * complementing the depth tracking in the chain composer. + * + * **Limitation:** This interceptor checks `ctx.invocationDepth`, which is always 0 + * for top-level chain invocations. It only fires when a delegated agent (called via + * `ctx.delegateAndWait`) also has this interceptor and enters via the registry's + * interceptor chain. Since `ctx.delegateAndWait` calls `targetAgent.invokeAgent` + * directly (bypassing the chain), this interceptor is primarily belt-and-suspenders + * for future scenarios where delegated calls may route through the registry. + * + * The actual depth protection lives in `ctx.delegateAndWait`'s internal + * `nextDepth > maxDepth` check in the chain composer. + * + * @example + * ```ts + * const registry = new AgentRegistry([ + * { + * agent: MyAgent, + * channels: [slackChannel], + * interceptors: [ + * createDepthGuardInterceptor({ maxDepth: 5 }) + * ] + * } + * ]); + * ``` + */ +export function createDepthGuardInterceptor(config: DepthGuardConfig = {}): Interceptor { + const maxDepth = config.maxDepth ?? 5; + + return async (input: AgentInput, ctx: InterceptorContext, next: NextFunction): Promise => { + if (ctx.invocationDepth > maxDepth) { + config.onDepthExceeded?.(ctx.invocationDepth, maxDepth, input); + ctx.logger?.error(`Depth guard: invocation depth ${ctx.invocationDepth} exceeds maximum ${maxDepth}`, { + currentDepth: ctx.invocationDepth, + maxDepth, + }); + throw new DepthExceededError(ctx.invocationDepth, maxDepth); + } + + return await next(); + }; +} diff --git a/packages/toolpack-agents/src/interceptors/builtins/event-dedup.ts b/packages/toolpack-agents/src/interceptors/builtins/event-dedup.ts new file mode 100644 index 0000000..eb7a8d5 --- /dev/null +++ b/packages/toolpack-agents/src/interceptors/builtins/event-dedup.ts @@ -0,0 +1,98 @@ +import type { AgentInput } from '../../agent/types.js'; +import type { Interceptor, InterceptorContext, InterceptorResult, NextFunction } from '../types.js'; + +/** + * LRU cache for tracking seen event IDs. + * Simple Map-based implementation with size limit. + */ +class LRUCache { + private cache: Map = new Map(); + + constructor(private maxSize: number) {} + + has(key: string): boolean { + return this.cache.has(key); + } + + set(key: string, value: T): void { + // If key exists, delete it first to move to end (most recent) + if (this.cache.has(key)) { + this.cache.delete(key); + } + + // If at capacity, remove oldest (first item) + if (this.cache.size >= this.maxSize) { + const firstKey = this.cache.keys().next().value; + if (firstKey !== undefined) { + this.cache.delete(firstKey); + } + } + + this.cache.set(key, value); + } + + clear(): void { + this.cache.clear(); + } + + size(): number { + return this.cache.size; + } +} + +/** + * Configuration for the event deduplication interceptor. + */ +export interface EventDedupConfig { + /** Maximum number of event IDs to cache (default: 1000) */ + maxCacheSize?: number; + + /** Function to extract event ID from input. Defaults to input.conversationId */ + getEventId?: (input: AgentInput) => string | undefined; + + /** Optional callback when duplicate is detected */ + onDuplicate?: (eventId: string, input: AgentInput) => void; +} + +/** + * Creates an event deduplication interceptor. + * + * Drops duplicate events based on event ID (e.g., Slack retries, webhook redeliveries). + * Uses an LRU cache to track recently seen event IDs. + * + * @example + * ```ts + * const registry = new AgentRegistry([ + * { + * agent: MyAgent, + * channels: [slackChannel], + * interceptors: [ + * createEventDedupInterceptor({ maxCacheSize: 500 }) + * ] + * } + * ]); + * ``` + */ +export function createEventDedupInterceptor(config: EventDedupConfig = {}): Interceptor { + const maxCacheSize = config.maxCacheSize ?? 1000; + const getEventId = config.getEventId ?? ((input: AgentInput) => input.context?.eventId as string | undefined); + const seenEvents = new LRUCache(maxCacheSize); + + return async (input: AgentInput, ctx: InterceptorContext, next: NextFunction): Promise => { + const eventId = getEventId(input); + + if (eventId) { + if (seenEvents.has(eventId)) { + // Duplicate detected - skip silently + config.onDuplicate?.(eventId, input); + ctx.logger?.debug(`Event dedup: dropping duplicate event ${eventId}`, { eventId }); + return ctx.skip(); + } + + // Mark as seen + seenEvents.set(eventId, true); + } + + return await next(); + }; +} diff --git a/packages/toolpack-agents/src/interceptors/builtins/index.ts b/packages/toolpack-agents/src/interceptors/builtins/index.ts new file mode 100644 index 0000000..de38d4a --- /dev/null +++ b/packages/toolpack-agents/src/interceptors/builtins/index.ts @@ -0,0 +1,13 @@ +// Built-in interceptors shipped with the agents package +// All are opt-in via the registration list - none run unless explicitly listed + +export { createEventDedupInterceptor, type EventDedupConfig } from './event-dedup.js'; +export { createNoiseFilterInterceptor, type NoiseFilterConfig } from './noise-filter.js'; +export { createSelfFilterInterceptor, type SelfFilterConfig } from './self-filter.js'; +export { createRateLimitInterceptor, type RateLimitConfig } from './rate-limit.js'; +export { createParticipantResolverInterceptor, type ParticipantResolverConfig } from './participant-resolver.js'; +export { createCaptureInterceptor, CAPTURE_INTERCEPTOR_MARKER, type CaptureHistoryConfig } from './capture-history.js'; +export { createAddressCheckInterceptor, type AddressCheckConfig, type AddressCheckResult } from './address-check.js'; +export { createIntentClassifierInterceptor, type IntentClassifierInterceptorConfig } from './intent-classifier.js'; +export { createDepthGuardInterceptor, type DepthGuardConfig, DepthExceededError } from './depth-guard.js'; +export { createTracerInterceptor, type TracerConfig } from './tracer.js'; diff --git a/packages/toolpack-agents/src/interceptors/builtins/intent-classifier.ts b/packages/toolpack-agents/src/interceptors/builtins/intent-classifier.ts new file mode 100644 index 0000000..8d8a194 --- /dev/null +++ b/packages/toolpack-agents/src/interceptors/builtins/intent-classifier.ts @@ -0,0 +1,148 @@ +import type { AgentInput } from '../../agent/types.js'; +import type { Interceptor, InterceptorContext, InterceptorResult, NextFunction } from '../types.js'; +import type { IntentClassifierInput, IntentClassification } from '../../capabilities/index.js'; + +/** + * Configuration for the intent classifier interceptor. + */ +export interface IntentClassifierInterceptorConfig { + /** Name of the IntentClassifierAgent in the registry */ + classifierAgentName?: string; + + /** Function to extract message text from input */ + getMessageText: (input: AgentInput) => string | undefined; + + /** Agent's display name for classification context */ + agentName: string; + + /** Agent's unique ID */ + agentId: string; + + /** Sender name for classification context */ + getSenderName: (input: AgentInput) => string; + + /** Channel name for classification context */ + getChannelName: (input: AgentInput) => string; + + /** Check if this is a direct message */ + isDirectMessage?: (input: AgentInput) => boolean; + + /** Optional: Get recent context messages */ + getRecentContext?: (input: AgentInput) => Array<{ sender: string; content: string }>; + + /** Optional callback when classification is made */ + onClassified?: (classification: IntentClassification, input: AgentInput) => void; +} + +/** + * Creates an intent classifier interceptor. + * + * Delegates to the IntentClassifierAgent for ambiguous address-check cases. + * Should be placed AFTER the address-check interceptor. + * + * Only runs when address-check result is 'ambiguous' or 'indirect'. + * Skips response for 'passive' and 'ignore' classifications. + * + * @example + * ```ts + * const registry = new AgentRegistry([ + * { + * agent: MyAgent, + * channels: [slackChannel], + * interceptors: [ + * createAddressCheckInterceptor({ agentName: 'Assistant', ... }), + * createIntentClassifierInterceptor({ + * agentName: 'Assistant', + * agentId: 'U123456', + * getMessageText: (input) => input.message || '', + * getSenderName: (input) => input.context?.userName as string || 'Unknown', + * getChannelName: (input) => input.context?.channelName as string || 'general', + * classifierAgentName: 'intent-classifier' // capability agent name + * }) + * ] + * } + * ]); + * ``` + */ +export function createIntentClassifierInterceptor(config: IntentClassifierInterceptorConfig): Interceptor { + const classifierAgentName = config.classifierAgentName ?? 'intent-classifier'; + + return async (input: AgentInput, ctx: InterceptorContext, next: NextFunction): Promise => { + // Check if address-check already determined this is direct + const addressCheck = input.context?._addressCheck as string | undefined; + + // If clearly direct, no need to classify + if (addressCheck === 'direct') { + return await next(); + } + + // If clearly ignore or passive, skip silently (no LLM call needed) + if (addressCheck === 'ignore' || addressCheck === 'passive') { + config.onClassified?.(addressCheck as 'ignore' | 'passive', input); + return ctx.skip(); + } + + // For ambiguous or indirect - run intent classifier to determine if agent should respond + const messageText = config.getMessageText(input) ?? ''; + + // Skip empty messages + if (!messageText.trim()) { + return await next(); + } + + // Build classifier input + const classifierInput: IntentClassifierInput = { + message: messageText, + agentName: config.agentName, + agentId: config.agentId, + senderName: config.getSenderName(input), + channelName: config.getChannelName(input), + isDirectMessage: config.isDirectMessage?.(input) ?? false, + recentContext: config.getRecentContext?.(input), + }; + + try { + // Delegate to intent classifier agent + const classifierResult = await ctx.delegateAndWait(classifierAgentName, { + message: 'classify', + data: classifierInput, + conversationId: input.conversationId, + }); + + // Parse classification from result + const classification = (classifierResult.output as string).trim() as IntentClassification; + + config.onClassified?.(classification, input); + ctx.logger?.debug(`Intent classified as: ${classification}`, { classification }); + + // Enrich input with classification + const enrichedInput: AgentInput = { + ...input, + context: { + ...input.context, + _intentClassification: classification, + }, + }; + + // Handle classification result + switch (classification) { + case 'direct': + // Continue to agent + return await next(enrichedInput); + + case 'indirect': + case 'passive': + case 'ignore': + default: + // Don't respond + return ctx.skip(); + } + } catch (error) { + // If classification fails, fall back to allowing the message + ctx.logger?.error('Intent classification failed, allowing message', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + return await next(); + } + }; +} diff --git a/packages/toolpack-agents/src/interceptors/builtins/noise-filter.ts b/packages/toolpack-agents/src/interceptors/builtins/noise-filter.ts new file mode 100644 index 0000000..51d07a4 --- /dev/null +++ b/packages/toolpack-agents/src/interceptors/builtins/noise-filter.ts @@ -0,0 +1,55 @@ +import type { AgentInput } from '../../agent/types.js'; +import type { Interceptor, InterceptorContext, InterceptorResult, NextFunction } from '../types.js'; + +/** + * Configuration for the noise filter interceptor. + */ +export interface NoiseFilterConfig { + /** List of subtypes to drop (e.g., ['message_changed', 'message_deleted']) */ + denySubtypes: string[]; + + /** Optional function to extract subtype from input */ + getSubtype?: (input: AgentInput) => string | undefined; + + /** Optional callback when noise is filtered */ + onFiltered?: (subtype: string, input: AgentInput) => void; +} + +/** + * Creates a noise filter interceptor. + * + * Drops messages whose subtype is in the deny-list. + * Useful for filtering out message edits, deletions, bot messages, etc. + * + * @example + * ```ts + * const registry = new AgentRegistry([ + * { + * agent: MyAgent, + * channels: [slackChannel], + * interceptors: [ + * createNoiseFilterInterceptor({ + * denySubtypes: ['message_changed', 'message_deleted', 'bot_message'] + * }) + * ] + * } + * ]); + * ``` + */ +export function createNoiseFilterInterceptor(config: NoiseFilterConfig): Interceptor { + const getSubtype = config.getSubtype ?? ((input: AgentInput) => input.context?.subtype as string | undefined); + const denySet = new Set(config.denySubtypes); + + return async (input: AgentInput, ctx: InterceptorContext, next: NextFunction): Promise => { + const subtype = getSubtype(input); + + if (subtype && denySet.has(subtype)) { + // Message subtype is in deny-list - skip silently + config.onFiltered?.(subtype, input); + ctx.logger?.debug(`Noise filter: dropping message with subtype "${subtype}"`, { subtype }); + return ctx.skip(); + } + + return await next(); + }; +} diff --git a/packages/toolpack-agents/src/interceptors/builtins/participant-resolver.ts b/packages/toolpack-agents/src/interceptors/builtins/participant-resolver.ts new file mode 100644 index 0000000..bfe7546 --- /dev/null +++ b/packages/toolpack-agents/src/interceptors/builtins/participant-resolver.ts @@ -0,0 +1,112 @@ +import type { AgentInput, Participant } from '../../agent/types.js'; +import type { Interceptor, InterceptorContext, InterceptorResult, NextFunction } from '../types.js'; + +/** + * Configuration for the participant resolver interceptor. + */ +export interface ParticipantResolverConfig { + /** + * Explicit resolver function. Takes precedence over the channel's + * `resolveParticipant` hook when provided. + * + * If omitted, the interceptor will call `ctx.channel.resolveParticipant` + * if the channel defines one. If neither is available, the input's + * existing `participant` field (populated by `channel.normalize()`) is + * passed through unchanged. + */ + resolveParticipant?: (input: AgentInput) => Participant | undefined | Promise; + + /** + * Optional callback fired after a participant is resolved (from any + * source, including `channel.normalize()`). + */ + onResolved?: (input: AgentInput, participant: Participant) => void; +} + +/** + * Creates a participant resolver interceptor. + * + * Resolves the participant from input and enriches the input with participant + * information for downstream interceptors. This is a foundational interceptor + * that should typically be placed early in the chain so downstream interceptors + * have access to participant context. + * + * Note: This interceptor does NOT write to conversation history. It only + * enriches the input with participant metadata. History persistence must be + * handled separately by the application layer or a future history interceptor. + * + * @example + * ```ts + * const registry = new AgentRegistry([ + * { + * agent: MyAgent, + * channels: [slackChannel], + * interceptors: [ + * createParticipantResolverInterceptor({ + * resolveParticipant: (input) => ({ + * kind: 'user', + * id: input.context?.userId as string, + * displayName: input.context?.userName as string + * }), + * onResolved: (input, participant) => { + * // Optionally persist to your own history store + * historyStore.append({ participant, message: input.message }); + * } + * }) + * ] + * } + * ]); + * ``` + */ +export function createParticipantResolverInterceptor( + config: ParticipantResolverConfig = {} +): Interceptor { + return async (input: AgentInput, ctx: InterceptorContext, next: NextFunction): Promise => { + // Resolution order: + // 1. Explicit `config.resolveParticipant` + // 2. Channel's own `resolveParticipant` hook (lazy, cached) + // 3. Whatever the channel already placed on `input.participant` + let resolved: Participant | undefined; + + if (config.resolveParticipant) { + resolved = await config.resolveParticipant(input); + } else if (typeof ctx.channel.resolveParticipant === 'function') { + try { + resolved = await ctx.channel.resolveParticipant(input); + } catch (error) { + // Resolver must never crash the pipeline - log and fall through. + ctx.logger?.warn('Channel resolveParticipant threw; falling back', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + // Merge resolved participant over whatever normalize() already set, + // so a later lookup of `displayName` takes precedence over id-only. + const participant = resolved ?? input.participant; + + if (participant) { + const enrichedInput: AgentInput = { + ...input, + participant, + // Keep legacy context slot populated for back-compat with older + // interceptors that read `context._participant`. + context: { + ...input.context, + _participant: participant, + }, + }; + + config.onResolved?.(enrichedInput, participant); + ctx.logger?.debug('Resolved participant', { + participantId: participant.id, + participantKind: participant.kind, + }); + + return await next(enrichedInput); + } + + // Nothing to enrich - pass through unchanged. + return await next(); + }; +} diff --git a/packages/toolpack-agents/src/interceptors/builtins/rate-limit.ts b/packages/toolpack-agents/src/interceptors/builtins/rate-limit.ts new file mode 100644 index 0000000..bd8c27c --- /dev/null +++ b/packages/toolpack-agents/src/interceptors/builtins/rate-limit.ts @@ -0,0 +1,170 @@ +import type { AgentInput } from '../../agent/types.js'; +import type { Interceptor, InterceptorContext, InterceptorResult, NextFunction } from '../types.js'; + +/** + * LRU cache for rate limit buckets. + * Prevents unbounded memory growth in high-traffic scenarios. + */ +class LRUCache { + private cache: Map = new Map(); + + constructor(private maxSize: number) {} + + get(key: string): T | undefined { + const value = this.cache.get(key); + if (value !== undefined) { + // Move to end (most recently used) + this.cache.delete(key); + this.cache.set(key, value); + } + return value; + } + + set(key: string, value: T): void { + // If key exists, delete it first to move to end + if (this.cache.has(key)) { + this.cache.delete(key); + } else if (this.cache.size >= this.maxSize) { + // Remove oldest (first item) + const firstKey = this.cache.keys().next().value; + if (firstKey !== undefined) { + this.cache.delete(firstKey); + } + } + this.cache.set(key, value); + } + + clear(): void { + this.cache.clear(); + } + + size(): number { + return this.cache.size; + } +} + +/** + * Token bucket for rate limiting. + */ +class TokenBucket { + private tokens: number; + private lastRefill: number; + + constructor( + private capacity: number, + private refillRate: number, // tokens per second + private refillInterval: number // milliseconds + ) { + this.tokens = capacity; + this.lastRefill = Date.now(); + } + + consume(tokens: number = 1): boolean { + this.refill(); + + if (this.tokens >= tokens) { + this.tokens -= tokens; + return true; + } + + return false; + } + + private refill(): void { + const now = Date.now(); + const elapsed = now - this.lastRefill; + const tokensToAdd = Math.floor((elapsed / this.refillInterval) * this.refillRate); + + if (tokensToAdd > 0) { + this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd); + this.lastRefill = now; + } + } + + getTokens(): number { + this.refill(); + return this.tokens; + } +} + +/** + * Configuration for the rate limit interceptor. + */ +export interface RateLimitConfig { + /** Tokens per interval (default: 10) */ + tokensPerInterval?: number; + + /** Interval in milliseconds (default: 60000 = 1 minute) */ + interval?: number; + + /** Maximum number of buckets to cache (default: 1000). LRU eviction when exceeded. */ + maxBuckets?: number; + + /** Function to extract rate limit key from input (e.g., user ID, conversation ID) */ + getKey: (input: AgentInput) => string; + + /** Behavior when rate limit exceeded: 'skip' silently or 'reject' with error (default: 'skip') */ + onExceeded?: 'skip' | 'reject'; + + /** Optional callback when rate limit is hit */ + onRateLimited?: (key: string, input: AgentInput) => void; +} + +/** + * Creates a rate limit interceptor. + * + * Token-bucket rate limiting per user or conversation. + * Skips or rejects when rate exceeded. + * + * @example + * ```ts + * const registry = new AgentRegistry([ + * { + * agent: MyAgent, + * channels: [slackChannel], + * interceptors: [ + * createRateLimitInterceptor({ + * getKey: (input) => input.context?.userId as string || 'default', + * tokensPerInterval: 5, + * interval: 60000, // 5 messages per minute per user + * onExceeded: 'skip' + * }) + * ] + * } + * ]); + * ``` + */ +export function createRateLimitInterceptor(config: RateLimitConfig): Interceptor { + const tokensPerInterval = config.tokensPerInterval ?? 10; + const interval = config.interval ?? 60000; + const maxBuckets = config.maxBuckets ?? 1000; + const onExceeded = config.onExceeded ?? 'skip'; + + // LRU bucket cache per key to prevent unbounded memory growth + const buckets = new LRUCache(maxBuckets); + + return async (input: AgentInput, ctx: InterceptorContext, next: NextFunction): Promise => { + const key = config.getKey(input); + + // Get or create bucket for this key + let bucket = buckets.get(key); + if (!bucket) { + bucket = new TokenBucket(tokensPerInterval, tokensPerInterval, interval); + buckets.set(key, bucket); + } + + if (!bucket.consume()) { + // Rate limit exceeded + config.onRateLimited?.(key, input); + ctx.logger?.warn(`Rate limit exceeded for key: ${key}`, { key }); + + if (onExceeded === 'reject') { + throw new Error(`Rate limit exceeded. Please try again later.`); + } + + return ctx.skip(); + } + + return await next(); + }; +} diff --git a/packages/toolpack-agents/src/interceptors/builtins/self-filter.ts b/packages/toolpack-agents/src/interceptors/builtins/self-filter.ts new file mode 100644 index 0000000..5c9b826 --- /dev/null +++ b/packages/toolpack-agents/src/interceptors/builtins/self-filter.ts @@ -0,0 +1,57 @@ +import type { AgentInput } from '../../agent/types.js'; +import type { Interceptor, InterceptorContext, InterceptorResult, NextFunction } from '../types.js'; + +/** + * Configuration for the self filter interceptor. + */ +export interface SelfFilterConfig { + /** + * Platform-specific agent ID (e.g., Slack user ID "U123456"). + * If not provided, falls back to the agent's registered name. + */ + agentId?: string; + + /** Function to extract sender ID from input */ + getSenderId: (input: AgentInput) => string | undefined; + + /** Optional callback when self-message is detected */ + onSelfMessage?: (senderId: string, input: AgentInput) => void; +} + +/** + * Creates a self filter interceptor (loop guard). + * + * Drops messages where the sender ID equals the agent's own ID. + * Prevents infinite loops where the agent responds to its own messages. + * + * @example + * ```ts + * const registry = new AgentRegistry([ + * { + * agent: MyAgent, + * channels: [slackChannel], + * interceptors: [ + * createSelfFilterInterceptor({ + * agentId: 'U0123456', // Slack bot user ID + * getSenderId: (input) => input.context?.senderId as string + * }) + * ] + * } + * ]); + * ``` + */ +export function createSelfFilterInterceptor(config: SelfFilterConfig): Interceptor { + return async (input: AgentInput, ctx: InterceptorContext, next: NextFunction): Promise => { + const senderId = config.getSenderId(input); + const agentId = config.agentId ?? ctx.agent.name; // Use platform ID if provided, else agent name + + if (senderId && senderId === agentId) { + // Message is from self - skip to prevent loop + config.onSelfMessage?.(senderId, input); + ctx.logger?.debug(`Self filter: dropping self-message from ${senderId}`, { senderId, agentId }); + return ctx.skip(); + } + + return await next(); + }; +} diff --git a/packages/toolpack-agents/src/interceptors/builtins/tracer.ts b/packages/toolpack-agents/src/interceptors/builtins/tracer.ts new file mode 100644 index 0000000..26607bb --- /dev/null +++ b/packages/toolpack-agents/src/interceptors/builtins/tracer.ts @@ -0,0 +1,117 @@ +import type { AgentInput } from '../../agent/types.js'; +import type { Interceptor, InterceptorContext, InterceptorResult, NextFunction } from '../types.js'; +import { isSkipSentinel } from '../types.js'; + +/** + * Configuration for the tracer interceptor. + */ +export interface TracerConfig { + /** + * Log level for tracing (default: 'debug') + */ + level?: 'debug' | 'info'; + + /** + * Whether to include full input data in logs (default: false) + */ + includeInputData?: boolean; + + /** + * Whether to include full result output in logs (default: false) + */ + includeResultOutput?: boolean; + + /** + * Optional: Filter which inputs to trace + */ + shouldTrace?: (input: AgentInput) => boolean; +} + +/** + * Creates a tracer interceptor. + * + * Structured logging of each hop for debugging. + * Logs entry (before calling next) and exit (after receiving result). + * + * @example + * ```ts + * const registry = new AgentRegistry([ + * { + * agent: MyAgent, + * channels: [slackChannel], + * interceptors: [ + * createTracerInterceptor({ level: 'debug', includeInputData: true }) + * ] + * } + * ]); + * ``` + */ +export function createTracerInterceptor(config: TracerConfig = {}): Interceptor { + const level = config.level ?? 'debug'; + const includeInputData = config.includeInputData ?? false; + const includeResultOutput = config.includeResultOutput ?? false; + + return async (input: AgentInput, ctx: InterceptorContext, next: NextFunction): Promise => { + // Check if we should trace this input + if (config.shouldTrace && !config.shouldTrace(input)) { + return await next(); + } + + const logMethod = level === 'info' ? ctx.logger?.info : ctx.logger?.debug; + + // Log entry + logMethod?.('Interceptor entry', { + agent: ctx.agent.name, + channel: ctx.channel.name, + depth: ctx.invocationDepth, + conversationId: input.conversationId, + intent: input.intent, + input: includeInputData ? input : undefined, + }); + + const startTime = performance.now(); + + try { + const result = await next(); + const duration = performance.now() - startTime; + + // Log exit + if (isSkipSentinel(result)) { + logMethod?.('Interceptor exit: skipped', { + agent: ctx.agent.name, + channel: ctx.channel.name, + depth: ctx.invocationDepth, + conversationId: input.conversationId, + durationMs: duration.toFixed(2), + }); + } else { + logMethod?.('Interceptor exit: success', { + agent: ctx.agent.name, + channel: ctx.channel.name, + depth: ctx.invocationDepth, + conversationId: input.conversationId, + durationMs: duration.toFixed(2), + outputLength: result.output.length, + result: includeResultOutput ? result : undefined, + }); + } + + return result; + } catch (error) { + const duration = performance.now() - startTime; + + // Log error + ctx.logger?.error('Interceptor exit: error', { + agent: ctx.agent.name, + channel: ctx.channel.name, + depth: ctx.invocationDepth, + conversationId: input.conversationId, + durationMs: duration.toFixed(2), + error: error instanceof Error ? error.message : 'Unknown error', + errorType: error?.constructor?.name, + }); + + throw error; + } + }; +} diff --git a/packages/toolpack-agents/src/interceptors/chain.test.ts b/packages/toolpack-agents/src/interceptors/chain.test.ts new file mode 100644 index 0000000..8eee032 --- /dev/null +++ b/packages/toolpack-agents/src/interceptors/chain.test.ts @@ -0,0 +1,555 @@ +import { describe, it, expect, vi } from 'vitest'; +import type { AgentInput, AgentResult, AgentInstance, ChannelInterface } from '../agent/types.js'; +import type { IAgentRegistry } from '../agent/types.js'; +import { + type Interceptor, + type InterceptorContext, + SKIP_SENTINEL, + skip, + isSkipSentinel, +} from './types.js'; +import { + composeChain, + executeChain, + InvocationDepthExceededError, +} from './chain.js'; + +// Mock agent +function createMockAgent(name: string, result: AgentResult): AgentInstance { + return { + name, + description: `Mock ${name}`, + mode: 'chat', + invokeAgent: vi.fn().mockResolvedValue(result), + } as unknown as AgentInstance; +} + +// Mock channel +function createMockChannel(name: string): ChannelInterface { + return { + name, + isTriggerChannel: false, + listen: vi.fn(), + send: vi.fn().mockResolvedValue(undefined), + normalize: vi.fn(), + onMessage: vi.fn(), + }; +} + +// Mock registry +function createMockRegistry(agents: Map): IAgentRegistry { + return { + start: vi.fn(), + sendTo: vi.fn().mockResolvedValue(undefined), + getAgent: vi.fn((name: string) => agents.get(name)), + getAllAgents: vi.fn(() => Array.from(agents.values())), + getPendingAsk: vi.fn(), + addPendingAsk: vi.fn(), + resolvePendingAsk: vi.fn().mockResolvedValue(undefined), + hasPendingAsks: vi.fn(), + incrementRetries: vi.fn(), + cleanupExpiredAsks: vi.fn().mockReturnValue(0), + } as unknown as IAgentRegistry; +} + +describe('Interceptor Chain', () => { + const baseInput: AgentInput = { + message: 'Hello', + conversationId: 'conv-1', + }; + + const baseResult: AgentResult = { + output: 'Hi there!', + }; + + describe('composeChain', () => { + it('invokes agent directly when no interceptors', async () => { + const agent = createMockAgent('test', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map()); + + const chain = composeChain([], agent, channel, registry); + const result = await chain.execute(baseInput); + + expect(agent.invokeAgent).toHaveBeenCalledWith(baseInput); + expect(result).toEqual(baseResult); + }); + + it('executes single interceptor in order', async () => { + const agent = createMockAgent('test', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map()); + + const order: string[] = []; + + const interceptor: Interceptor = async (input, ctx, next) => { + order.push('interceptor-in'); + const result = await next(); + order.push('interceptor-out'); + return result; + }; + + const chain = composeChain([interceptor], agent, channel, registry); + await chain.execute(baseInput); + + expect(order).toEqual(['interceptor-in', 'interceptor-out']); + }); + + it('executes multiple interceptors in correct order (outermost first)', async () => { + const agent = createMockAgent('test', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map()); + + const order: string[] = []; + + const interceptor1: Interceptor = async (input, ctx, next) => { + order.push('outer-in'); + const result = await next(); + order.push('outer-out'); + return result; + }; + + const interceptor2: Interceptor = async (input, ctx, next) => { + order.push('inner-in'); + const result = await next(); + order.push('inner-out'); + return result; + }; + + const chain = composeChain([interceptor1, interceptor2], agent, channel, registry); + await chain.execute(baseInput); + + // First interceptor is outermost: runs first in, last out + expect(order).toEqual(['outer-in', 'inner-in', 'inner-out', 'outer-out']); + }); + + it('allows interceptor to short-circuit by not calling next', async () => { + const agent = createMockAgent('test', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map()); + + const shortCircuitResult: AgentResult = { + output: 'Short-circuited!', + metadata: { shortCircuited: true }, + }; + + const interceptor: Interceptor = async (input, ctx, next) => { + // Don't call next - return early + return shortCircuitResult; + }; + + const chain = composeChain([interceptor], agent, channel, registry); + const result = await chain.execute(baseInput); + + expect(agent.invokeAgent).not.toHaveBeenCalled(); + expect(result).toEqual(shortCircuitResult); + }); + + it('provides correct context to interceptor', async () => { + const agent = createMockAgent('test', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map()); + + let capturedCtx: InterceptorContext | undefined; + + const interceptor: Interceptor = async (input, ctx, next) => { + capturedCtx = ctx; + return await next(); + }; + + const chain = composeChain([interceptor], agent, channel, registry); + await chain.execute(baseInput); + + expect(capturedCtx).toBeDefined(); + expect(capturedCtx!.agent).toBe(agent); + expect(capturedCtx!.channel).toBe(channel); + expect(capturedCtx!.registry).toBe(registry); + expect(capturedCtx!.invocationDepth).toBe(0); + expect(typeof capturedCtx!.delegateAndWait).toBe('function'); + expect(typeof capturedCtx!.skip).toBe('function'); + }); + + it('initial invocation depth is 0 at top-level', async () => { + const agent = createMockAgent('test', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map()); + + let capturedDepth: number | undefined; + + const interceptor: Interceptor = async (input, ctx, next) => { + capturedDepth = ctx.invocationDepth; + return await next(); + }; + + const chain = composeChain([interceptor], agent, channel, registry); + await chain.execute(baseInput); + + // Top-level chain starts at depth 0 + expect(capturedDepth).toBe(0); + }); + + it('delegateAndWait increments depth for nested delegation', async () => { + // This test verifies that when an interceptor at depth 0 calls delegateAndWait, + // the delegated agent is invoked with the next depth level (1) + const delegateResult: AgentResult = { output: 'Delegated!' }; + const delegateAgent = createMockAgent('delegate', delegateResult); + const mainAgent = createMockAgent('main', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map([['delegate', delegateAgent]])); + + // Track the depth check in delegateAndWait + let depthCheckPassed = false; + + const interceptor: Interceptor = async (input, ctx, next) => { + // At depth 0, try to delegate - this should use depth 1 for the check + if (ctx.invocationDepth === 0) { + await ctx.delegateAndWait('delegate', { message: 'test' }); + depthCheckPassed = true; + } + return await next(); + }; + + const chain = composeChain([interceptor], mainAgent, channel, registry, { + maxInvocationDepth: 5, // Allow up to depth 5 + }); + + await chain.execute(baseInput); + + // Delegation succeeded at depth 1 (0 + 1) + expect(depthCheckPassed).toBe(true); + expect(delegateAgent.invokeAgent).toHaveBeenCalled(); + }); + }); + + describe('skip sentinel', () => { + it('supports skip() helper from context', async () => { + const agent = createMockAgent('test', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map()); + + const interceptor: Interceptor = async (input, ctx, next) => { + return ctx.skip(); + }; + + const chain = composeChain([interceptor], agent, channel, registry); + const result = await chain.execute(baseInput); + + expect(result).toBe(SKIP_SENTINEL); + expect(isSkipSentinel(result)).toBe(true); + expect(agent.invokeAgent).not.toHaveBeenCalled(); + }); + + it('skip() returns SKIP_SENTINEL symbol', () => { + expect(skip()).toBe(SKIP_SENTINEL); + }); + + it('isSkipSentinel correctly identifies sentinel', () => { + expect(isSkipSentinel(SKIP_SENTINEL)).toBe(true); + expect(isSkipSentinel({ output: 'test' })).toBe(false); + expect(isSkipSentinel(null as unknown as typeof SKIP_SENTINEL)).toBe(false); + expect(isSkipSentinel(undefined as unknown as typeof SKIP_SENTINEL)).toBe(false); + }); + }); + + describe('executeChain helper', () => { + it('returns AgentResult when not skipped', async () => { + const agent = createMockAgent('test', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map()); + + const chain = composeChain([], agent, channel, registry); + const result = await executeChain(chain, baseInput); + + expect(result).toEqual(baseResult); + }); + + it('returns null when skipped', async () => { + const agent = createMockAgent('test', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map()); + + const interceptor: Interceptor = async (input, ctx, next) => { + return ctx.skip(); + }; + + const chain = composeChain([interceptor], agent, channel, registry); + const result = await executeChain(chain, baseInput); + + expect(result).toBeNull(); + }); + }); + + describe('delegation', () => { + it('delegateAndWait invokes target agent', async () => { + const delegateResult: AgentResult = { output: 'Delegated result!' }; + const delegateAgent = createMockAgent('delegate', delegateResult); + const mainAgent = createMockAgent('main', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map([['delegate', delegateAgent]])); + + let delegatedResult: AgentResult | undefined; + + const interceptor: Interceptor = async (input, ctx, next) => { + delegatedResult = await ctx.delegateAndWait('delegate', { + message: 'Please help', + data: { extra: 'context' }, + }); + return await next(); + }; + + const chain = composeChain([interceptor], mainAgent, channel, registry); + await chain.execute(baseInput); + + expect(delegateAgent.invokeAgent).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Please help', + data: { extra: 'context' }, + // Inherits conversationId from original input (baseInput.conversationId = 'conv-1') + conversationId: 'conv-1', + }) + ); + expect(delegatedResult).toEqual(delegateResult); + }); + + it('throws when delegating to non-existent agent', async () => { + const mainAgent = createMockAgent('main', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map()); // empty + + const interceptor: Interceptor = async (input, ctx, next) => { + await ctx.delegateAndWait('nonexistent', { message: 'test' }); + return await next(); + }; + + const chain = composeChain([interceptor], mainAgent, channel, registry); + + await expect(chain.execute(baseInput)).rejects.toThrow( + 'Agent "nonexistent" not found for delegation' + ); + }); + + it('throws InvocationDepthExceededError when past max depth', async () => { + const agent = createMockAgent('test', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map()); + + let error: Error | undefined; + + const interceptor: Interceptor = async (input, ctx, next) => { + try { + // Try to delegate at max depth + await ctx.delegateAndWait('test', { message: 'test' }); + } catch (e) { + error = e as Error; + } + return await next(); + }; + + const chain = composeChain([interceptor], agent, channel, registry, { + maxInvocationDepth: 0, // Zero tolerance + }); + await chain.execute(baseInput); + + expect(error).toBeInstanceOf(InvocationDepthExceededError); + // At depth 0, trying to delegate results in nextDepth=1, which exceeds maxDepth=0 + expect(error?.message).toContain('Invocation depth 1 exceeds maximum 0'); + }); + + it('inherits conversationId from original input when delegating', async () => { + const delegateResult: AgentResult = { output: 'Delegated!' }; + const delegateAgent = createMockAgent('delegate', delegateResult); + const mainAgent = createMockAgent('main', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map([['delegate', delegateAgent]])); + + let delegatedInput: AgentInput | undefined; + + const interceptor: Interceptor = async (input, ctx, next) => { + await ctx.delegateAndWait('delegate', { message: 'test' }); + return await next(); + }; + + (delegateAgent.invokeAgent as ReturnType).mockImplementation((input: AgentInput) => { + delegatedInput = input; + return Promise.resolve(delegateResult); + }); + + const chain = composeChain([interceptor], mainAgent, channel, registry); + await chain.execute({ ...baseInput, conversationId: 'original-conv-123' }); + + // Delegated call should inherit conversationId from original execute input + expect(delegatedInput?.conversationId).toBe('original-conv-123'); + }); + + it('allows override of conversationId in delegation', async () => { + const delegateResult: AgentResult = { output: 'Delegated!' }; + const delegateAgent = createMockAgent('delegate', delegateResult); + const mainAgent = createMockAgent('main', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map([['delegate', delegateAgent]])); + + let delegatedInput: AgentInput | undefined; + + const interceptor: Interceptor = async (input, ctx, next) => { + await ctx.delegateAndWait('delegate', { + message: 'test', + conversationId: 'custom-conv-456', // Explicitly set + }); + return await next(); + }; + + (delegateAgent.invokeAgent as ReturnType).mockImplementation((input: AgentInput) => { + delegatedInput = input; + return Promise.resolve(delegateResult); + }); + + const chain = composeChain([interceptor], mainAgent, channel, registry); + await chain.execute({ ...baseInput, conversationId: 'original-conv-123' }); + + // Delegated call should use the explicitly provided conversationId + expect(delegatedInput?.conversationId).toBe('custom-conv-456'); + }); + }); + + describe('error propagation', () => { + it('propagates errors from interceptor', async () => { + const agent = createMockAgent('test', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map()); + + const interceptor: Interceptor = async (input, ctx, next) => { + throw new Error('Interceptor error!'); + }; + + const chain = composeChain([interceptor], agent, channel, registry); + + await expect(chain.execute(baseInput)).rejects.toThrow('Interceptor error!'); + }); + + it('propagates errors from agent', async () => { + const agent = createMockAgent('test', baseResult); + (agent.invokeAgent as ReturnType).mockRejectedValue( + new Error('Agent error!') + ); + + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map()); + + const chain = composeChain([], agent, channel, registry); + + await expect(chain.execute(baseInput)).rejects.toThrow('Agent error!'); + }); + + it('still runs post-processing when error occurs downstream', async () => { + const agent = createMockAgent('test', baseResult); + (agent.invokeAgent as ReturnType).mockRejectedValue( + new Error('Agent error!') + ); + + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map()); + + const postProcessingRan: boolean[] = []; + + const interceptor: Interceptor = async (input, ctx, next) => { + try { + return await next(); + } catch { + postProcessingRan.push(true); + return { output: 'recovered' }; + } + }; + + const chain = composeChain([interceptor], agent, channel, registry); + + // The interceptor catches and recovers + const result = await chain.execute(baseInput); + + expect(postProcessingRan).toContain(true); + expect(result).toEqual({ output: 'recovered' }); + }); + }); + + describe('interceptor can transform input', () => { + it('allows interceptor to modify input before passing to next', async () => { + const agent = createMockAgent('test', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map()); + + let receivedInput: AgentInput | undefined; + + const innerInterceptor: Interceptor = async (input, ctx, next) => { + receivedInput = input; + return await next(); + }; + + const outerInterceptor: Interceptor = async (input, ctx, next) => { + const modifiedInput: AgentInput = { + ...input, + message: 'Modified: ' + input.message, + context: { modified: true }, + }; + // Pass modified input downstream + return await next(modifiedInput); + }; + + const chain = composeChain([outerInterceptor, innerInterceptor], agent, channel, registry); + await chain.execute(baseInput); + + // The inner interceptor should receive the modified input + expect(receivedInput?.message).toBe('Modified: Hello'); + expect(receivedInput?.context).toEqual({ modified: true }); + }); + + it('uses original input when next() called without arguments', async () => { + const agent = createMockAgent('test', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map()); + + let receivedInput: AgentInput | undefined; + + const innerInterceptor: Interceptor = async (input, ctx, next) => { + receivedInput = input; + return await next(); + }; + + const outerInterceptor: Interceptor = async (input, ctx, next) => { + // Call next() without passing input - should use original + return await next(); + }; + + const chain = composeChain([outerInterceptor, innerInterceptor], agent, channel, registry); + await chain.execute(baseInput); + + // The inner interceptor should receive the original input + expect(receivedInput?.message).toBe('Hello'); + }); + }); + + describe('interceptor can transform result', () => { + it('allows interceptor to modify result on way out', async () => { + const agent = createMockAgent('test', baseResult); + const channel = createMockChannel('test-channel'); + const registry = createMockRegistry(new Map()); + + const interceptor: Interceptor = async (input, ctx, next) => { + const result = await next(); + if (!isSkipSentinel(result)) { + return { + ...result, + metadata: { ...result.metadata, intercepted: true }, + }; + } + return result; + }; + + const chain = composeChain([interceptor], agent, channel, registry); + const result = await chain.execute(baseInput); + + expect(result).toEqual({ + output: 'Hi there!', + metadata: { intercepted: true }, + }); + }); + }); +}); diff --git a/packages/toolpack-agents/src/interceptors/chain.ts b/packages/toolpack-agents/src/interceptors/chain.ts new file mode 100644 index 0000000..372afe5 --- /dev/null +++ b/packages/toolpack-agents/src/interceptors/chain.ts @@ -0,0 +1,154 @@ +import type { AgentInput, AgentResult, AgentInstance, ChannelInterface } from '../agent/types.js'; +import type { IAgentRegistry } from '../agent/types.js'; +import { + type Interceptor, + type InterceptorContext, + type NextFunction, + type InterceptorResult, + type InterceptorChainConfig, + SKIP_SENTINEL, + skip, +} from './types.js'; + +/** + * Composed chain of interceptors ready to execute. + */ +export interface ComposedChain { + /** Execute the chain with the given input */ + execute(input: AgentInput): Promise; +} + +/** + * Error thrown when invocation depth exceeds the configured maximum. + */ +export class InvocationDepthExceededError extends Error { + constructor(currentDepth: number, maxDepth: number) { + super(`Invocation depth ${currentDepth} exceeds maximum ${maxDepth}`); + this.name = 'InvocationDepthExceededError'; + } +} + +/** + * Compose an array of interceptors into an executable chain. + * + * The first interceptor in the array is outermost (runs first on way in, + * last on way out). The final handler invokes the agent directly. + * + * @param interceptors Ordered array of interceptors (empty = direct agent call) + * @param agent The agent to invoke at the end of the chain + * @param channel The triggering channel + * @param registry The agent registry + * @param config Chain configuration + * @returns Composed chain ready to execute + * + * @example + * ```ts + * const chain = composeChain( + * [eventDedup, noiseFilter, intentClassifier], + * agent, + * channel, + * registry, + * { maxInvocationDepth: 5 } + * ); + * const result = await chain.execute(input); + * ``` + */ +export function composeChain( + interceptors: Interceptor[], + agent: AgentInstance, + channel: ChannelInterface, + registry: IAgentRegistry | null, + config: InterceptorChainConfig = {} +): ComposedChain { + const maxDepth = config.maxInvocationDepth ?? 5; + + return { + async execute(executeInput: AgentInput): Promise { + // Create context inside execute to close over the execute-time input + const createContext = (depth: number): InterceptorContext => ({ + agent, + channel, + registry, + invocationDepth: depth, + delegateAndWait: async (agentName: string, delegateInput: Partial) => { + const nextDepth = depth + 1; + if (nextDepth > maxDepth) { + throw new InvocationDepthExceededError(nextDepth, maxDepth); + } + + if (!registry) { + throw new Error(`Cannot delegate to "${agentName}": agent is running in standalone mode without a registry`); + } + + const targetAgent = registry.getAgent(agentName); + if (!targetAgent) { + throw new Error(`Agent "${agentName}" not found for delegation`); + } + + // Build full input with inheritance from original execute input + const fullInput: AgentInput = { + message: delegateInput.message ?? '', + intent: delegateInput.intent, + data: delegateInput.data, + context: delegateInput.context, + // Inherit conversationId from delegate input, then original execute input, then fallback + conversationId: delegateInput.conversationId + ?? executeInput.conversationId + ?? `delegation-${Date.now()}`, + }; + + // Invoke target agent directly (interceptors don't apply on delegate calls) + return await targetAgent.invokeAgent(fullInput); + }, + skip, + }); + + const ctx = createContext(0); + + // Build the chain from inside out + // Start with the final handler (agent invocation) + let chain: NextFunction = async (overrideInput?: AgentInput) => { + const effectiveInput = overrideInput ?? executeInput; + const result = await agent.invokeAgent(effectiveInput); + return result; + }; + + // Wrap with interceptors in reverse order (so first interceptor is outermost) + for (let i = interceptors.length - 1; i >= 0; i--) { + const interceptor = interceptors[i]; + const next = chain; + + chain = async (overrideInput?: AgentInput) => { + const effectiveInput = overrideInput ?? executeInput; + return await interceptor(effectiveInput, ctx, next); + }; + } + + // Execute the chain + return await chain(); + }, + }; +} + +/** + * Execute a chain with the given input, handling the skip sentinel. + * + * Returns `null` if the chain was skipped (caller should not send to channel), + * otherwise returns the AgentResult. + * + * @param chain The composed chain + * @param input The agent input + * @returns AgentResult or null if skipped + */ +export async function executeChain( + chain: ComposedChain, + input: AgentInput +): Promise { + const result = await chain.execute(input); + + if (result === SKIP_SENTINEL) { + return null; + } + + return result; +} diff --git a/packages/toolpack-agents/src/interceptors/index.ts b/packages/toolpack-agents/src/interceptors/index.ts new file mode 100644 index 0000000..babe15b --- /dev/null +++ b/packages/toolpack-agents/src/interceptors/index.ts @@ -0,0 +1,47 @@ +// Interceptor system for composable agent middleware +// Enables cross-cutting concerns like filtering, classification, and rate limiting + +export { + SKIP_SENTINEL, + type InterceptorResult, + type InterceptorContext, + type NextFunction, + type Interceptor, + type InterceptorChainConfig, + isSkipSentinel, + skip, +} from './types.js'; + +export { + type ComposedChain, + InvocationDepthExceededError, + composeChain, + executeChain, +} from './chain.js'; + +// Built-in interceptors +export { + createEventDedupInterceptor, + type EventDedupConfig, + createNoiseFilterInterceptor, + type NoiseFilterConfig, + createSelfFilterInterceptor, + type SelfFilterConfig, + createRateLimitInterceptor, + type RateLimitConfig, + createParticipantResolverInterceptor, + type ParticipantResolverConfig, + createCaptureInterceptor, + CAPTURE_INTERCEPTOR_MARKER, + type CaptureHistoryConfig, + createAddressCheckInterceptor, + type AddressCheckConfig, + type AddressCheckResult, + createIntentClassifierInterceptor, + type IntentClassifierInterceptorConfig, + createDepthGuardInterceptor, + type DepthGuardConfig, + DepthExceededError, + createTracerInterceptor, + type TracerConfig, +} from './builtins/index.js'; diff --git a/packages/toolpack-agents/src/interceptors/types.ts b/packages/toolpack-agents/src/interceptors/types.ts new file mode 100644 index 0000000..297c69f --- /dev/null +++ b/packages/toolpack-agents/src/interceptors/types.ts @@ -0,0 +1,129 @@ +import type { AgentInput, AgentResult, AgentInstance, ChannelInterface } from '../agent/types.js'; +import type { IAgentRegistry } from '../agent/types.js'; + +/** + * Sentinel value indicating the interceptor chain should end silently. + * When returned by an interceptor, the registry must not call `channel.send`. + */ +export const SKIP_SENTINEL = Symbol('interceptor-skip-sentinel'); + +/** + * Result from an interceptor or the chain. + * - `AgentResult`: Normal result, send to channel + * - `SkipSentinel`: Silent skip, do not send + */ +export type InterceptorResult = AgentResult | typeof SKIP_SENTINEL; + +/** + * Check if a result is the skip sentinel. + */ +export function isSkipSentinel(result: InterceptorResult): result is typeof SKIP_SENTINEL { + return result === SKIP_SENTINEL; +} + +/** + * Helper function to create a skip sentinel result. + * Use this in interceptors to signal "do not reply". + */ +export function skip(): typeof SKIP_SENTINEL { + return SKIP_SENTINEL; +} + +/** + * Context available to each interceptor during chain execution. + */ +export interface InterceptorContext { + /** The agent instance the chain wraps */ + agent: AgentInstance; + + /** The channel that triggered this invocation */ + channel: ChannelInterface; + + /** The registry for agent lookup and delegation. Null in standalone (single-agent) mode. */ + registry: IAgentRegistry | null; + + /** Current invocation depth (0 = top-level) */ + invocationDepth: number; + + /** + * Delegate to another agent synchronously (depth-aware). + * Increments invocation depth. Rejects if past depth cap. + */ + delegateAndWait(agentName: string, input: Partial): Promise; + + /** + * Signal that the chain should end silently. + * Returns the skip sentinel - use `return ctx.skip()` to short-circuit. + */ + skip: () => typeof SKIP_SENTINEL; + + /** Optional structured logger (wired by registry) */ + logger?: { + debug: (message: string, meta?: Record) => void; + info: (message: string, meta?: Record) => void; + warn: (message: string, meta?: Record) => void; + error: (message: string, meta?: Record) => void; + }; +} + +/** + * Next function in the interceptor chain. + * Call this to continue to the next interceptor or the final agent invocation. + * + * Optionally pass a modified input to downstream interceptors/agents. + * If no input is provided, the original input is used. + * + * @example + * ```ts + * // Pass modified input downstream + * const modifiedInput = { ...input, context: { ...input.context, annotated: true } }; + * return await next(modifiedInput); + * + * // Or pass original input unchanged + * return await next(); + * ``` + */ +export type NextFunction = (input?: AgentInput) => Promise; + +/** + * Interceptor function signature. + * Middleware-style pattern: inspect/transform input, optionally continue. + * + * @param input The incoming agent input + * @param ctx Context with agent, channel, registry, helpers + * @param next Continue to next interceptor/agent + * @returns Result (or skip sentinel to end silently) + * + * @example + * ```ts + * const myInterceptor: Interceptor = async (input, ctx, next) => { + * // Pre-processing + * if (shouldIgnore(input)) { + * return ctx.skip(); // Short-circuit silently + * } + * + * // Continue chain + * const result = await next(); + * + * // Post-processing (optional) + * if (!isSkipSentinel(result)) { + * result.metadata = { ...result.metadata, intercepted: true }; + * } + * + * return result; + * }; + * ``` + */ +export type Interceptor = ( + input: AgentInput, + ctx: InterceptorContext, + next: NextFunction +) => Promise; + +/** + * Configuration for the interceptor chain. + */ +export interface InterceptorChainConfig { + /** Maximum invocation depth for delegation (default: 5) */ + maxInvocationDepth?: number; +} diff --git a/packages/toolpack-agents/src/registry/index.test.ts b/packages/toolpack-agents/src/registry/index.test.ts new file mode 100644 index 0000000..8f78b39 --- /dev/null +++ b/packages/toolpack-agents/src/registry/index.test.ts @@ -0,0 +1,354 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { searchRegistry, RegistryError } from './search.js'; + +describe('registry', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + globalThis.fetch = vi.fn(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + describe('searchRegistry', () => { + it('should search for toolpack-agent packages', async () => { + const mockResponse = { + objects: [ + { + package: { + name: 'toolpack-agent-research', + version: '1.0.0', + description: 'A research agent', + keywords: ['toolpack-agent', 'research'], + date: '2024-01-01', + toolpack: { + agent: true, + category: 'research', + description: 'Research agent for web data', + tags: ['web', 'research'], + }, + links: { + npm: 'https://www.npmjs.com/package/toolpack-agent-research', + }, + }, + score: { final: 0.9 }, + }, + { + package: { + name: 'toolpack-agent-coding', + version: '2.0.0', + description: 'A coding agent', + keywords: ['toolpack-agent', 'coding'], + date: '2024-01-02', + toolpack: { + agent: true, + category: 'coding', + description: 'Coding assistant agent', + }, + links: { + npm: 'https://www.npmjs.com/package/toolpack-agent-coding', + }, + }, + score: { final: 0.8 }, + }, + ], + total: 2, + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await searchRegistry(); + + expect(result.agents).toHaveLength(2); + expect(result.agents[0].name).toBe('toolpack-agent-research'); + expect(result.agents[0].toolpack?.category).toBe('research'); + expect(result.total).toBe(2); + expect(result.hasMore).toBe(false); + }); + + it('should filter by keyword', async () => { + const mockResponse = { + objects: [ + { + package: { + name: 'toolpack-agent-finance', + version: '1.0.0', + description: 'Finance agent', + keywords: ['toolpack-agent', 'finance'], + toolpack: { + agent: true, + category: 'research', + tags: ['finance'], + }, + }, + }, + ], + total: 1, + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await searchRegistry({ keyword: 'finance' }); + + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + expect.stringContaining('text=toolpack-agent+finance'), + expect.any(Object) + ); + expect(result.agents).toHaveLength(1); + }); + + it('should filter by category', async () => { + const mockResponse = { + objects: [ + { + package: { + name: 'agent-1', + version: '1.0.0', + toolpack: { agent: true, category: 'research' }, + }, + }, + { + package: { + name: 'agent-2', + version: '1.0.0', + toolpack: { agent: true, category: 'coding' }, + }, + }, + { + package: { + name: 'agent-3', + version: '1.0.0', + toolpack: { agent: true, category: 'research' }, + }, + }, + ], + total: 3, + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await searchRegistry({ category: 'research' }); + + expect(result.agents).toHaveLength(2); + expect(result.agents.every(a => a.toolpack?.category === 'research')).toBe(true); + }); + + it('should filter by tag', async () => { + const mockResponse = { + objects: [ + { + package: { + name: 'agent-1', + version: '1.0.0', + toolpack: { agent: true, tags: ['ai', 'ml'] }, + }, + }, + { + package: { + name: 'agent-2', + version: '1.0.0', + toolpack: { agent: true, tags: ['web'] }, + }, + }, + { + package: { + name: 'agent-3', + version: '1.0.0', + keywords: ['ai'], + toolpack: { agent: true }, + }, + }, + ], + total: 3, + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await searchRegistry({ tag: 'ai' }); + + expect(result.agents).toHaveLength(2); + }); + + it('should only include packages with toolpack.agent = true', async () => { + const mockResponse = { + objects: [ + { + package: { + name: 'valid-agent', + version: '1.0.0', + toolpack: { agent: true, category: 'research' }, + }, + }, + { + package: { + name: 'invalid-agent', + version: '1.0.0', + keywords: ['toolpack-agent'], + // Missing toolpack.agent = true + }, + }, + { + package: { + name: 'another-valid', + version: '1.0.0', + toolpack: { agent: true, category: 'coding' }, + }, + }, + ], + total: 3, + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await searchRegistry(); + + expect(result.agents).toHaveLength(2); + expect(result.agents.map(a => a.name)).toContain('valid-agent'); + expect(result.agents.map(a => a.name)).toContain('another-valid'); + expect(result.agents.map(a => a.name)).not.toContain('invalid-agent'); + }); + + it('should handle pagination', async () => { + const mockResponse = { + objects: Array.from({ length: 30 }, (_, i) => ({ + package: { + name: `agent-${i}`, + version: '1.0.0', + toolpack: { agent: true }, + }, + })), + total: 30, + }; + + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result1 = await searchRegistry({ limit: 10, offset: 0 }); + expect(result1.agents).toHaveLength(10); + expect(result1.hasMore).toBe(true); + expect(result1.agents[0].name).toBe('agent-0'); + + const result2 = await searchRegistry({ limit: 10, offset: 10 }); + expect(result2.agents).toHaveLength(10); + expect(result2.hasMore).toBe(true); + expect(result2.agents[0].name).toBe('agent-10'); + + const result3 = await searchRegistry({ limit: 10, offset: 20 }); + expect(result3.agents).toHaveLength(10); + expect(result3.hasMore).toBe(false); + expect(result3.agents[0].name).toBe('agent-20'); + }); + + it('should use custom registry URL', async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ objects: [], total: 0 }), + } as Response); + + await searchRegistry({ registryUrl: 'https://private.registry.com' }); + + expect(vi.mocked(fetch)).toHaveBeenCalledWith( + expect.stringContaining('https://private.registry.com'), + expect.any(Object) + ); + }); + + it('should throw RegistryError on HTTP error', async () => { + vi.mocked(fetch).mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + json: async () => ({}), + } as Response); + + await expect(searchRegistry()).rejects.toThrow(RegistryError); + await expect(searchRegistry()).rejects.toThrow('NPM registry search failed'); + }); + + it('should throw RegistryError on fetch failure', async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error')); + + await expect(searchRegistry()).rejects.toThrow(RegistryError); + await expect(searchRegistry()).rejects.toThrow('Failed to search registry'); + }); + + it('should extract all toolpack metadata fields', async () => { + const mockResponse = { + objects: [ + { + package: { + name: 'complete-agent', + version: '1.2.3', + description: 'Full featured agent', + keywords: ['toolpack-agent'], + author: 'John Doe', + date: '2024-01-15', + links: { + npm: 'https://npm.example.com/complete-agent', + homepage: 'https://example.com', + repository: 'https://github.com/example/agent', + bugs: 'https://github.com/example/agent/issues', + }, + publisher: { username: 'johndoe', email: 'john@example.com' }, + maintainers: [{ username: 'johndoe', email: 'john@example.com' }], + toolpack: { + agent: true, + category: 'research', + description: 'Detailed agent description', + tags: ['ai', 'ml', 'nlp'], + author: 'Toolpack Team', + repository: 'https://github.com/toolpack/complete-agent', + homepage: 'https://toolpack.dev/complete-agent', + }, + }, + }, + ], + total: 1, + }; + + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await searchRegistry(); + const agent = result.agents[0]; + + expect(agent.name).toBe('complete-agent'); + expect(agent.version).toBe('1.2.3'); + expect(agent.description).toBe('Full featured agent'); + expect(agent.toolpack?.agent).toBe(true); + expect(agent.toolpack?.category).toBe('research'); + expect(agent.toolpack?.description).toBe('Detailed agent description'); + expect(agent.toolpack?.tags).toEqual(['ai', 'ml', 'nlp']); + expect(agent.toolpack?.author).toBe('Toolpack Team'); + expect(agent.toolpack?.repository).toBe('https://github.com/toolpack/complete-agent'); + expect(agent.toolpack?.homepage).toBe('https://toolpack.dev/complete-agent'); + expect(agent.author).toBe('John Doe'); + expect(agent.date).toBe('2024-01-15'); + expect(agent.links?.npm).toBe('https://npm.example.com/complete-agent'); + expect(agent.publisher?.username).toBe('johndoe'); + expect(agent.maintainers).toHaveLength(1); + }); + }); +}); diff --git a/packages/toolpack-agents/src/registry/index.ts b/packages/toolpack-agents/src/registry/index.ts new file mode 100644 index 0000000..0d7d5da --- /dev/null +++ b/packages/toolpack-agents/src/registry/index.ts @@ -0,0 +1,13 @@ +// Registry for toolpack-agents +// Discover and publish community-built agents + +// Search functionality +export { searchRegistry, RegistryError } from './search.js'; + +// Types +export type { + ToolpackAgentMetadata, + RegistryAgent, + SearchRegistryOptions, + SearchRegistryResult, +} from './types.js'; diff --git a/packages/toolpack-agents/src/registry/search.ts b/packages/toolpack-agents/src/registry/search.ts new file mode 100644 index 0000000..ee8116b --- /dev/null +++ b/packages/toolpack-agents/src/registry/search.ts @@ -0,0 +1,222 @@ +import type { + RegistryAgent, + SearchRegistryOptions, + SearchRegistryResult, + ToolpackAgentMetadata, +} from './types.js'; + +/** + * NPM registry search response format. + */ +interface NpmSearchResponse { + objects: Array<{ + package: { + name: string; + version: string; + description?: string; + keywords?: string[]; + date?: string; + links?: { + npm?: string; + homepage?: string; + repository?: string; + bugs?: string; + }; + publisher?: { + username?: string; + email?: string; + }; + maintainers?: Array<{ + username?: string; + email?: string; + }>; + author?: string | { name?: string; email?: string }; + [key: string]: unknown; + }; + score?: { + final?: number; + detail?: { + quality?: number; + popularity?: number; + maintenance?: number; + }; + }; + searchScore?: number; + }>; + total: number; + time?: string; +} + +/** + * Searches the NPM registry for toolpack agents. + * + * Queries packages with the "toolpack-agent" keyword and filters + * by optional category, tags, and search keywords. + * + * @example + * ```ts + * import { searchRegistry } from '@toolpack-sdk/agents/registry'; + * + * // Search all agents + * const results = await searchRegistry(); + * + * // Search by keyword + * const results = await searchRegistry({ keyword: 'fintech' }); + * + * // Filter by category + * const results = await searchRegistry({ category: 'research' }); + * + * // Combined search + * const results = await searchRegistry({ + * keyword: 'stock', + * category: 'research', + * limit: 10, + * }); + * + * // Display results + * for (const agent of results.agents) { + * console.log(`${agent.name}: ${agent.toolpack?.description || agent.description}`); + * console.log(` Install: npm install ${agent.name}`); + * } + * ``` + * + * @param options Search options + * @returns Search results with agents and pagination info + */ +export async function searchRegistry( + options: SearchRegistryOptions = {} +): Promise { + const { + keyword, + category, + tag, + limit = 20, + offset = 0, + registryUrl = 'https://registry.npmjs.org', + } = options; + + // Build search query - always include toolpack-agent keyword + const searchTerms: string[] = ['toolpack-agent']; + if (keyword) { + searchTerms.push(keyword); + } + if (tag) { + searchTerms.push(tag); + } + + const query = searchTerms.join(' '); + + // Build the NPM registry search URL + // NPM search API: /-/v1/search?text=...&size=...&from=... + const searchUrl = new URL('/-/v1/search', registryUrl); + searchUrl.searchParams.set('text', query); + searchUrl.searchParams.set('size', String(Math.min(limit + offset, 250))); // NPM max is 250 + searchUrl.searchParams.set('from', String(0)); // We'll handle offset in memory for filtering + + try { + const response = await fetch(searchUrl.toString(), { + headers: { + Accept: 'application/json', + }, + }); + + if (!response.ok) { + throw new RegistryError( + `NPM registry search failed: ${response.status} ${response.statusText}` + ); + } + + const data = (await response.json()) as NpmSearchResponse; + + // Transform and filter results + let agents: RegistryAgent[] = data.objects.map(obj => { + const pkg = obj.package; + const toolpack = extractToolpackMetadata(pkg); + + return { + name: pkg.name, + version: pkg.version, + description: pkg.description, + toolpack, + keywords: pkg.keywords, + author: pkg.author, + date: pkg.date, + links: pkg.links, + publisher: pkg.publisher, + maintainers: pkg.maintainers, + }; + }); + + // Filter by category if specified + if (category) { + agents = agents.filter( + agent => agent.toolpack?.category?.toLowerCase() === category.toLowerCase() + ); + } + + // Filter by tag if specified + if (tag) { + const tagLower = tag.toLowerCase(); + agents = agents.filter( + agent => + agent.toolpack?.tags?.some(t => t.toLowerCase() === tagLower) || + agent.keywords?.some(k => k.toLowerCase() === tagLower) + ); + } + + // Only include packages with toolpack.agent = true + agents = agents.filter(agent => agent.toolpack?.agent === true); + + // Apply offset and limit + const total = agents.length; + agents = agents.slice(offset, offset + limit); + + return { + agents, + total, + offset, + limit, + hasMore: total > offset + limit, + }; + } catch (error) { + if (error instanceof RegistryError) { + throw error; + } + throw new RegistryError( + `Failed to search registry: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +/** + * Extracts toolpack metadata from package.json data. + */ +function extractToolpackMetadata(pkg: Record): ToolpackAgentMetadata | undefined { + const toolpack = pkg.toolpack as Record | undefined; + + if (!toolpack || toolpack.agent !== true) { + return undefined; + } + + return { + agent: true, + category: typeof toolpack.category === 'string' ? toolpack.category : undefined, + description: typeof toolpack.description === 'string' ? toolpack.description : undefined, + tags: Array.isArray(toolpack.tags) + ? toolpack.tags.filter((t): t is string => typeof t === 'string') + : undefined, + author: typeof toolpack.author === 'string' ? toolpack.author : undefined, + repository: typeof toolpack.repository === 'string' ? toolpack.repository : undefined, + homepage: typeof toolpack.homepage === 'string' ? toolpack.homepage : undefined, + }; +} + +/** + * Error thrown when registry operations fail. + */ +export class RegistryError extends Error { + constructor(message: string) { + super(message); + this.name = 'RegistryError'; + } +} diff --git a/packages/toolpack-agents/src/registry/types.ts b/packages/toolpack-agents/src/registry/types.ts new file mode 100644 index 0000000..98de68d --- /dev/null +++ b/packages/toolpack-agents/src/registry/types.ts @@ -0,0 +1,138 @@ +/** + * Types for the toolpack-agents registry. + * Used for discovering and publishing community agents. + */ + +/** + * Metadata that should be included in a package.json to identify + * a package as a toolpack agent. + * + * @example + * ```json + * { + * "name": "toolpack-agent-fintech-research", + * "version": "1.0.0", + * "keywords": ["toolpack-agent"], + * "toolpack": { + * "agent": true, + * "category": "research", + * "description": "Research agent focused on fintech news and regulatory updates", + * "tags": ["fintech", "research", "news"], + * "author": "John Doe", + * "repository": "https://github.com/johndoe/toolpack-agent-fintech-research", + * "homepage": "https://example.com/fintech-agent" + * } + * } + * ``` + */ +export interface ToolpackAgentMetadata { + /** Must be true to be recognized as an agent */ + agent: true; + + /** Category for grouping (e.g., 'research', 'coding', 'data', 'custom') */ + category?: string; + + /** Short description of what the agent does */ + description?: string; + + /** Tags for searchability */ + tags?: string[]; + + /** Author name or organization */ + author?: string; + + /** Repository URL */ + repository?: string; + + /** Homepage URL */ + homepage?: string; +} + +/** + * An agent entry returned from the registry search. + */ +export interface RegistryAgent { + /** Package name */ + name: string; + + /** Package version */ + version: string; + + /** Package description from npm */ + description?: string; + + /** Toolpack-specific metadata */ + toolpack?: ToolpackAgentMetadata; + + /** NPM keywords */ + keywords?: string[]; + + /** Package author */ + author?: string | { name?: string; email?: string }; + + /** NPM registry date */ + date?: string; + + /** NPM registry links */ + links?: { + npm?: string; + homepage?: string; + repository?: string; + bugs?: string; + }; + + /** NPM registry publisher info */ + publisher?: { + username?: string; + email?: string; + }; + + /** NPM maintainers */ + maintainers?: Array<{ + username?: string; + email?: string; + }>; +} + +/** + * Options for searching the registry. + */ +export interface SearchRegistryOptions { + /** Search query string */ + keyword?: string; + + /** Filter by category */ + category?: string; + + /** Filter by tag */ + tag?: string; + + /** Maximum number of results (default: 20) */ + limit?: number; + + /** Offset for pagination (default: 0) */ + offset?: number; + + /** NPM registry URL (default: https://registry.npmjs.org) */ + registryUrl?: string; +} + +/** + * Result from a registry search. + */ +export interface SearchRegistryResult { + /** List of matching agents */ + agents: RegistryAgent[]; + + /** Total number of results (may be approximate) */ + total: number; + + /** Offset used for this query */ + offset: number; + + /** Limit used for this query */ + limit: number; + + /** Whether more results are available */ + hasMore: boolean; +} diff --git a/packages/toolpack-agents/src/testing/capture-events.ts b/packages/toolpack-agents/src/testing/capture-events.ts new file mode 100644 index 0000000..3c20b29 --- /dev/null +++ b/packages/toolpack-agents/src/testing/capture-events.ts @@ -0,0 +1,193 @@ +import { BaseAgent } from '../agent/base-agent.js'; + +export type AgentEventName = 'agent:start' | 'agent:complete' | 'agent:error'; + +export interface CapturedEvent { + name: AgentEventName; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: any; + timestamp: number; +} + +export interface EventCapture { + /** All captured events */ + events: CapturedEvent[]; + + /** Number of events captured */ + count: number; + + /** Clear all captured events */ + clear(): void; + + /** Stop capturing events and remove listeners */ + stop(): void; + + /** Check if an event with the given name was captured */ + hasEvent(name: AgentEventName): boolean; + + /** Get all events with the given name */ + getEvents(name: AgentEventName): CapturedEvent[]; + + /** Get the first event with the given name, or undefined if none */ + getFirstEvent(name: AgentEventName): CapturedEvent | undefined; + + /** Get the last event with the given name, or undefined if none */ + getLastEvent(name: AgentEventName): CapturedEvent | undefined; + + /** Assert that an event was captured (throws if not) */ + assertEvent(name: AgentEventName): void; + + /** Assert that an event was NOT captured (throws if it was) */ + assertNoEvent(name: AgentEventName): void; +} + +/** + * Captures events emitted by a BaseAgent during testing. + * Useful for asserting that certain lifecycle events were fired. + * + * @example + * ```ts + * const { agent } = createTestAgent(MyAgent); + * const events = captureEvents(agent); + * + * await agent.invokeAgent({ message: 'Do something' }); + * + * expect(events.hasEvent('agent:start')).toBe(true); + * expect(events.hasEvent('agent:complete')).toBe(true); + * expect(events.hasEvent('agent:error')).toBe(false); + * + * // Or use assertion helpers + * events.assertEvent('agent:start'); + * events.assertEvent('agent:complete'); + * events.assertNoEvent('agent:error'); + * ``` + * + * @param agent The agent to capture events from + * @returns Event capture object with assertion helpers + */ +export function captureEvents(agent: BaseAgent): EventCapture { + const events: CapturedEvent[] = []; + const listeners: Array<{ event: AgentEventName; handler: (...args: unknown[]) => void }> = []; + + const createHandler = (eventName: AgentEventName) => { + return (data: unknown) => { + events.push({ + name: eventName, + data, + timestamp: Date.now(), + }); + }; + }; + + // Attach listeners for all agent events + const eventNames: AgentEventName[] = ['agent:start', 'agent:complete', 'agent:error']; + + for (const eventName of eventNames) { + const handler = createHandler(eventName); + agent.on(eventName, handler); + listeners.push({ event: eventName, handler }); + } + + return { + get events() { + return [...events]; + }, + + get count() { + return events.length; + }, + + clear() { + events.length = 0; + }, + + stop() { + for (const { event, handler } of listeners) { + agent.off(event, handler); + } + listeners.length = 0; + }, + + hasEvent(name: AgentEventName): boolean { + return events.some(e => e.name === name); + }, + + getEvents(name: AgentEventName): CapturedEvent[] { + return events.filter(e => e.name === name); + }, + + getFirstEvent(name: AgentEventName): CapturedEvent | undefined { + return events.find(e => e.name === name); + }, + + getLastEvent(name: AgentEventName): CapturedEvent | undefined { + const filtered = events.filter(e => e.name === name); + return filtered[filtered.length - 1]; + }, + + assertEvent(name: AgentEventName): void { + if (!this.hasEvent(name)) { + const capturedEventNames = events.map(e => e.name).join(', ') || '(none)'; + throw new Error(`captureEvents: expected event "${name}" was not captured. Captured events: ${capturedEventNames}`); + } + }, + + assertNoEvent(name: AgentEventName): void { + if (this.hasEvent(name)) { + const count = events.filter(e => e.name === name).length; + throw new Error(`captureEvents: unexpected event "${name}" was captured ${count} time(s)`); + } + }, + }; +} + +/** + * Custom Vitest/Jest matcher for asserting captured events. + * Add this to your test setup for more readable assertions. + * + * @example + * ```ts + * // In your test setup file + * import { expect } from 'vitest'; + * import { registerEventMatchers } from '@toolpack-sdk/agents/testing'; + * registerEventMatchers(expect); + * + * // In your tests + * expect(events).toContainEvent('agent:start'); + * expect(events).not.toContainEvent('agent:error'); + * ``` + */ +export function registerEventMatchers( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect: { extend: (matchers: Record { message: () => string; pass: boolean }>) => void } +): void { + expect.extend({ + toContainEvent(...args: unknown[]) { + const received = args[0] as EventCapture; + const expectedEvent = args[1] as AgentEventName; + const pass = received.hasEvent(expectedEvent); + return { + message: () => + pass + ? `expected events to NOT contain "${expectedEvent}"` + : `expected events to contain "${expectedEvent}". Captured events: ${received.events.map(e => e.name).join(', ') || '(none)'}`, + pass, + }; + }, + + toContainEventTimes(...args: unknown[]) { + const received = args[0] as EventCapture; + const expectedEvent = args[1] as AgentEventName; + const times = args[2] as number; + const count = received.getEvents(expectedEvent).length; + const pass = count === times; + return { + message: () => + pass + ? `expected event "${expectedEvent}" to NOT be captured ${times} time(s), but it was` + : `expected event "${expectedEvent}" to be captured ${times} time(s), but it was captured ${count} time(s)`, + pass, + }; + }, + }); +} diff --git a/packages/toolpack-agents/src/testing/create-test-agent.ts b/packages/toolpack-agents/src/testing/create-test-agent.ts new file mode 100644 index 0000000..5d8059e --- /dev/null +++ b/packages/toolpack-agents/src/testing/create-test-agent.ts @@ -0,0 +1,241 @@ +import type { Toolpack } from 'toolpack-sdk'; +import { BaseAgent } from '../agent/base-agent.js'; +import type { AgentInput, BaseAgentOptions } from '../agent/types.js'; +import { MockChannel } from './mock-channel.js'; + +/** + * Configuration for mock responses in createTestAgent. + */ +export interface MockResponse { + /** String or regex to match against the message */ + trigger: string | RegExp; + /** The response to return when triggered */ + response: string; + /** Optional usage metadata */ + usage?: { prompt_tokens: number; completion_tokens: number; total_tokens: number }; +} + +/** + * Options for createTestAgent. + */ +export interface CreateTestAgentOptions { + /** Mock responses for the toolpack.generate() call */ + mockResponses?: MockResponse[]; + /** Default response when no trigger matches */ + defaultResponse?: string; + /** Provider name for the mock toolpack */ + provider?: string; + /** Model name for the mock toolpack */ + model?: string; +} + +/** + * Result from createTestAgent. + */ +export interface TestAgentResult { + /** The agent instance */ + agent: TAgent; + /** The mock channel wired to the agent */ + channel: MockChannel; + /** The mock toolpack instance */ + toolpack: Toolpack; + /** Helper to add more mock responses */ + addMockResponse: (response: MockResponse) => void; +} + +/** + * Creates an agent instance wired to a mock channel and mock toolpack. + * Perfect for unit testing agents in isolation. + * + * @example + * ```ts + * const { agent, channel, toolpack } = createTestAgent(CustomerSupportAgent, { + * mockResponses: [ + * { trigger: 'refund', response: 'Refund processed successfully.' }, + * ], + * }); + * + * const result = await agent.invokeAgent({ + * intent: 'refund_request', + * message: 'I want a refund for order #123', + * }); + * + * expect(result.output).toBe('Refund processed successfully.'); + * ``` + * + * @param AgentClass The agent class to instantiate + * @param options Configuration options + * @returns Test agent setup with agent, channel, and mock toolpack + */ +export function createTestAgent( + AgentClass: new (options: BaseAgentOptions) => TAgent, + options: CreateTestAgentOptions = {} +): TestAgentResult { + const mockResponses: MockResponse[] = [...(options.mockResponses ?? [])]; + const defaultResponse = options.defaultResponse ?? 'Mock AI response'; + + // Create mock toolpack + const toolpack = createMockToolpack(mockResponses, defaultResponse, options.provider, options.model); + + // Create agent instance + const agent = new AgentClass({ toolpack }); + + // Create mock channel + const channel = new MockChannel(); + + // Wire up the channel to the agent manually + channel.onMessage(async (input: AgentInput) => { + // Set the agent's internal state as if it came through the registry + agent._triggeringChannel = channel.name; + agent._conversationId = input.conversationId; + agent._isTriggerChannel = false; + + const result = await agent.invokeAgent(input); + + // Send result back through channel + await channel.send({ + output: result.output, + metadata: result.metadata, + }); + }); + + channel.listen(); + + const addMockResponse = (response: MockResponse) => { + mockResponses.push(response); + }; + + return { + agent, + channel, + toolpack, + addMockResponse, + }; +} + +/** + * Creates a mock Toolpack instance for testing. + */ +function createMockToolpack( + mockResponses: MockResponse[], + defaultResponse: string, + defaultProvider = 'openai', + defaultModel?: string +): Toolpack { + return { + generate: async (request: unknown, _providerOverride?: string) => { + const req = request as { + messages: Array<{ role: string; content: string }>; + model?: string; + tools?: unknown[]; + }; + + // Get the last user message + const lastMessage = req.messages + .filter(m => m.role === 'user') + .pop(); + + const messageContent = lastMessage?.content ?? ''; + + // Find matching mock response + for (const mock of mockResponses) { + if (typeof mock.trigger === 'string') { + if (messageContent.toLowerCase().includes(mock.trigger.toLowerCase())) { + return { + content: mock.response, + usage: mock.usage ?? { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }; + } + } else if (mock.trigger instanceof RegExp) { + if (mock.trigger.test(messageContent)) { + return { + content: mock.response, + usage: mock.usage ?? { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }; + } + } + } + + // Return default response + return { + content: defaultResponse, + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }; + }, + setMode: () => { + // No-op in tests + }, + registerMode: () => { + // No-op in tests + }, + // Add any other required Toolpack methods as no-ops or mocks + setProvider: () => {}, + setModel: () => {}, + // Provider and model getters + get provider() { + return defaultProvider; + }, + get model() { + return defaultModel || 'gpt-4'; + }, + } as unknown as Toolpack; +} + +/** + * Creates a minimal mock Toolpack for simple test cases. + * Returns the same response for all generate() calls. + * + * @example + * ```ts + * const toolpack = createMockToolpackSimple('Hello!'); + * const agent = new MyAgent(toolpack); + * const result = await agent.run('Hi'); + * expect(result.output).toBe('Hello!'); + * ``` + */ +export function createMockToolpackSimple(response = 'Mock AI response'): Toolpack { + return { + generate: async () => ({ + content: response, + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }), + setMode: () => {}, + registerMode: () => {}, + setProvider: () => {}, + setModel: () => {}, + } as unknown as Toolpack; +} + +/** + * Creates a mock Toolpack that returns different responses based on a sequence. + * Useful for testing multi-turn conversations or stateful interactions. + * + * @example + * ```ts + * const toolpack = createMockToolpackSequence([ + * 'First response', + * 'Second response', + * 'Third response', + * ]); + * + * // First call returns 'First response', second call 'Second response', etc. + * ``` + */ +export function createMockToolpackSequence(responses: string[]): Toolpack { + let callIndex = 0; + + return { + generate: async () => { + const response = responses[callIndex] ?? 'No more mock responses'; + callIndex++; + return { + content: response, + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }; + }, + setMode: () => {}, + registerMode: () => {}, + setProvider: () => {}, + setModel: () => {}, + } as unknown as Toolpack; +} diff --git a/packages/toolpack-agents/src/testing/index.test.ts b/packages/toolpack-agents/src/testing/index.test.ts new file mode 100644 index 0000000..2a92ea4 --- /dev/null +++ b/packages/toolpack-agents/src/testing/index.test.ts @@ -0,0 +1,419 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MockChannel } from './mock-channel.js'; +import { MockKnowledge, createMockKnowledge, createMockKnowledgeSync } from './mock-knowledge.js'; +import { + createTestAgent, + createMockToolpackSimple, + createMockToolpackSequence, +} from './create-test-agent.js'; +import { captureEvents } from './capture-events.js'; +import { BaseAgent } from '../agent/base-agent.js'; +import type { AgentInput, AgentResult } from '../agent/types.js'; +import { CHAT_MODE } from 'toolpack-sdk'; + +describe('testing utilities', () => { + describe('MockChannel', () => { + it('should capture outputs sent to the channel', async () => { + const channel = new MockChannel(); + + await channel.send({ output: 'Hello' }); + await channel.send({ output: 'World' }); + + expect(channel.outputs).toHaveLength(2); + expect(channel.lastOutput?.output).toBe('World'); + }); + + it('should normalize incoming messages', () => { + const channel = new MockChannel(); + + const input = channel.normalize({ + message: 'Test message', + intent: 'test_intent', + conversationId: 'conv-123', + context: { threadTs: '123.456' }, + }); + + expect(input.message).toBe('Test message'); + expect(input.intent).toBe('test_intent'); + expect(input.conversationId).toBe('conv-123'); + expect(input.context).toEqual({ threadTs: '123.456' }); + }); + + it('should call handler when receiving a message', async () => { + const channel = new MockChannel(); + const handler = vi.fn().mockResolvedValue(undefined); + + channel.onMessage(handler); + await channel.receive({ message: 'Test', conversationId: 'conv-1' }); + + expect(handler).toHaveBeenCalledOnce(); + expect(handler.mock.calls[0][0].message).toBe('Test'); + }); + + it('should track inputs received', async () => { + const channel = new MockChannel(); + channel.onMessage(vi.fn().mockResolvedValue(undefined)); + + await channel.receive({ message: 'First', conversationId: 'conv-1' }); + await channel.receive({ message: 'Second', conversationId: 'conv-1' }); + + expect(channel.inputs).toHaveLength(2); + expect(channel.lastInput?.message).toBe('Second'); + expect(channel.receivedCount).toBe(2); + }); + + it('should clear all data', async () => { + const channel = new MockChannel(); + await channel.send({ output: 'Test' }); + + channel.clear(); + + expect(channel.outputs).toHaveLength(0); + expect(channel.inputs).toHaveLength(0); + }); + + it('should throw if receiving without handler', async () => { + const channel = new MockChannel(); + + await expect(channel.receive({ message: 'Test' })).rejects.toThrow( + 'no message handler registered' + ); + }); + + it('should assert output contains text', async () => { + const channel = new MockChannel(); + await channel.send({ output: 'Hello world!' }); + + channel.assertOutputContains('world'); + + expect(() => channel.assertOutputContains('missing')).toThrow('no output containing "missing"'); + }); + + it('should assert last output', async () => { + const channel = new MockChannel(); + await channel.send({ output: 'First' }); + await channel.send({ output: 'Second' }); + + channel.assertLastOutput('Second'); + + expect(() => channel.assertLastOutput('Wrong')).toThrow('last output mismatch'); + }); + + it('should handle isListening state', () => { + const channel = new MockChannel(); + + expect(channel.isListening).toBe(false); + + channel.listen(); + expect(channel.isListening).toBe(true); + + channel.stop(); + expect(channel.isListening).toBe(false); + }); + + it('should receive message with convenience method', async () => { + const channel = new MockChannel(); + const handler = vi.fn().mockResolvedValue(undefined); + channel.onMessage(handler); + + await channel.receiveMessage('Hello', 'conv-123', 'greet', { foo: 'bar' }); + + expect(handler).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Hello', + conversationId: 'conv-123', + intent: 'greet', + context: { foo: 'bar' }, + }) + ); + }); + }); + + describe('MockKnowledge', () => { + it('should create async knowledge with initial chunks', async () => { + const knowledge = await createMockKnowledge({ + initialChunks: [ + { content: 'Test content here', metadata: { source: 'test' } }, + ], + }); + + // Query with same text to match via vector similarity + const results = await knowledge.query('Test content here'); + expect(results.length).toBeGreaterThan(0); + expect(results[0].chunk.content).toBe('Test content here'); + }); + + it('should query with keyword matching', async () => { + const knowledge = createMockKnowledgeSync({ + initialChunks: [ + { content: 'Apple is a fruit', metadata: { category: 'fruit' } }, + { content: 'Banana is yellow', metadata: { category: 'fruit' } }, + { content: 'Carrot is orange', metadata: { category: 'vegetable' } }, + ], + }); + + const results = await knowledge.query('apple'); + + expect(results).toHaveLength(1); + expect(results[0].chunk.content).toBe('Apple is a fruit'); + expect(results[0].score).toBeGreaterThan(0); + }); + + it('should add content', async () => { + const knowledge = createMockKnowledgeSync(); + + const id = await knowledge.add('New content', { source: 'test' }); + + expect(id).toBeDefined(); + expect(knowledge.getAllChunks()).toHaveLength(1); + + const results = await knowledge.query('New'); + expect(results).toHaveLength(1); + }); + + it('should filter by metadata', async () => { + const knowledge = createMockKnowledgeSync({ + initialChunks: [ + { content: 'Apple fruit', metadata: { category: 'fruit' } }, + { content: 'Carrot vegetable', metadata: { category: 'vegetable' } }, + ], + }); + + const results = await knowledge.query('fruit', { filter: { category: 'fruit' } }); + + expect(results).toHaveLength(1); + expect(results[0].chunk.content).toBe('Apple fruit'); + }); + + it('should clear all chunks', async () => { + const knowledge = createMockKnowledgeSync({ + initialChunks: [{ content: 'Test' }], + }); + + knowledge.clear(); + + expect(knowledge.getAllChunks()).toHaveLength(0); + }); + + it('should convert to tool', async () => { + const knowledge = createMockKnowledgeSync({ + initialChunks: [{ content: 'Test info' }], + }); + + const tool = knowledge.toTool(); + + expect(tool.name).toBe('knowledge_search'); + expect(tool.description).toBeDefined(); + expect(tool.parameters).toBeDefined(); + + const results = await tool.execute({ query: 'info' }); + expect(results).toHaveLength(1); + expect(results[0].content).toBe('Test info'); + }); + + it('should respect limit option', async () => { + const knowledge = createMockKnowledgeSync({ + initialChunks: [ + { content: 'One number' }, + { content: 'Two number' }, + { content: 'Three number' }, + ], + }); + + const results = await knowledge.query('number', { limit: 2 }); + + expect(results).toHaveLength(2); + }); + }); + + describe('createTestAgent', () => { + class TestAgent extends BaseAgent { + name = 'test-agent'; + description = 'A test agent'; + mode = CHAT_MODE; + + async invokeAgent(input: AgentInput): Promise { + const result = await this.run(input.message || ''); + return result; + } + } + + it('should create agent with mock channel', async () => { + const { agent, channel } = createTestAgent(TestAgent); + + expect(agent).toBeInstanceOf(TestAgent); + expect(channel).toBeInstanceOf(MockChannel); + expect(agent.name).toBe('test-agent'); + }); + + it('should route messages through channel to agent', async () => { + const { channel } = createTestAgent(TestAgent, { + mockResponses: [{ trigger: 'hello', response: 'Hi there!' }], + }); + + await channel.receiveMessage('hello', 'conv-1'); + + expect(channel.lastOutput?.output).toBe('Hi there!'); + }); + + it('should return default response when no trigger matches', async () => { + const { channel } = createTestAgent(TestAgent, { + defaultResponse: 'Default answer', + }); + + await channel.receiveMessage('unknown query', 'conv-1'); + + expect(channel.lastOutput?.output).toBe('Default answer'); + }); + + it('should add more mock responses', async () => { + const { channel, addMockResponse } = createTestAgent(TestAgent); + + addMockResponse({ trigger: 'new', response: 'New response' }); + + await channel.receiveMessage('new', 'conv-1'); + + expect(channel.lastOutput?.output).toBe('New response'); + }); + + it('should support regex triggers', async () => { + const { channel } = createTestAgent(TestAgent, { + mockResponses: [{ trigger: /\d+/, response: 'Number detected' }], + }); + + await channel.receiveMessage('The answer is 42', 'conv-1'); + + expect(channel.lastOutput?.output).toBe('Number detected'); + }); + }); + + describe('createMockToolpackSimple', () => { + it('should return same response for all calls', async () => { + const toolpack = createMockToolpackSimple('Always this'); + + const result1 = await toolpack.generate({ messages: [{ role: 'user', content: 'Q1' }], model: 'gpt-4' }); + const result2 = await toolpack.generate({ messages: [{ role: 'user', content: 'Q2' }], model: 'gpt-4' }); + + expect(result1.content).toBe('Always this'); + expect(result2.content).toBe('Always this'); + }); + }); + + describe('createMockToolpackSequence', () => { + it('should return responses in sequence', async () => { + const toolpack = createMockToolpackSequence(['First', 'Second', 'Third']); + + const result1 = await toolpack.generate({ messages: [], model: 'gpt-4' }); + const result2 = await toolpack.generate({ messages: [], model: 'gpt-4' }); + const result3 = await toolpack.generate({ messages: [], model: 'gpt-4' }); + const result4 = await toolpack.generate({ messages: [], model: 'gpt-4' }); + + expect(result1.content).toBe('First'); + expect(result2.content).toBe('Second'); + expect(result3.content).toBe('Third'); + expect(result4.content).toBe('No more mock responses'); + }); + }); + + describe('captureEvents', () => { + class EventfulAgent extends BaseAgent { + name = 'eventful-agent'; + description = 'An agent that emits events'; + mode = CHAT_MODE; + + async invokeAgent(input: AgentInput): Promise { + this.emit('agent:start', { message: input.message }); + this.emit('agent:complete', { output: 'Done' }); + return { output: 'Done' }; + } + } + + it('should capture agent events', async () => { + const toolpack = createMockToolpackSimple(); + const agent = new EventfulAgent({ toolpack }); + const capture = captureEvents(agent); + + await agent.invokeAgent({ message: 'Test' }); + + expect(capture.hasEvent('agent:start')).toBe(true); + expect(capture.hasEvent('agent:complete')).toBe(true); + expect(capture.count).toBe(2); + }); + + it('should get events by name', async () => { + const toolpack = createMockToolpackSimple(); + const agent = new EventfulAgent({ toolpack }); + const capture = captureEvents(agent); + + await agent.invokeAgent({ message: 'Test' }); + + const startEvents = capture.getEvents('agent:start'); + expect(startEvents).toHaveLength(1); + expect(startEvents[0].name).toBe('agent:start'); + }); + + it('should get first and last events', async () => { + const toolpack = createMockToolpackSimple(); + const agent = new EventfulAgent({ toolpack }); + const capture = captureEvents(agent); + + await agent.invokeAgent({ message: 'Test' }); + + expect(capture.getFirstEvent('agent:start')).toBeDefined(); + expect(capture.getLastEvent('agent:complete')).toBeDefined(); + }); + + it('should clear events', async () => { + const toolpack = createMockToolpackSimple(); + const agent = new EventfulAgent({ toolpack }); + const capture = captureEvents(agent); + + await agent.invokeAgent({ message: 'Test' }); + capture.clear(); + + expect(capture.count).toBe(0); + expect(capture.hasEvent('agent:start')).toBe(false); + }); + + it('should assert event presence', async () => { + const toolpack = createMockToolpackSimple(); + const agent = new EventfulAgent({ toolpack }); + const capture = captureEvents(agent); + + await agent.invokeAgent({ message: 'Test' }); + + capture.assertEvent('agent:start'); + capture.assertEvent('agent:complete'); + + expect(() => capture.assertEvent('agent:error')).toThrow( + 'expected event "agent:error" was not captured' + ); + }); + + it('should assert event absence', async () => { + const toolpack = createMockToolpackSimple(); + const agent = new EventfulAgent({ toolpack }); + const capture = captureEvents(agent); + + await agent.invokeAgent({ message: 'Test' }); + + capture.assertNoEvent('agent:error'); + + expect(() => capture.assertNoEvent('agent:start')).toThrow( + 'unexpected event "agent:start" was captured' + ); + }); + + it('should stop capturing and remove listeners', async () => { + const toolpack = createMockToolpackSimple(); + const agent = new EventfulAgent({ toolpack }); + const capture = captureEvents(agent); + + capture.stop(); + await agent.invokeAgent({ message: 'Test' }); + + // Events were emitted but capture was stopped + expect(capture.count).toBe(0); + }); + }); +}); diff --git a/packages/toolpack-agents/src/testing/index.ts b/packages/toolpack-agents/src/testing/index.ts new file mode 100644 index 0000000..ca66673 --- /dev/null +++ b/packages/toolpack-agents/src/testing/index.ts @@ -0,0 +1,29 @@ +// Testing utilities for toolpack-agents +// Provides mocks, helpers, and utilities for testing agents in isolation + +// Mock Channel +export { MockChannel } from './mock-channel.js'; + +// Mock Knowledge +export { createMockKnowledge, createMockKnowledgeSync, MockKnowledge } from './mock-knowledge.js'; +export type { MockKnowledgeOptions } from './mock-knowledge.js'; + +// Test Agent Factory +export { + createTestAgent, + createMockToolpackSimple, + createMockToolpackSequence, +} from './create-test-agent.js'; +export type { + MockResponse, + CreateTestAgentOptions, + TestAgentResult, +} from './create-test-agent.js'; + +// Event Capture +export { captureEvents, registerEventMatchers } from './capture-events.js'; +export type { + AgentEventName, + CapturedEvent, + EventCapture, +} from './capture-events.js'; diff --git a/packages/toolpack-agents/src/testing/mock-channel.ts b/packages/toolpack-agents/src/testing/mock-channel.ts new file mode 100644 index 0000000..dd9ea08 --- /dev/null +++ b/packages/toolpack-agents/src/testing/mock-channel.ts @@ -0,0 +1,201 @@ +import { AgentInput, AgentOutput, ChannelInterface } from '../agent/types.js'; + +/** + * Mock channel for testing agents without external integrations. + * Simulates a channel that can receive messages and capture outputs. + * + * @example + * ```ts + * const mockChannel = new MockChannel(); + * + * // Simulate an incoming message + * await mockChannel.receive({ + * message: 'Analyse this week\'s leads', + * intent: 'morning_analysis', + * conversationId: 'test-thread-1', + * }); + * + * // Assert what the agent sent back + * expect(mockChannel.lastOutput?.output).toContain('leads'); + * expect(mockChannel.outputs).toHaveLength(1); + * ``` + */ +export class MockChannel implements ChannelInterface { + name = 'mock-channel'; + isTriggerChannel = false; + + private _handler?: (input: AgentInput) => Promise; + private _outputs: AgentOutput[] = []; + private _inputs: AgentInput[] = []; + private _listening = false; + + /** + * All outputs sent to this channel. + */ + get outputs(): AgentOutput[] { + return [...this._outputs]; + } + + /** + * The most recent output sent to this channel, or undefined if none. + */ + get lastOutput(): AgentOutput | undefined { + return this._outputs[this._outputs.length - 1]; + } + + /** + * All inputs received by this channel. + */ + get inputs(): AgentInput[] { + return [...this._inputs]; + } + + /** + * The most recent input received by this channel, or undefined if none. + */ + get lastInput(): AgentInput | undefined { + return this._inputs[this._inputs.length - 1]; + } + + /** + * Number of messages received. + */ + get receivedCount(): number { + return this._inputs.length; + } + + /** + * Number of outputs sent. + */ + get sentCount(): number { + return this._outputs.length; + } + + /** + * Whether the channel is currently "listening". + */ + get isListening(): boolean { + return this._listening; + } + + /** + * Set the message handler. Called by AgentRegistry. + */ + onMessage(handler: (input: AgentInput) => Promise): void { + this._handler = handler; + } + + /** + * Start listening. Called by AgentRegistry. + * For MockChannel, this just sets a flag. + */ + listen(): void { + this._listening = true; + } + + /** + * Stop listening. + */ + stop(): void { + this._listening = false; + } + + /** + * Send output to this channel. + * Captures the output for later assertions. + */ + async send(output: AgentOutput): Promise { + this._outputs.push(output); + } + + /** + * Normalize an incoming event into AgentInput. + */ + normalize(incoming: unknown): AgentInput { + const data = incoming as Record; + return { + intent: data.intent as string | undefined, + message: data.message as string | undefined, + data: data.data, + context: (data.context as Record) || {}, + conversationId: (data.conversationId as string) || 'test-conversation-1', + }; + } + + /** + * Simulate receiving a message on this channel. + * Normalizes the input and invokes the registered handler. + * + * @param incoming The raw incoming message data + * @returns A promise that resolves when the handler completes + * @throws If no handler is registered (channel not wired to agent) + */ + async receive(incoming: unknown): Promise { + if (!this._handler) { + throw new Error('MockChannel: no message handler registered. Call onMessage() first or ensure channel is registered with AgentRegistry.'); + } + + const input = this.normalize(incoming); + this._inputs.push(input); + await this._handler(input); + } + + /** + * Simulate receiving a message with a specific conversation ID. + * + * @param message The message text + * @param conversationId The conversation ID + * @param intent Optional intent + * @param context Optional context + */ + async receiveMessage( + message: string, + conversationId = 'test-conversation-1', + intent?: string, + context?: Record + ): Promise { + await this.receive({ + message, + conversationId, + intent, + context, + }); + } + + /** + * Clear all captured inputs and outputs. + */ + clear(): void { + this._inputs = []; + this._outputs = []; + } + + /** + * Assert that an output containing the given text was sent. + * + * @param text The text to search for + * @throws If no matching output is found + */ + assertOutputContains(text: string): void { + const found = this._outputs.some(o => o.output.includes(text)); + if (!found) { + throw new Error(`MockChannel: no output containing "${text}" found. Outputs: ${JSON.stringify(this._outputs.map(o => o.output))}`); + } + } + + /** + * Assert that the last output matches the expected text. + * + * @param expected The expected text + * @throws If the last output doesn't match + */ + assertLastOutput(expected: string): void { + const last = this.lastOutput; + if (!last) { + throw new Error(`MockChannel: no output sent. Expected: "${expected}"`); + } + if (last.output !== expected) { + throw new Error(`MockChannel: last output mismatch.\nExpected: "${expected}"\nActual: "${last.output}"`); + } + } +} diff --git a/packages/toolpack-agents/src/testing/mock-knowledge.ts b/packages/toolpack-agents/src/testing/mock-knowledge.ts new file mode 100644 index 0000000..21acdc1 --- /dev/null +++ b/packages/toolpack-agents/src/testing/mock-knowledge.ts @@ -0,0 +1,291 @@ +import type { Knowledge } from '@toolpack-sdk/knowledge'; +import type { Chunk, Embedder, QueryOptions, QueryResult } from '@toolpack-sdk/knowledge'; + +/** + * Options for creating mock knowledge. + */ +export interface MockKnowledgeOptions { + /** Initial chunks to populate the knowledge base */ + initialChunks?: Array<{ + content: string; + metadata?: Record; + }>; + /** Dimensions for the mock embedder (default: 384) */ + dimensions?: number; + /** Description for the knowledge tool */ + description?: string; +} + +/** + * Creates an in-memory mock Knowledge instance for testing. + * No embedder, no provider needed — everything is in-memory. + * + * @example + * ```ts + * const knowledge = await createMockKnowledge({ + * initialChunks: [ + * { content: 'Lead: Acme Corp, score: 85', metadata: { source: 'crm' } }, + * ], + * }); + * + * // Use with SDK + * const toolpack = await Toolpack.init({ + * provider: 'openai', + * knowledge, // Available to all agents + * }); + * ``` + */ +export async function createMockKnowledge( + options: MockKnowledgeOptions = {} +): Promise { + const { Knowledge } = await import('@toolpack-sdk/knowledge'); + const { MemoryProvider } = await import('@toolpack-sdk/knowledge'); + + const dimensions = options.dimensions ?? 384; + + // Create a mock embedder that generates pseudo-random vectors + const mockEmbedder: Embedder = { + dimensions, + async embed(text: string): Promise { + // Generate a deterministic "random" vector based on the text + const vector: number[] = []; + let seed = 0; + for (let i = 0; i < text.length; i++) { + seed = (seed + text.charCodeAt(i)) % 1000; + } + for (let i = 0; i < dimensions; i++) { + // Simple pseudo-random based on seed and position + const val = Math.sin(seed * (i + 1)) * 0.5 + 0.5; + vector.push(val); + } + return vector; + }, + async embedBatch(texts: string[]): Promise { + return Promise.all(texts.map(t => this.embed(t))); + }, + }; + + const provider = new MemoryProvider(); + await provider.validateDimensions(dimensions); + + // Add initial chunks if provided + if (options.initialChunks && options.initialChunks.length > 0) { + const chunks: Chunk[] = []; + for (const item of options.initialChunks) { + const vector = await mockEmbedder.embed(item.content); + chunks.push({ + id: `mock-${Date.now()}-${Math.random().toString(36).slice(2)}`, + content: item.content, + metadata: item.metadata || {}, + vector, + }); + } + await provider.add(chunks); + } + + return Knowledge.create({ + provider, + embedder: mockEmbedder, + sources: [], + description: options.description ?? 'Mock knowledge base for testing', + reSync: false, + }); +} + +/** + * Synchronous version of createMockKnowledge for simple test cases. + * Returns a mock knowledge-like object that's not a full Knowledge instance + * but implements the key methods needed for testing. + * + * This is useful when you don't want to deal with async setup in tests. + */ +export function createMockKnowledgeSync( + options: MockKnowledgeOptions = {} +): MockKnowledge { + const dimensions = options.dimensions ?? 384; + const chunks: Chunk[] = []; + + // Generate deterministic vector + const generateVector = (text: string): number[] => { + const vector: number[] = []; + let seed = 0; + for (let i = 0; i < text.length; i++) { + seed = (seed + text.charCodeAt(i)) % 1000; + } + for (let i = 0; i < dimensions; i++) { + const val = Math.sin(seed * (i + 1)) * 0.5 + 0.5; + vector.push(val); + } + return vector; + }; + + // Add initial chunks + if (options.initialChunks) { + for (const item of options.initialChunks) { + chunks.push({ + id: `mock-${Date.now()}-${Math.random().toString(36).slice(2)}`, + content: item.content, + metadata: item.metadata || {}, + vector: generateVector(item.content), + }); + } + } + + return new MockKnowledge(chunks, generateVector, options.description); +} + +/** + * A simplified mock Knowledge for synchronous test setup. + * Implements the key methods that agents use: query() and add() + */ +export class MockKnowledge { + private chunks: Chunk[]; + private generateVector: (text: string) => number[]; + private _description: string; + + constructor( + initialChunks: Chunk[] = [], + generateVector: (text: string) => number[], + description = 'Mock knowledge base' + ) { + this.chunks = [...initialChunks]; + this.generateVector = generateVector; + this._description = description; + } + + /** + * Query the mock knowledge base using simple keyword matching. + * This doesn't do real semantic search but is sufficient for most tests. + */ + async query(text: string, options?: QueryOptions): Promise { + const limit = options?.limit ?? 10; + const filter = options?.filter; + + // Simple keyword matching + const keywords = text.toLowerCase().split(/\s+/); + + const results = this.chunks + .filter(chunk => { + // Apply metadata filter if provided + if (filter) { + for (const [key, value] of Object.entries(filter)) { + if (chunk.metadata[key] !== value) { + return false; + } + } + } + return true; + }) + .map(chunk => { + const chunkText = chunk.content.toLowerCase(); + // Score based on keyword matches + let score = 0; + for (const keyword of keywords) { + if (chunkText.includes(keyword)) { + score += 0.3; + // Bonus for exact word match + if (new RegExp(`\\b${keyword}\\b`).test(chunkText)) { + score += 0.2; + } + } + } + // Cap at 1.0 + score = Math.min(score, 1); + + return { + chunk: { + id: chunk.id, + content: chunk.content, + metadata: options?.includeMetadata === false ? {} : chunk.metadata, + vector: options?.includeVectors ? chunk.vector : undefined, + }, + score, + distance: 1 - score, + }; + }) + .filter(r => r.score > 0) + .sort((a, b) => b.score - a.score) + .slice(0, limit); + + return results; + } + + /** + * Add content to the mock knowledge base. + */ + async add(content: string, metadata?: Record): Promise { + const id = `mock-${Date.now()}-${Math.random().toString(36).slice(2)}`; + this.chunks.push({ + id, + content, + metadata: metadata || {}, + vector: this.generateVector(content), + }); + return id; + } + + /** + * Get all chunks in the knowledge base. + */ + getAllChunks(): Chunk[] { + return [...this.chunks]; + } + + /** + * Clear all chunks. + */ + clear(): void { + this.chunks = []; + } + + /** + * Convert to a tool format for use with agents. + */ + toTool(): { + name: string; + displayName: string; + description: string; + category: string; + cacheable: boolean; + parameters: { + type: string; + properties: Record; + required: string[]; + }; + execute: (params: { + query: string; + limit?: number; + threshold?: number; + filter?: Record; + }) => Promise }>>; + } { + return { + name: 'knowledge_search', + displayName: 'Knowledge Search', + description: this._description, + category: 'search', + cacheable: false, + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query to find relevant information' }, + limit: { type: 'number', description: 'Maximum number of results to return (default: 10)' }, + threshold: { type: 'number', description: 'Minimum similarity threshold 0-1 (default: 0.7)' }, + filter: { type: 'object', description: 'Optional metadata filters' }, + }, + required: ['query'], + }, + execute: async (params) => { + const results = await this.query(params.query, { + limit: params.limit, + filter: params.filter as QueryOptions['filter'], + }); + return results.map(r => ({ + content: r.chunk.content, + score: r.score, + metadata: r.chunk.metadata, + })); + }, + }; + } +} diff --git a/packages/toolpack-agents/src/transport/delegation.test.ts b/packages/toolpack-agents/src/transport/delegation.test.ts new file mode 100644 index 0000000..0cd8932 --- /dev/null +++ b/packages/toolpack-agents/src/transport/delegation.test.ts @@ -0,0 +1,362 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { BaseAgent } from '../agent/base-agent.js'; +import { AgentRegistry } from '../agent/agent-registry.js'; +import { LocalTransport } from './local-transport.js'; +import { JsonRpcTransport } from './jsonrpc-transport.js'; +import { AgentJsonRpcServer } from './jsonrpc-server.js'; +import type { AgentInput, AgentResult, BaseAgentOptions } from '../agent/types.js'; +import type { Toolpack } from 'toolpack-sdk'; +import { CHAT_MODE, CODING_MODE } from 'toolpack-sdk'; + +// Mock Toolpack +const createMockToolpack = (): Toolpack => ({ + generate: vi.fn().mockResolvedValue({ content: 'Mock response' }), + setMode: vi.fn(), + registerMode: vi.fn(), +} as unknown as Toolpack); + +// Test agents +class DataAgent extends BaseAgent { + name = 'data-agent'; + description = 'Generates data reports'; + mode = CODING_MODE; + + constructor(options: BaseAgentOptions) { + super(options); + } + + async invokeAgent(input: AgentInput): Promise { + return { + output: `Data report for: ${input.message}`, + metadata: { delegatedBy: input.context?.delegatedBy }, + }; + } +} + +class EmailAgent extends BaseAgent { + name = 'email-agent'; + description = 'Sends emails'; + mode = CHAT_MODE; + + constructor(options: BaseAgentOptions) { + super(options); + } + + async invokeAgent(input: AgentInput): Promise { + // Test delegation + if (input.message?.includes('with report')) { + const report = await this.delegateAndWait('data-agent', { + message: 'Generate weekly report', + intent: 'generate_report', + }); + return { + output: `Email sent with: ${report.output}`, + }; + } + + return { + output: `Email sent: ${input.message}`, + }; + } +} + +class CoordinatorAgent extends BaseAgent { + name = 'coordinator'; + description = 'Coordinates other agents'; + mode = CHAT_MODE; + + constructor(options: BaseAgentOptions) { + super(options); + } + + async invokeAgent(input: AgentInput): Promise { + // Fire-and-forget delegation + await this.delegate('data-agent', { + message: 'Background task', + }); + + return { + output: 'Coordinator task complete', + }; + } +} + +describe('Agent Delegation', () => { + describe('LocalTransport (same process)', () => { + let toolpack: Toolpack; + let registry: AgentRegistry; + + beforeEach(async () => { + toolpack = createMockToolpack(); + const dataAgent = new DataAgent({ toolpack }); + const emailAgent = new EmailAgent({ toolpack }); + const coordinatorAgent = new CoordinatorAgent({ toolpack }); + registry = new AgentRegistry([dataAgent, emailAgent, coordinatorAgent]); + await registry.start(); + }); + + it('should delegate to another agent and wait for result', async () => { + const emailAgent = registry.getAgent('email-agent'); + expect(emailAgent).toBeDefined(); + + const result = await emailAgent!.invokeAgent({ + message: 'Send email with report', + conversationId: 'test-1', + }); + + expect(result.output).toContain('Email sent with:'); + expect(result.output).toContain('Data report for: Generate weekly report'); + }); + + it('should include delegatedBy in context', async () => { + const emailAgent = registry.getAgent('email-agent'); + + // Manually test delegation with context + const result = await (emailAgent as any).delegateAndWait('data-agent', { + message: 'Generate report', + }); + + expect(result.metadata?.delegatedBy).toBe('email-agent'); + }); + + it('should support fire-and-forget delegation', async () => { + const coordinator = registry.getAgent('coordinator'); + const result = await coordinator!.invokeAgent({ + message: 'Start coordination', + conversationId: 'test-3', + }); + + expect(result.output).toBe('Coordinator task complete'); + }); + + it('should throw error if agent not found', async () => { + const transport = new LocalTransport(registry); + + await expect( + transport.invoke('non-existent-agent', { + message: 'test', + conversationId: 'test-4', + }) + ).rejects.toThrow('Agent "non-existent-agent" not found'); + }); + + it('should throw error if registry not set', async () => { + const agent = new DataAgent({ toolpack }); + // Don't register with registry + + await expect( + (agent as any).delegateAndWait('email-agent', { message: 'test' }) + ).rejects.toThrow('Agent not registered'); + }); + }); + + describe('JsonRpcTransport (cross-process)', () => { + let server: AgentJsonRpcServer; + let toolpack: Toolpack; + const SERVER_PORT = 3456; + + beforeEach(async () => { + toolpack = createMockToolpack(); + + // Start JSON-RPC server with agents + server = new AgentJsonRpcServer({ port: SERVER_PORT }); + server.registerAgent('data-agent', new DataAgent({ toolpack })); + server.listen(); + + // Wait for server to start + await new Promise(resolve => setTimeout(resolve, 100)); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('should invoke remote agent via JSON-RPC', async () => { + const transport = new JsonRpcTransport({ + agents: { + 'data-agent': `http://localhost:${SERVER_PORT}`, + }, + }); + + const result = await transport.invoke('data-agent', { + message: 'Generate report', + conversationId: 'test-rpc-1', + }); + + expect(result.output).toBe('Data report for: Generate report'); + }); + + it('should work with AgentRegistry using JsonRpcTransport', async () => { + const transport = new JsonRpcTransport({ + agents: { + 'data-agent': `http://localhost:${SERVER_PORT}`, + }, + }); + + const emailAgent = new EmailAgent({ toolpack }); + const registry = new AgentRegistry([emailAgent], { transport }); + await registry.start(); + + const retrievedEmailAgent = registry.getAgent('email-agent'); + const result = await retrievedEmailAgent!.invokeAgent({ + message: 'Send email with report', + conversationId: 'test-rpc-2', + }); + + expect(result.output).toContain('Email sent with:'); + expect(result.output).toContain('Data report for:'); + }); + + it('should throw error if agent not in transport config', async () => { + const transport = new JsonRpcTransport({ + agents: { + 'data-agent': `http://localhost:${SERVER_PORT}`, + }, + }); + + await expect( + transport.invoke('non-existent-agent', { + message: 'test', + conversationId: 'test-rpc-3', + }) + ).rejects.toThrow('Agent "non-existent-agent" not found in transport configuration'); + }); + + it('should throw error if server returns error', async () => { + const transport = new JsonRpcTransport({ + agents: { + 'unknown-agent': `http://localhost:${SERVER_PORT}`, + }, + }); + + await expect( + transport.invoke('unknown-agent', { + message: 'test', + conversationId: 'test-rpc-4', + }) + ).rejects.toThrow('JSON-RPC error'); + }); + + it('should throw error if server is unreachable', async () => { + const transport = new JsonRpcTransport({ + agents: { + 'data-agent': 'http://localhost:9999', // Wrong port + }, + }); + + await expect( + transport.invoke('data-agent', { + message: 'test', + conversationId: 'test-rpc-5', + }) + ).rejects.toThrow('Failed to invoke agent'); + }); + }); + + describe('Hybrid (Local + Remote)', () => { + let server: AgentJsonRpcServer; + let toolpack: Toolpack; + const SERVER_PORT = 3457; + + beforeEach(async () => { + toolpack = createMockToolpack(); + + // Start JSON-RPC server with DataAgent + server = new AgentJsonRpcServer({ port: SERVER_PORT }); + server.registerAgent('data-agent', new DataAgent({ toolpack })); + server.listen(); + + await new Promise(resolve => setTimeout(resolve, 100)); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('should support hybrid local and remote agents', async () => { + // EmailAgent is local, DataAgent is remote + const transport = new JsonRpcTransport({ + agents: { + 'data-agent': `http://localhost:${SERVER_PORT}`, + }, + }); + + const emailAgent = new EmailAgent({ toolpack }); + const registry = new AgentRegistry([emailAgent], { transport }); + await registry.start(); + + const retrievedEmailAgent = registry.getAgent('email-agent'); + const result = await retrievedEmailAgent!.invokeAgent({ + message: 'Send email with report', + conversationId: 'test-hybrid-1', + }); + + expect(result.output).toContain('Email sent with:'); + expect(result.output).toContain('Data report for:'); + }); + }); + + describe('JSON-RPC Server', () => { + let server: AgentJsonRpcServer; + let toolpack: Toolpack; + const SERVER_PORT = 3458; + + beforeEach(() => { + toolpack = createMockToolpack(); + server = new AgentJsonRpcServer({ port: SERVER_PORT }); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('should register multiple agents', () => { + server.registerAgent('data-agent', new DataAgent({ toolpack })); + server.registerAgent('email-agent', new EmailAgent({ toolpack })); + + expect((server as any).agents.size).toBe(2); + }); + + it('should handle invalid JSON-RPC requests', async () => { + server.registerAgent('data-agent', new DataAgent({ toolpack })); + server.listen(); + await new Promise(resolve => setTimeout(resolve, 100)); + + const response = await fetch(`http://localhost:${SERVER_PORT}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '1.0', // Wrong version + method: 'agent.invoke:data-agent', + params: { message: 'test' }, + id: 1, + }), + }); + + const result = await response.json(); + expect(result.error).toBeDefined(); + expect(result.error.message).toContain('Invalid Request'); + }); + + it('should handle unknown methods', async () => { + server.registerAgent('data-agent', new DataAgent({ toolpack })); + server.listen(); + await new Promise(resolve => setTimeout(resolve, 100)); + + const response = await fetch(`http://localhost:${SERVER_PORT}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'unknown.method', + params: { message: 'test' }, + id: 1, + }), + }); + + const result = await response.json(); + expect(result.error).toBeDefined(); + expect(result.error.message).toContain('Method not found'); + }); + }); +}); diff --git a/packages/toolpack-agents/src/transport/index.ts b/packages/toolpack-agents/src/transport/index.ts new file mode 100644 index 0000000..09ccbf2 --- /dev/null +++ b/packages/toolpack-agents/src/transport/index.ts @@ -0,0 +1,11 @@ +// Transport layer for agent-to-agent communication + +// Types +export type { AgentTransport, AgentRegistryTransportOptions } from './types.js'; + +// Local transport (same process) +export { LocalTransport } from './local-transport.js'; + +// JSON-RPC transport (cross-process) +export { JsonRpcTransport } from './jsonrpc-transport.js'; +export { AgentJsonRpcServer } from './jsonrpc-server.js'; diff --git a/packages/toolpack-agents/src/transport/jsonrpc-server.ts b/packages/toolpack-agents/src/transport/jsonrpc-server.ts new file mode 100644 index 0000000..5ec09dc --- /dev/null +++ b/packages/toolpack-agents/src/transport/jsonrpc-server.ts @@ -0,0 +1,214 @@ +import http from 'http'; +import type { BaseAgent } from '../agent/base-agent.js'; +import type { AgentInput, AgentResult } from '../agent/types.js'; + +/** + * JSON-RPC 2.0 request format + */ +interface JsonRpcRequest { + jsonrpc: '2.0'; + method: string; + params?: AgentInput; + id?: string | number | null; +} + +/** + * JSON-RPC 2.0 response format + */ +interface JsonRpcResponse { + jsonrpc: '2.0'; + result?: AgentResult; + error?: { + code: number; + message: string; + data?: unknown; + }; + id: string | number | null; +} + +/** + * JSON-RPC 2.0 server for hosting multiple agents. + * Exposes agents via standard JSON-RPC protocol over HTTP. + * + * @example + * ```ts + * const server = new AgentJsonRpcServer({ port: 3000 }); + * server.registerAgent('data-agent', new DataAgent(toolpack)); + * server.registerAgent('email-agent', new EmailAgent(toolpack)); + * server.listen(); + * ``` + */ +export class AgentJsonRpcServer { + private agents: Map = new Map(); + private server?: http.Server; + private port: number; + + constructor(options: { port: number }) { + this.port = options.port; + } + + /** + * Register an agent with the server. + * @param name The agent name (used in JSON-RPC method calls) + * @param agent The agent instance + */ + registerAgent(name: string, agent: BaseAgent): void { + this.agents.set(name, agent); + } + + /** + * Start the JSON-RPC server. + */ + listen(): void { + this.server = http.createServer(async (req, res) => { + if (req.method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Method not allowed' })); + return; + } + + let body = ''; + req.on('data', chunk => { + body += chunk.toString(); + }); + + req.on('end', async () => { + try { + const request = JSON.parse(body) as JsonRpcRequest; + const response = await this.handleRequest(request); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(response)); + } catch (error) { + const errorResponse: JsonRpcResponse = { + jsonrpc: '2.0', + error: { + code: -32700, + message: 'Parse error', + data: error instanceof Error ? error.message : String(error), + }, + id: null, + }; + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(errorResponse)); + } + }); + }); + + this.server.listen(this.port, () => { + console.log(`[AgentJsonRpcServer] Listening on port ${this.port}`); + console.log(`[AgentJsonRpcServer] Registered agents: ${Array.from(this.agents.keys()).join(', ')}`); + }); + } + + /** + * Stop the server. + */ + async stop(): Promise { + return new Promise((resolve, reject) => { + if (!this.server) { + resolve(); + return; + } + + this.server.close((err) => { + if (err) { + reject(err); + } else { + console.log('[AgentJsonRpcServer] Server stopped'); + resolve(); + } + }); + }); + } + + /** + * Handle a JSON-RPC request. + */ + private async handleRequest(request: JsonRpcRequest): Promise { + // Validate JSON-RPC version + if (request.jsonrpc !== '2.0') { + return { + jsonrpc: '2.0', + error: { + code: -32600, + message: 'Invalid Request', + data: 'jsonrpc must be "2.0"', + }, + id: request.id || null, + }; + } + + // Parse method - format: "agent.invoke:agent-name" + const [method, agentName] = request.method.split(':'); + + if (method !== 'agent.invoke') { + return { + jsonrpc: '2.0', + error: { + code: -32601, + message: 'Method not found', + data: `Unknown method: ${request.method}`, + }, + id: request.id || null, + }; + } + + if (!agentName) { + return { + jsonrpc: '2.0', + error: { + code: -32602, + message: 'Invalid params', + data: 'Agent name required in method (e.g., "agent.invoke:data-agent")', + }, + id: request.id || null, + }; + } + + const agent = this.agents.get(agentName); + if (!agent) { + return { + jsonrpc: '2.0', + error: { + code: -32602, + message: 'Invalid params', + data: `Agent "${agentName}" not found. Available: ${Array.from(this.agents.keys()).join(', ')}`, + }, + id: request.id || null, + }; + } + + if (!request.params) { + return { + jsonrpc: '2.0', + error: { + code: -32602, + message: 'Invalid params', + data: 'params (AgentInput) required', + }, + id: request.id || null, + }; + } + + try { + const result = await agent.invokeAgent(request.params); + return { + jsonrpc: '2.0', + result, + id: request.id || null, + }; + } catch (error) { + return { + jsonrpc: '2.0', + error: { + code: -32603, + message: 'Internal error', + data: error instanceof Error ? error.message : String(error), + }, + id: request.id || null, + }; + } + } +} diff --git a/packages/toolpack-agents/src/transport/jsonrpc-transport.ts b/packages/toolpack-agents/src/transport/jsonrpc-transport.ts new file mode 100644 index 0000000..815fd8e --- /dev/null +++ b/packages/toolpack-agents/src/transport/jsonrpc-transport.ts @@ -0,0 +1,110 @@ +import type { AgentInput, AgentResult } from '../agent/types.js'; +import type { AgentTransport } from './types.js'; +import { AgentError } from '../agent/errors.js'; + +/** + * JSON-RPC 2.0 request format + */ +interface JsonRpcRequest { + jsonrpc: '2.0'; + method: string; + params: AgentInput; + id: string | number; +} + +/** + * JSON-RPC 2.0 response format + */ +interface JsonRpcResponse { + jsonrpc: '2.0'; + result?: AgentResult; + error?: { + code: number; + message: string; + data?: unknown; + }; + id: string | number | null; +} + +/** + * JSON-RPC transport for cross-process agent communication. + * Calls remote agents via JSON-RPC 2.0 over HTTP. + * + * @example + * ```ts + * const transport = new JsonRpcTransport({ + * agents: { + * 'data-agent': 'http://localhost:3000', + * 'research-agent': 'http://remote-server:3000', + * } + * }); + * + * const registry = new AgentRegistry(registrations, { transport }); + * ``` + */ +export class JsonRpcTransport implements AgentTransport { + private agentUrls: Map; + + constructor(options: { + /** Map of agent names to their JSON-RPC server URLs */ + agents: Record; + }) { + this.agentUrls = new Map(Object.entries(options.agents)); + } + + async invoke(agentName: string, input: AgentInput): Promise { + const url = this.agentUrls.get(agentName); + + if (!url) { + throw new AgentError( + `Agent "${agentName}" not found in transport configuration. ` + + `Available agents: ${Array.from(this.agentUrls.keys()).join(', ')}` + ); + } + + const request: JsonRpcRequest = { + jsonrpc: '2.0', + method: `agent.invoke:${agentName}`, + params: input, + id: Date.now(), + }; + + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + throw new AgentError( + `HTTP ${response.status}: ${response.statusText}` + ); + } + + const jsonRpcResponse = await response.json() as JsonRpcResponse; + + if (jsonRpcResponse.error) { + throw new AgentError( + `JSON-RPC error (${jsonRpcResponse.error.code}): ${jsonRpcResponse.error.message}` + + (jsonRpcResponse.error.data ? ` - ${JSON.stringify(jsonRpcResponse.error.data)}` : '') + ); + } + + if (!jsonRpcResponse.result) { + throw new AgentError('No result in JSON-RPC response'); + } + + return jsonRpcResponse.result; + } catch (error) { + if (error instanceof AgentError) { + throw error; + } + throw new AgentError( + `Failed to invoke agent "${agentName}" at ${url}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } +} diff --git a/packages/toolpack-agents/src/transport/local-transport.ts b/packages/toolpack-agents/src/transport/local-transport.ts new file mode 100644 index 0000000..bdd0617 --- /dev/null +++ b/packages/toolpack-agents/src/transport/local-transport.ts @@ -0,0 +1,66 @@ +import { randomUUID } from 'crypto'; +import type { AgentInput, AgentResult, IAgentRegistry } from '../agent/types.js'; +import type { AgentTransport } from './types.js'; +import type { ConversationStore, StoredMessage } from '../history/types.js'; +import { AgentError } from '../agent/errors.js'; + +/** + * Local transport for same-process agent delegation. + * Resolves agents via the AgentRegistry and calls invokeAgent() directly. + * + * Also captures delegated exchanges into the target agent's `conversationHistory` + * so that the peer-agent's store reflects the full dialogue, including turns + * initiated by other agents rather than human users. + */ +export class LocalTransport implements AgentTransport { + constructor(private registry: IAgentRegistry) {} + + async invoke(agentName: string, input: AgentInput): Promise { + const agent = this.registry.getAgent(agentName); + + if (!agent) { + throw new AgentError( + `Agent "${agentName}" not found in registry. ` + + `Available agents: ${this.registry.getAllAgents().map(a => a.name).join(', ')}` + ); + } + + // Access the target agent's conversation store (present on BaseAgent instances). + const store = (agent as unknown as { conversationHistory?: ConversationStore }).conversationHistory; + const conversationId = input.conversationId; + const delegatingAgentName = input.context?.delegatedBy as string | undefined; + + // Capture the inbound delegated message as an 'agent' turn so the target's + // history reflects who sent it. + if (store && conversationId && delegatingAgentName) { + const inbound: StoredMessage = { + id: (input.context?.messageId as string | undefined) ?? randomUUID(), + conversationId, + participant: { kind: 'agent', id: delegatingAgentName, displayName: delegatingAgentName }, + content: input.message ?? '', + timestamp: new Date().toISOString(), + scope: 'channel', + metadata: {}, + }; + try { await store.append(inbound); } catch { /* non-fatal — history errors must not crash the pipeline */ } + } + + const result = await agent.invokeAgent(input); + + // Capture the target agent's reply so it appears in the store alongside the inbound. + if (store && conversationId) { + const reply: StoredMessage = { + id: randomUUID(), + conversationId, + participant: { kind: 'agent', id: agentName, displayName: agentName }, + content: result.output, + timestamp: new Date().toISOString(), + scope: 'channel', + metadata: {}, + }; + try { await store.append(reply); } catch { /* non-fatal */ } + } + + return result; + } +} diff --git a/packages/toolpack-agents/src/transport/types.ts b/packages/toolpack-agents/src/transport/types.ts new file mode 100644 index 0000000..1fec7b4 --- /dev/null +++ b/packages/toolpack-agents/src/transport/types.ts @@ -0,0 +1,23 @@ +import type { AgentInput, AgentResult } from '../agent/types.js'; + +/** + * Transport interface for agent-to-agent communication. + * Enables pluggable transport mechanisms (local, JSON-RPC, etc.) + */ +export interface AgentTransport { + /** + * Invoke a remote agent by name. + * @param agentName The name of the target agent + * @param input The input to send to the agent + * @returns The agent's result + */ + invoke(agentName: string, input: AgentInput): Promise; +} + +/** + * Options for configuring the AgentRegistry transport. + */ +export interface AgentRegistryTransportOptions { + /** Transport implementation for cross-process communication */ + transport?: AgentTransport; +} diff --git a/packages/toolpack-agents/tests/integration/_helpers/mock-slack-workspace.ts b/packages/toolpack-agents/tests/integration/_helpers/mock-slack-workspace.ts new file mode 100644 index 0000000..3a4598a --- /dev/null +++ b/packages/toolpack-agents/tests/integration/_helpers/mock-slack-workspace.ts @@ -0,0 +1,171 @@ +import { BaseChannel } from '../../../src/channels/base-channel.js'; +import type { AgentInput, AgentOutput } from '../../../src/agent/types.js'; + +export interface PostRecord { + agentName: string; + channelId: string; + text: string; + metadata?: Record; +} + +/** + * A lightweight mock Slack channel that: + * - Skips HTTP server creation (listen is a no-op) + * - Accepts events via dispatchEvent() + * - Captures outbound sends into MockSlackWorkspace.posts + * - Replicates SlackChannel's channel-allowlist filtering + */ +export class MockSlackChannel extends BaseChannel { + readonly isTriggerChannel = false; + + private workspace: MockSlackWorkspace; + private allowedChannels: string[] | null; + + constructor( + workspace: MockSlackWorkspace, + allowedChannels: string[] | null, + name?: string, + ) { + super(); + this.workspace = workspace; + this.allowedChannels = allowedChannels; + this.name = name; + } + + listen(): void {} + + async send(output: AgentOutput): Promise { + const meta = output.metadata as Record | undefined; + const channelId = + (meta?.channelId as string | undefined) ?? + this.allowedChannels?.[0] ?? + 'unknown'; + + this.workspace.capturePost({ + agentName: this.name ?? 'unknown', + channelId, + text: output.output, + metadata: meta, + }); + } + + normalize(incoming: unknown): AgentInput { + const ev = incoming as Record; + const channelId = ev.channel as string | undefined; + const userId = ev.user as string | undefined; + const text = (ev.text as string | undefined) ?? ''; + const channelType = ev.channel_type as string | undefined; + + return { + message: text, + conversationId: channelId ?? '', + participant: userId ? { kind: 'user', id: userId } : undefined, + context: { + user: userId, + channel: channelId, + channelId, + channelType, + }, + }; + } + + /** Mirror of SlackChannel.shouldProcessEvent (channel-allowlist + DM pass-through). */ + shouldProcessEvent(ev: Record): boolean { + if (this.allowedChannels !== null) { + const channelType = ev.channel_type as string | undefined; + const isDM = channelType === 'im' || channelType === 'mpim'; + if (!isDM) { + const ch = ev.channel as string | undefined; + if (!ch || !this.allowedChannels.includes(ch)) return false; + } + } + return true; + } + + /** Inject a Slack-like event directly into this channel. */ + async dispatchEvent(ev: Record): Promise { + if (this.shouldProcessEvent(ev)) { + await this.handleMessage(this.normalize(ev)); + } + } +} + +/** + * Simulates a Slack workspace for integration testing. + * + * Usage: + * const ws = new MockSlackWorkspace(); + * const ch = ws.createChannel('strategist-slack', ['#team', '#general'], 'strategist-slack'); + * await ws.postFromHuman('#team', 'U_HUMAN', 'Hello @strategist'); + * ws.posts // captured outbound messages + */ +export class MockSlackWorkspace { + posts: PostRecord[] = []; + private channels: MockSlackChannel[] = []; + + /** Create a MockSlackChannel and register it with this workspace. */ + createChannel( + allowedChannels: string[] | null, + name?: string, + ): MockSlackChannel { + const ch = new MockSlackChannel(this, allowedChannels, name); + this.channels.push(ch); + return ch; + } + + capturePost(record: PostRecord): void { + this.posts.push(record); + } + + /** Broadcast a human message to every channel that accepts it. */ + async postFromHuman( + channelId: string, + userId: string, + text: string, + ): Promise { + const ev = { + type: 'message', + channel: channelId, + channel_type: 'channel', + user: userId, + text, + ts: String(Date.now() / 1000), + }; + for (const ch of this.channels) { + await ch.dispatchEvent(ev); + } + } + + /** Broadcast a DM to every channel that accepts DMs (channel_type: 'im'). */ + async postDM( + dmChannelId: string, + userId: string, + text: string, + ): Promise { + const ev = { + type: 'message', + channel: dmChannelId, + channel_type: 'im', + user: userId, + text, + ts: String(Date.now() / 1000), + }; + for (const ch of this.channels) { + await ch.dispatchEvent(ev); + } + } + + /** Posts captured for a specific channelId. */ + visiblePosts(channelId: string): PostRecord[] { + return this.posts.filter(p => p.channelId === channelId); + } + + /** Posts captured from a specific agent. */ + postsFrom(agentName: string): PostRecord[] { + return this.posts.filter(p => p.agentName === agentName); + } + + reset(): void { + this.posts = []; + } +} diff --git a/packages/toolpack-agents/tests/integration/_helpers/scripted-llm.ts b/packages/toolpack-agents/tests/integration/_helpers/scripted-llm.ts new file mode 100644 index 0000000..6ca2091 --- /dev/null +++ b/packages/toolpack-agents/tests/integration/_helpers/scripted-llm.ts @@ -0,0 +1,67 @@ +import type { Toolpack } from 'toolpack-sdk'; + +export interface DelegationSpec { + to: string; + message: string; +} + +export interface ScriptEntry { + match: RegExp | string; + reply: string; + delegations?: DelegationSpec[]; +} + +type AgentScripts = Record; + +/** + * A Toolpack.generate-compatible mock that returns deterministic responses + * per agent name and message pattern. Used exclusively by TestAgent. + */ +export class ScriptedLLM { + private scripts: AgentScripts; + + constructor(scripts: AgentScripts) { + this.scripts = scripts; + } + + getEntry(agentName: string, message: string): ScriptEntry | undefined { + const entries = this.scripts[agentName]; + if (!entries) return undefined; + for (const entry of entries) { + const hit = + typeof entry.match === 'string' + ? message.includes(entry.match) + : entry.match.test(message); + if (hit) return entry; + } + return undefined; + } + + /** Returns a Toolpack.generate-compatible function bound to agentName. */ + makeGenerate(agentName: string): Toolpack['generate'] { + return async (request: unknown) => { + const req = request as { messages: Array<{ role: string; content: string }> }; + const lastUser = [...req.messages].reverse().find(m => m.role === 'user'); + const message = lastUser?.content ?? ''; + const entry = this.getEntry(agentName, message); + return { + content: entry?.reply ?? `[${agentName}] no script matched: "${message.slice(0, 60)}"`, + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }; + }; + } + + /** Build a minimal Toolpack mock for the given agent. */ + makeToolpack(agentName: string): Toolpack { + const generate = this.makeGenerate(agentName); + return { + generate, + setMode: () => {}, + registerMode: () => {}, + setProvider: () => {}, + setModel: () => {}, + on: () => {}, + off: () => {}, + } as unknown as Toolpack; + } +} diff --git a/packages/toolpack-agents/tests/integration/_helpers/test-agent.ts b/packages/toolpack-agents/tests/integration/_helpers/test-agent.ts new file mode 100644 index 0000000..807cbe0 --- /dev/null +++ b/packages/toolpack-agents/tests/integration/_helpers/test-agent.ts @@ -0,0 +1,59 @@ +import { BaseAgent } from '../../../src/agent/base-agent.js'; +import type { AgentInput, AgentResult, ChannelInterface } from '../../../src/agent/types.js'; +import type { ScriptedLLM } from './scripted-llm.js'; + +export interface TestAgentConfig { + name: string; + scriptedLLM: ScriptedLLM; + channels?: ChannelInterface[]; +} + +/** + * Minimal BaseAgent subclass for integration tests. + * + * invokeAgent behaviour: + * 1. Looks up the current script entry for this agent + message. + * 2. If the entry lists delegations, runs each via delegateAndWait (parallel), + * then calls run() with an "aggregated results" message so the next script + * entry (the synthesis step) can produce the final reply. + * 3. Otherwise calls run() with the original message. + */ +export class TestAgent extends BaseAgent { + name: string; + description: string; + mode = 'chat'; + + private scriptedLLM: ScriptedLLM; + + constructor(config: TestAgentConfig) { + super({ toolpack: config.scriptedLLM.makeToolpack(config.name) }); + this.name = config.name; + this.description = `Integration test agent: ${config.name}`; + this.scriptedLLM = config.scriptedLLM; + if (config.channels) this.channels = config.channels; + } + + async invokeAgent(input: AgentInput): Promise { + const message = input.message ?? ''; + const entry = this.scriptedLLM.getEntry(this.name, message); + + if (entry?.delegations && entry.delegations.length > 0) { + const results = await Promise.all( + entry.delegations.map(d => + this.delegateAndWait(d.to, { + message: d.message, + conversationId: input.conversationId, + }), + ), + ); + const aggregated = results.map(r => r.output).join('\n'); + return this.run( + `aggregated results: ${aggregated}`, + undefined, + { conversationId: input.conversationId }, + ); + } + + return this.run(message, undefined, { conversationId: input.conversationId }); + } +} diff --git a/packages/toolpack-agents/tests/integration/channel-subscription-gates.test.ts b/packages/toolpack-agents/tests/integration/channel-subscription-gates.test.ts new file mode 100644 index 0000000..a4b9fd8 --- /dev/null +++ b/packages/toolpack-agents/tests/integration/channel-subscription-gates.test.ts @@ -0,0 +1,106 @@ +/** + * §4.2 — Channel subscription gates observation (Pillar 3) + * + * Verifies that an agent NOT invited to a channel never receives events + * posted there — its conversation store for that channel remains empty — + * and therefore cannot reference the confidential content. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { AgentRegistry } from '../../src/agent/agent-registry.js'; +import { ScriptedLLM } from './_helpers/scripted-llm.js'; +import { TestAgent } from './_helpers/test-agent.js'; +import { MockSlackWorkspace } from './_helpers/mock-slack-workspace.js'; + +const EXEC_CHANNEL = 'CEXEC'; +const GENERAL_CHANNEL = 'CGENERAL'; + +let workspace: MockSlackWorkspace; +let registry: AgentRegistry; +let strategist: TestAgent; +let frontendAgent: TestAgent; + +beforeEach(async () => { + workspace = new MockSlackWorkspace(); + + const llm = new ScriptedLLM({ + Strategist: [ + { match: /.*/, reply: 'Strategist received the message.' }, + ], + Frontend: [ + { match: /anything from #exec/, reply: 'I have no information about #exec.' }, + ], + }); + + // Strategist → both #exec and #general + const strategistExec = workspace.createChannel([EXEC_CHANNEL], 'strategist-exec'); + const strategistGeneral = workspace.createChannel([GENERAL_CHANNEL], 'strategist-general'); + + // Frontend → #general ONLY (not #exec) + const frontendGeneral = workspace.createChannel([GENERAL_CHANNEL], 'frontend-general'); + + strategist = new TestAgent({ + name: 'Strategist', + scriptedLLM: llm, + channels: [strategistExec, strategistGeneral], + }); + + frontendAgent = new TestAgent({ + name: 'Frontend', + scriptedLLM: llm, + channels: [frontendGeneral], + }); + + registry = new AgentRegistry([strategist, frontendAgent]); + await registry.start(); +}); + +afterEach(async () => { + await registry.stop(); +}); + +describe('Pillar 3 — channel subscription gates observation', () => { + it('Strategist receives #exec event and stores it', async () => { + await workspace.postFromHuman(EXEC_CHANNEL, 'U_EXEC', 'Confidential note: target Q4.'); + + const history = await strategist.conversationHistory.get(EXEC_CHANNEL); + expect(history.length).toBeGreaterThan(0); + expect(history.some(t => t.content.includes('Confidential note'))).toBe(true); + }); + + it('Frontend does NOT receive the #exec event — store is empty for that channel', async () => { + await workspace.postFromHuman(EXEC_CHANNEL, 'U_EXEC', 'Confidential note: target Q4.'); + + const frontendExecHistory = await frontendAgent.conversationHistory.get(EXEC_CHANNEL); + expect(frontendExecHistory.length).toBe(0); + }); + + it('Frontend store search for #exec content returns nothing', async () => { + await workspace.postFromHuman(EXEC_CHANNEL, 'U_EXEC', 'Confidential note: target Q4.'); + + const results = await frontendAgent.conversationHistory.search( + EXEC_CHANNEL, + 'Confidential target Q4', + { limit: 10 }, + ); + expect(results.length).toBe(0); + }); + + it('Frontend CAN receive #general events independently', async () => { + await workspace.postFromHuman(GENERAL_CHANNEL, 'U_HUMAN', 'Anything from #exec recently?'); + + const frontendGeneralHistory = await frontendAgent.conversationHistory.get(GENERAL_CHANNEL); + expect(frontendGeneralHistory.some(t => t.content.includes('#exec'))).toBe(true); + }); + + it('Strategist outbound post in #exec is captured; Frontend post array has no #exec entries', async () => { + await workspace.postFromHuman(EXEC_CHANNEL, 'U_EXEC', 'Confidential note: target Q4.'); + + const strategistPosts = workspace.postsFrom('strategist-exec'); + expect(strategistPosts.length).toBeGreaterThan(0); + // Frontend never fired into #exec + const frontendExecPosts = workspace.posts.filter( + p => p.agentName.startsWith('frontend') && p.channelId === EXEC_CHANNEL, + ); + expect(frontendExecPosts.length).toBe(0); + }); +}); diff --git a/packages/toolpack-agents/tests/integration/conversation-search-isolation.test.ts b/packages/toolpack-agents/tests/integration/conversation-search-isolation.test.ts new file mode 100644 index 0000000..6082ab0 --- /dev/null +++ b/packages/toolpack-agents/tests/integration/conversation-search-isolation.test.ts @@ -0,0 +1,98 @@ +/** + * §4.3 — Conversation isolation checks (Pillar 2) + * + * Verifies conversation-level isolation properties in integration flow: + * - turns are stored under the conversation they arrived in + * - searching a different conversation does not surface those turns + * - search results stay scoped to the queried conversationId + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { AgentRegistry } from '../../src/agent/agent-registry.js'; +import { ScriptedLLM } from './_helpers/scripted-llm.js'; +import { TestAgent } from './_helpers/test-agent.js'; +import { MockSlackWorkspace } from './_helpers/mock-slack-workspace.js'; + +const TEAM_CHANNEL = 'CTEAM'; +const DM_CHANNEL = 'DM_STRATEGIST_HUMAN'; + +let workspace: MockSlackWorkspace; +let registry: AgentRegistry; +let strategist: TestAgent; + +beforeEach(async () => { + workspace = new MockSlackWorkspace(); + + const llm = new ScriptedLLM({ + Strategist: [ + { match: /team channel/, reply: 'Strategist reply in #team channel.' }, + { match: /anything from/, reply: 'I only know what was said in this DM.' }, + ], + }); + + const teamChannel = workspace.createChannel([TEAM_CHANNEL], 'strategist-team'); + const dmChannel = workspace.createChannel(null, 'strategist-dm'); + + strategist = new TestAgent({ + name: 'Strategist', + scriptedLLM: llm, + channels: [teamChannel, dmChannel], + }); + + registry = new AgentRegistry([strategist]); + + await registry.start(); +}); + +afterEach(async () => { + await registry.stop(); +}); + +describe('Pillar 2 — conversation-scoped search', () => { + it('team-channel messages are stored under TEAM_CHANNEL', async () => { + // Plant a turn in #team conversation + await workspace.postFromHuman(TEAM_CHANNEL, 'U_HUMAN', 'Message in team channel'); + + // Now check what was stored under TEAM_CHANNEL + const teamTurns = await strategist.conversationHistory.get(TEAM_CHANNEL); + expect(teamTurns.length).toBeGreaterThan(0); + expect(teamTurns.some(t => t.content.includes('team channel') || t.content.includes('Message in'))).toBe(true); + }); + + it('searching DM conversation cannot reach #team turns', async () => { + // Seed #team with identifiable content + await workspace.postFromHuman(TEAM_CHANNEL, 'U_HUMAN', 'Confidential team message XYZ123'); + + // Confirm the #team conversation has the content + const teamTurns = await strategist.conversationHistory.get(TEAM_CHANNEL); + expect(teamTurns.some(t => t.content.includes('XYZ123'))).toBe(true); + + // DM conversation is separate — search it and verify #team content is absent + const dmTurns = await strategist.conversationHistory.get(DM_CHANNEL); + const foundInDM = dmTurns.some(t => t.content.includes('XYZ123')); + expect(foundInDM).toBe(false); + + // Direct store search: searching DM_CHANNEL for XYZ123 returns nothing + // even though it exists in TEAM_CHANNEL. + const dmSearchResults = await strategist.conversationHistory.search( + DM_CHANNEL, + 'XYZ123', + { limit: 10 }, + ); + expect(dmSearchResults.some(r => r.content.includes('XYZ123'))).toBe(false); + }); + + it('search scoped to its own conversationId returns its own turns', async () => { + await workspace.postFromHuman(TEAM_CHANNEL, 'U_HUMAN', 'Message in team channel about dashboards'); + + const results = await strategist.conversationHistory.search( + TEAM_CHANNEL, + 'dashboard', + { limit: 5 }, + ); + // May or may not match depending on store impl, but must not throw and must + // only contain TEAM_CHANNEL turns + for (const r of results) { + expect(r.conversationId).toBe(TEAM_CHANNEL); + } + }); +}); diff --git a/packages/toolpack-agents/tests/integration/delegation-store-isolation.test.ts b/packages/toolpack-agents/tests/integration/delegation-store-isolation.test.ts new file mode 100644 index 0000000..4760687 --- /dev/null +++ b/packages/toolpack-agents/tests/integration/delegation-store-isolation.test.ts @@ -0,0 +1,106 @@ +/** + * §4.5 — Per-agent store isolation under delegation + * + * Verifies that: + * - When Lead delegates to Frontend with a conversationId, Frontend's store + * records the delegated exchange under that conversationId. + * - After the delegation returns, a fresh message to Frontend from a human + * uses its own (independent) conversationId — the delegation scope does + * not bleed into the next unrelated conversation. + * - Strategist's store never contains Frontend's or Backend's reasoning. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { AgentRegistry } from '../../src/agent/agent-registry.js'; +import { ScriptedLLM } from './_helpers/scripted-llm.js'; +import { TestAgent } from './_helpers/test-agent.js'; +import { MockSlackWorkspace } from './_helpers/mock-slack-workspace.js'; + +const TEAM_CHANNEL = 'CTEAM'; +const FRESH_DM = 'DM_FRONTEND_HUMAN'; + +let workspace: MockSlackWorkspace; +let registry: AgentRegistry; +let lead: TestAgent; +let frontend: TestAgent; +let strategist: TestAgent; + +beforeEach(async () => { + workspace = new MockSlackWorkspace(); + + const llm = new ScriptedLLM({ + Lead: [ + { + match: /scope the dashboard/, + reply: '', + delegations: [ + { to: 'Frontend', message: 'Frontend spec: design the component list' }, + ], + }, + { match: /aggregated/, reply: 'Synthesised plan based on frontend input.' }, + ], + Frontend: [ + { match: /component list/, reply: 'Component plan: A, B, C.' }, + { match: /fresh human message/, reply: 'Handling fresh human request independently.' }, + ], + Strategist: [ + { match: /.*/, reply: 'Strategist standing by.' }, + ], + }); + + const leadChannel = workspace.createChannel([TEAM_CHANNEL], 'lead-team'); + const frontendChannel = workspace.createChannel([TEAM_CHANNEL, FRESH_DM], 'frontend-channel'); + const strategistChannel = workspace.createChannel([TEAM_CHANNEL], 'strategist-team'); + + lead = new TestAgent({ name: 'Lead', scriptedLLM: llm, channels: [leadChannel] }); + frontend = new TestAgent({ name: 'Frontend', scriptedLLM: llm, channels: [frontendChannel] }); + strategist = new TestAgent({ name: 'Strategist', scriptedLLM: llm, channels: [strategistChannel] }); + + registry = new AgentRegistry([lead, frontend, strategist]); + await registry.start(); +}); + +afterEach(async () => { + await registry.stop(); +}); + +describe('Per-agent store isolation under delegation', () => { + it('delegated exchange is recorded in the target agent store under the originating conversationId', async () => { + await workspace.postFromHuman(TEAM_CHANNEL, 'U_HUMAN', 'scope the dashboard'); + + // Frontend was delegated by Lead using TEAM_CHANNEL as conversationId + const frontendHistory = await frontend.conversationHistory.get(TEAM_CHANNEL); + expect(frontendHistory.length).toBeGreaterThan(0); + + // The inbound delegated message from Lead should be recorded + const hasLeadMessage = frontendHistory.some( + t => t.content.includes('component list') || t.participant.id === 'Lead', + ); + expect(hasLeadMessage).toBe(true); + }); + + it("Strategist store does not contain Frontend's delegation reasoning", async () => { + await workspace.postFromHuman(TEAM_CHANNEL, 'U_HUMAN', 'scope the dashboard'); + + const strategistHistory = await strategist.conversationHistory.get(TEAM_CHANNEL); + const hasFrontendContent = strategistHistory.some( + t => t.content.includes('Component plan') || t.participant.id === 'Frontend', + ); + expect(hasFrontendContent).toBe(false); + }); + + it('fresh DM to Frontend after delegation uses its own conversationId', async () => { + // First trigger a delegation flow + await workspace.postFromHuman(TEAM_CHANNEL, 'U_HUMAN', 'scope the dashboard'); + + // Now send a completely unrelated DM directly to Frontend + await workspace.postDM(FRESH_DM, 'U_HUMAN2', 'fresh human message for frontend'); + + // The fresh DM must be stored under FRESH_DM, not TEAM_CHANNEL + const freshHistory = await frontend.conversationHistory.get(FRESH_DM); + expect(freshHistory.some(t => t.content.includes('fresh human message'))).toBe(true); + + // Confirm TEAM_CHANNEL content did not leak into FRESH_DM + const freshSearch = await frontend.conversationHistory.search(FRESH_DM, 'component list', { limit: 5 }); + expect(freshSearch.some(r => r.conversationId !== FRESH_DM)).toBe(false); + }); +}); diff --git a/packages/toolpack-agents/tests/integration/knowledge-multi-layer.test.ts b/packages/toolpack-agents/tests/integration/knowledge-multi-layer.test.ts new file mode 100644 index 0000000..0a5f915 --- /dev/null +++ b/packages/toolpack-agents/tests/integration/knowledge-multi-layer.test.ts @@ -0,0 +1,86 @@ +/** + * §4.4 — Multi-layer knowledge merge & promote + * + * Verifies that: + * - knowledge_search returns results from both private (_layer:0) and shared + * (_layer:1) knowledge bases, sorted by score. + * - knowledge_add writes the new entry into the private (index-0) store only. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createMockKnowledgeSync, MockKnowledge } from '../../src/testing/mock-knowledge.js'; + +let privateKB: MockKnowledge; +let sharedKB: MockKnowledge; + +beforeEach(() => { + privateKB = createMockKnowledgeSync({ + initialChunks: [ + { content: 'Strategist private fact: Q4 revenue target is $2M', metadata: { source: 'private' } }, + ], + }); + + sharedKB = createMockKnowledgeSync({ + initialChunks: [ + { content: 'Shared project brief: building a client reporting dashboard', metadata: { source: 'shared' } }, + ], + }); +}); + +describe('Multi-layer knowledge — merge', () => { + it('returns results from both layers tagged with _layer index', async () => { + const layers = [privateKB, sharedKB]; + + // Query each layer and tag results + const allResults = ( + await Promise.all( + layers.map(async (kb, layerIdx) => { + const results = await kb.query('revenue target dashboard', { limit: 5 }); + return results.map(r => ({ ...r, _layer: layerIdx })); + }), + ) + ).flat(); + + // Sort by score desc (mirrors real multi-layer merge behaviour) + allResults.sort((a, b) => b.score - a.score); + + expect(allResults.length).toBeGreaterThanOrEqual(2); + + const layerIndices = allResults.map(r => r._layer); + expect(layerIndices).toContain(0); // private layer present + expect(layerIndices).toContain(1); // shared layer present + + // Scores should be non-negative and descending + for (let i = 0; i < allResults.length - 1; i++) { + expect(allResults[i].score).toBeGreaterThanOrEqual(allResults[i + 1].score); + } + }); + + it('each layer returns its own content', async () => { + const privateResults = await privateKB.query('revenue', { limit: 5 }); + const sharedResults = await sharedKB.query('dashboard', { limit: 5 }); + + expect(privateResults.some(r => r.chunk.content.includes('revenue target'))).toBe(true); + expect(sharedResults.some(r => r.chunk.content.includes('reporting dashboard'))).toBe(true); + }); +}); + +describe('Multi-layer knowledge — knowledge_add promotes to private layer', () => { + it('adds new entry to private KB only', async () => { + const newFact = 'Strategist note: client prefers weekly digests'; + await privateKB.add(newFact, { source: 'private' }); + + const privateAfter = await privateKB.query('weekly digests', { limit: 5 }); + const sharedAfter = await sharedKB.query('weekly digests', { limit: 5 }); + + expect(privateAfter.some(r => r.chunk.content.includes('weekly digests'))).toBe(true); + expect(sharedAfter.some(r => r.chunk.content.includes('weekly digests'))).toBe(false); + }); + + it('private KB grows; shared KB stays unchanged', async () => { + const before = sharedKB.getAllChunks().length; + await privateKB.add('extra private fact', { source: 'private' }); + + expect(privateKB.getAllChunks().length).toBe(2); + expect(sharedKB.getAllChunks().length).toBe(before); + }); +}); diff --git a/packages/toolpack-agents/tests/integration/multi-agent-workflow.test.ts b/packages/toolpack-agents/tests/integration/multi-agent-workflow.test.ts new file mode 100644 index 0000000..13ba31b --- /dev/null +++ b/packages/toolpack-agents/tests/integration/multi-agent-workflow.test.ts @@ -0,0 +1,281 @@ +/** + * §4.1 — End-to-end multi-agent workflow + * + * Scenario: human feature request → Strategist responds → Lead scopes, + * delegates to Frontend + Backend in parallel → Lead synthesises → QA + * reviews via DM. + * + * Verifies all seven goals from §1 of the E2E Integration Test Plan. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { AgentRegistry } from '../../src/agent/agent-registry.js'; +import { ScriptedLLM } from './_helpers/scripted-llm.js'; +import { TestAgent } from './_helpers/test-agent.js'; +import { MockSlackWorkspace } from './_helpers/mock-slack-workspace.js'; + +// Channel / conversation IDs +const TEAM = 'CTEAM'; +const GENERAL = 'CGENERAL'; +const EXEC = 'CEXEC'; +const QA_DM = 'DM_QA_HUMAN'; + +let workspace: MockSlackWorkspace; +let registry: AgentRegistry; +let strategist: TestAgent; +let lead: TestAgent; +let frontend: TestAgent; +let backend: TestAgent; +let qa: TestAgent; +let marketing: TestAgent; + +beforeEach(async () => { + workspace = new MockSlackWorkspace(); + + const llm = new ScriptedLLM({ + Strategist: [ + { + match: /dashboard/, + reply: 'Strategic take: high value. @lead please scope.', + }, + ], + Lead: [ + { + match: /scope/, + reply: '', + delegations: [ + { to: 'Frontend', message: 'Frontend spec: design the component list.' }, + { to: 'Backend', message: 'API design: define endpoints.' }, + ], + }, + { + match: /aggregated/, + reply: 'Plan: frontend + backend aligned. Posting to #team.', + }, + ], + Frontend: [ + { match: /component list/, reply: 'Component plan: A, B, C.' }, + ], + Backend: [ + { match: /endpoints/, reply: 'Endpoints: /reports, /sessions.' }, + ], + QA: [ + { match: /acceptance criteria/, reply: 'QA review: criteria look good.' }, + ], + Marketing: [ + { match: /.*/, reply: 'Marketing standing by.' }, + ], + }); + + // Strategist, Lead, Marketing → #general, #team, #exec + strategist = new TestAgent({ + name: 'Strategist', + scriptedLLM: llm, + channels: [ + workspace.createChannel([GENERAL, TEAM, EXEC], 'strategist-slack'), + ], + }); + + lead = new TestAgent({ + name: 'Lead', + scriptedLLM: llm, + channels: [ + workspace.createChannel([GENERAL, TEAM, EXEC], 'lead-slack'), + ], + }); + + marketing = new TestAgent({ + name: 'Marketing', + scriptedLLM: llm, + channels: [ + workspace.createChannel([GENERAL, TEAM, EXEC], 'marketing-slack'), + ], + }); + + // Frontend, Backend, QA → #general, #team only (NOT #exec) + frontend = new TestAgent({ + name: 'Frontend', + scriptedLLM: llm, + channels: [ + workspace.createChannel([GENERAL, TEAM], 'frontend-slack'), + ], + }); + + backend = new TestAgent({ + name: 'Backend', + scriptedLLM: llm, + channels: [ + workspace.createChannel([GENERAL, TEAM], 'backend-slack'), + ], + }); + + qa = new TestAgent({ + name: 'QA', + scriptedLLM: llm, + channels: [ + workspace.createChannel(null, 'qa-slack'), // accepts DMs too + ], + }); + + registry = new AgentRegistry([strategist, lead, marketing, frontend, backend, qa]); + await registry.start(); +}); + +afterEach(async () => { + await registry.stop(); +}); + +// ─── Goal 1 & 7: Human message reaches addressed agent and triggers coherent response ─── + +describe('Goal 1 — human message reaches agent', () => { + it('Strategist receives the #team message and posts a response', async () => { + await workspace.postFromHuman(TEAM, 'U_HUMAN', 'We need a new dashboard. @strategist thoughts?'); + + const strategistPosts = workspace.postsFrom('strategist-slack'); + expect(strategistPosts.length).toBeGreaterThan(0); + expect(strategistPosts[0].text).toContain('Strategic take'); + }); +}); + +// ─── Goal 2: Inter-agent delegation ─────────────────────────────────────────── + +describe('Goal 2 — inter-agent delegation via delegateAndWait', () => { + it('Lead delegates to Frontend and Backend and synthesises results', async () => { + await workspace.postFromHuman(TEAM, 'U_HUMAN', 'Please scope the dashboard work.'); + + const leadPosts = workspace.postsFrom('lead-slack'); + expect(leadPosts.length).toBeGreaterThan(0); + expect(leadPosts[0].text).toContain('Plan:'); + }); + + it('delegation does not produce Slack posts from Frontend/Backend (local transport only)', async () => { + await workspace.postFromHuman(TEAM, 'U_HUMAN', 'Please scope the dashboard work.'); + + // Frontend and Backend are NOT subscribed to the "scope" message via Slack — + // they only receive it through LocalTransport delegation. + // So their slack channels should not have fired for this particular message. + // (They CAN still post if they received the team broadcast, but the key point + // is their delegation responses travel through LocalTransport, not Slack.) + const frontendDirectPosts = workspace.postsFrom('frontend-slack'); + const backendDirectPosts = workspace.postsFrom('backend-slack'); + + // The delegation message ("Frontend spec: ...") contains "component list" not "scope", + // so the direct Slack post (if any, from the TEAM broadcast) would match the + // "scope" pattern in the LLM — but Frontend's script has no "scope" entry, + // so it would fall to the default no-match reply. That is fine. + // The important assertion is that Lead's synthesis post IS present. + const leadPosts = workspace.postsFrom('lead-slack'); + expect(leadPosts.some(p => p.text.includes('Plan:'))).toBe(true); + }); +}); + +// ─── Goal 3 & 5: Per-agent store isolation ─────────────────────────────────── + +describe('Goal 3 — per-agent conversation store isolation', () => { + it('Frontend store does not contain Strategist reasoning', async () => { + await workspace.postFromHuman(TEAM, 'U_HUMAN', 'We need a new dashboard. @strategist thoughts?'); + + const frontendTeamHistory = await frontend.conversationHistory.get(TEAM); + const hasStrategistReasoning = frontendTeamHistory.some( + t => t.participant.id === 'Strategist' && t.content.includes('Strategic take'), + ); + expect(hasStrategistReasoning).toBe(false); + }); + + it('Strategist store does not contain Frontend or Backend delegation content', async () => { + await workspace.postFromHuman(TEAM, 'U_HUMAN', 'Please scope the dashboard work.'); + + const strategistHistory = await strategist.conversationHistory.get(TEAM); + const hasFrontendContent = strategistHistory.some( + t => t.content.includes('Component plan') || t.participant.id === 'Frontend', + ); + expect(hasFrontendContent).toBe(false); + }); +}); + +// ─── Goal 4: conversation_search scoped ────────────────────────────────────── + +describe('Goal 4 — conversation_search is conversation-scoped', () => { + it('DM search cannot surface #team content', async () => { + await workspace.postFromHuman(TEAM, 'U_HUMAN', 'We need a new dashboard. SECRET_TEAM_TOKEN'); + + const dmResults = await strategist.conversationHistory.search( + 'SOME_OTHER_CONV_ID', + 'SECRET_TEAM_TOKEN', + { limit: 10 }, + ); + expect(dmResults.length).toBe(0); + }); +}); + +// ─── Goal 5: Multi-layer knowledge ─────────────────────────────────────────── +// (Full knowledge tests live in knowledge-multi-layer.test.ts; here we just +// verify agents start with isolated stores — a prerequisite for knowledge isolation.) + +describe('Goal 5 — knowledge isolation pre-condition', () => { + it('each agent has its own independent conversationHistory instance', () => { + expect(strategist.conversationHistory).not.toBe(lead.conversationHistory); + expect(lead.conversationHistory).not.toBe(frontend.conversationHistory); + expect(frontend.conversationHistory).not.toBe(backend.conversationHistory); + }); +}); + +// ─── Goal 6: Channel subscription gating ───────────────────────────────────── + +describe('Goal 6 — channel subscription gates observation', () => { + it('Frontend does not receive #exec events', async () => { + await workspace.postFromHuman(EXEC, 'U_EXEC', 'Confidential exec note.'); + + const frontendExecHistory = await frontend.conversationHistory.get(EXEC); + expect(frontendExecHistory.length).toBe(0); + }); + + it('Strategist receives #exec events', async () => { + await workspace.postFromHuman(EXEC, 'U_EXEC', 'Confidential exec note.'); + + const strategistExecHistory = await strategist.conversationHistory.get(EXEC); + expect(strategistExecHistory.length).toBeGreaterThan(0); + }); +}); + +// ─── Goal 7: Full end-to-end multi-agent workflow ──────────────────────────── + +describe('Goal 7 — full end-to-end workflow', () => { + it('human → Strategist → Lead delegates → synthesis → QA DM all produce correct outputs', async () => { + // Step 1: Human posts feature request in #team + await workspace.postFromHuman( + TEAM, + 'U_HUMAN', + 'We need a new dashboard for client reporting. @strategist thoughts?', + ); + + // Step 2: Human asks Lead to scope in the same channel + await workspace.postFromHuman(TEAM, 'U_HUMAN', 'Please scope the dashboard work.'); + + // Step 3: Human DMs QA + await workspace.postDM(QA_DM, 'U_HUMAN', 'Review acceptance criteria for the dashboard.'); + + // Assert Strategist replied in #team + const strategistPosts = workspace.postsFrom('strategist-slack'); + expect(strategistPosts.some(p => p.channelId === TEAM && p.text.includes('Strategic take'))).toBe(true); + + // Assert Lead posted synthesis in #team (no DMs — delegations are local) + const leadPosts = workspace.postsFrom('lead-slack'); + expect(leadPosts.some(p => p.channelId === TEAM && p.text.includes('Plan:'))).toBe(true); + + // Assert QA replied in DM + const qaPosts = workspace.postsFrom('qa-slack'); + expect(qaPosts.some(p => p.text.includes('QA review'))).toBe(true); + + // Assert delegation exchange recorded in Frontend store + const frontendDelegationHistory = await frontend.conversationHistory.get(TEAM); + expect(frontendDelegationHistory.some(t => t.content.includes('component list') || t.participant.id === 'Lead')).toBe(true); + + // Assert Backend delegation exchange recorded in Backend store + const backendDelegationHistory = await backend.conversationHistory.get(TEAM); + expect(backendDelegationHistory.some(t => t.content.includes('endpoints') || t.participant.id === 'Lead')).toBe(true); + + // Assert per-agent isolation: Strategist store has no Frontend/Backend content + const strategistFull = await strategist.conversationHistory.get(TEAM); + expect(strategistFull.some(t => t.participant.id === 'Frontend' || t.participant.id === 'Backend')).toBe(false); + }); +}); diff --git a/packages/toolpack-agents/tsconfig.json b/packages/toolpack-agents/tsconfig.json new file mode 100644 index 0000000..8b411f1 --- /dev/null +++ b/packages/toolpack-agents/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/toolpack-agents/tsup.config.ts b/packages/toolpack-agents/tsup.config.ts new file mode 100644 index 0000000..21a07e7 --- /dev/null +++ b/packages/toolpack-agents/tsup.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'tsup'; +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); +const pkg = require('./package.json'); + +export default defineConfig({ + entry: { + index: 'src/index.ts', + 'channels/index': 'src/channels/index.ts', + 'testing/index': 'src/testing/index.ts', + 'registry/index': 'src/registry/index.ts', + 'capabilities/index': 'src/capabilities/index.ts', + 'interceptors/index': 'src/interceptors/index.ts', + }, + dts: true, + format: ['esm', 'cjs'], + splitting: false, + sourcemap: false, + clean: true, + outDir: 'dist', + outExtension({ format }) { + return { js: format === 'esm' ? '.js' : '.cjs' }; + }, + external: Object.keys(pkg.peerDependencies || {}), + shims: true, + esbuildOptions(options) { + options.platform = 'node'; + }, + minify: true, +}); diff --git a/packages/toolpack-agents/vitest.config.ts b/packages/toolpack-agents/vitest.config.ts new file mode 100644 index 0000000..f168127 --- /dev/null +++ b/packages/toolpack-agents/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts', 'tests/**/*.test.ts'], + exclude: ['node_modules', 'dist'], + fileParallelism: false, + }, +}); diff --git a/packages/toolpack-knowledge/README.md b/packages/toolpack-knowledge/README.md index bbf92fe..92cc81c 100644 --- a/packages/toolpack-knowledge/README.md +++ b/packages/toolpack-knowledge/README.md @@ -122,6 +122,8 @@ const webSource = new WebUrlSource(['https://docs.example.com'], { userAgent: 'MyApp/1.0', // Custom user agent maxChunkSize: 1500, // Chunk size for web content timeoutMs: 30000, // Request timeout + sameDomainOnly: true, // Only follow links on the same domain (default: true) + maxPagesPerDomain: 20, // Cap pages per domain (default: 10) }); const kb = await Knowledge.create({ @@ -285,6 +287,8 @@ new WebUrlSource(['https://example.com', 'https://docs.example.com'], { maxChunkSize: 2000, // Max tokens per chunk chunkOverlap: 200, // Overlap between chunks timeoutMs: 30000, // Request timeout (default: 30000ms) + sameDomainOnly: true, // Only follow links on the same domain (default: true) + maxPagesPerDomain: 10, // Max pages crawled per domain (default: 10) namespace: 'web', // Chunk ID prefix metadata: { source: 'web' }, // Added to all chunks }) @@ -337,6 +341,67 @@ new ApiDataSource('https://api.example.com/data', { - JSON path support - Flexible content transformation +### JSONSource + +Index data from local JSON files. + +```typescript +import { JSONSource } from '@toolpack-sdk/knowledge'; + +new JSONSource('./data/products.json', { + toContent: (item: any) => `${item.name}\n\n${item.description}`, // Required + filter: (item: any) => item.active === true, // Optional: filter items + chunkSize: 100, // Items per chunk (default: 100) + namespace: 'products', + metadata: { source: 'products-db' }, +}) +``` + +**Features:** +- Parses JSON arrays (or single objects) +- Optional item-level filtering +- Required `toContent` callback to control what gets embedded + +### SQLiteSource + +Index rows from a SQLite database. Requires `better-sqlite3`. + +```typescript +import { SQLiteSource } from '@toolpack-sdk/knowledge'; + +new SQLiteSource('./data/app.db', { + query: 'SELECT id, title, body FROM articles WHERE published = 1', // Optional: defaults to all rows + toContent: (row) => `${row.title}\n\n${row.body}`, // Required + chunkSize: 50, // Rows per chunk (default: 100) + namespace: 'articles', + metadata: { source: 'sqlite' }, + preLoadCSV: { // Optional: load a CSV into the DB before querying + tableName: 'articles', + csvPath: './data/articles.csv', + delimiter: ',', + headers: true, + }, +}) +``` + +### PostgresSource + +Index rows from a PostgreSQL database. Requires `pg`. + +```typescript +import { PostgresSource } from '@toolpack-sdk/knowledge'; + +new PostgresSource({ + connectionString: process.env.DATABASE_URL, // or use host/port/database/user/password + query: 'SELECT id, title, content FROM docs WHERE status = $1', + toContent: (row) => `${row.title}\n\n${row.content}`, // Required + chunkSize: 50, + namespace: 'docs', + metadata: { source: 'postgres' }, + ssl: true, +}) +``` + ## Embedders ### OllamaEmbedder @@ -345,11 +410,34 @@ Local embeddings via Ollama. Zero API cost. ```typescript new OllamaEmbedder({ - model: 'nomic-embed-text', // or 'mxbai-embed-large' + model: 'nomic-embed-text', // or 'mxbai-embed-large', 'all-minilm', 'bge-m3', etc. baseUrl: 'http://localhost:11434', // default + dimensions: 768, // optional: override auto-detected dimensions + retries: 3, // default + retryDelay: 1000, // ms, default +}) +``` + +Known models: `nomic-embed-text` (768), `mxbai-embed-large` (1024), `all-minilm` (384), `snowflake-arctic-embed` (1024), `bge-m3` (1024), `bge-large` (1024). Pass `dimensions` for any other model. + +### OpenRouterEmbedder + +Embeddings via OpenRouter, giving access to OpenAI embedding models through a single API key. + +```typescript +import { OpenRouterEmbedder } from '@toolpack-sdk/knowledge'; + +new OpenRouterEmbedder({ + model: 'openai/text-embedding-3-small', // or 'openai/text-embedding-3-large', 'openai/text-embedding-ada-002' + apiKey: process.env.OPENROUTER_API_KEY!, + dimensions: 1536, // optional: override auto-detected dimensions + retries: 3, // default + retryDelay: 1000, // ms, default }) ``` +Known models: `openai/text-embedding-3-small` (1536), `openai/text-embedding-3-large` (3072), `openai/text-embedding-ada-002` (1536). Pass `dimensions` for any other model. + ### OpenAIEmbedder OpenAI text-embedding models with retry logic. diff --git a/packages/toolpack-knowledge/src/__tests__/json-source.test.ts b/packages/toolpack-knowledge/src/__tests__/json-source.test.ts new file mode 100644 index 0000000..1b8d243 --- /dev/null +++ b/packages/toolpack-knowledge/src/__tests__/json-source.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as os from 'os'; +import { JSONSource } from '../sources/json.js'; + +const defaultToContent = (item: unknown) => + typeof item === 'object' && item !== null + ? (item as { name?: string }).name ?? JSON.stringify(item) + : String(item); + +describe('JSONSource', () => { + let tempDir: string; + let testFile: string; + + beforeAll(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'json-source-test-')); + }); + + afterAll(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + describe('constructor', () => { + it('should throw if toContent is not provided', () => { + expect(() => { + new JSONSource('/path/to/file.json', {} as { toContent: (item: unknown) => string }); + }).toThrow('JSONSource requires a toContent callback'); + }); + }); + + describe('load', () => { + it('should load single object from JSON file', async () => { + testFile = path.join(tempDir, 'single.json'); + await fs.writeFile(testFile, JSON.stringify({ name: 'Test', value: 42 })); + + const source = new JSONSource(testFile, { + toContent: (item) => `Name: ${(item as { name: string }).name}, Value: ${(item as { value: number }).value}`, + }); + const chunks: Awaited>[] = []; + + for await (const chunk of source.load()) { + chunks.push({ value: chunk, done: false } as const); + } + + expect(chunks).toHaveLength(1); + expect(chunks[0].value.content).toContain('Name: Test'); + expect(chunks[0].value.content).toContain('Value: 42'); + expect(chunks[0].value.metadata.type).toBe('json_object'); + }); + + it('should load and chunk array from JSON file', async () => { + testFile = path.join(tempDir, 'array.json'); + const data = Array.from({ length: 10 }, (_, i) => ({ id: i, name: `Item ${i}` })); + await fs.writeFile(testFile, JSON.stringify(data)); + + const source = new JSONSource(testFile, { + chunkSize: 3, + toContent: (item) => `ID: ${(item as { id: number }).id}, Name: ${(item as { name: string }).name}`, + }); + const chunks: Awaited>[] = []; + + for await (const chunk of source.load()) { + chunks.push({ value: chunk, done: false } as const); + } + + expect(chunks).toHaveLength(4); // 3+3+3+1 + expect(chunks[0].value.metadata.totalItems).toBe(10); + expect(chunks[0].value.metadata.startIndex).toBe(0); + expect(chunks[0].value.metadata.endIndex).toBe(3); + expect(chunks[0].value.content).toContain('ID: 0, Name: Item 0'); + }); + + it('should apply filter to array data', async () => { + testFile = path.join(tempDir, 'filtered.json'); + const data = [ + { id: 1, active: true }, + { id: 2, active: false }, + { id: 3, active: true }, + ]; + await fs.writeFile(testFile, JSON.stringify(data)); + + const source = new JSONSource(testFile, { + filter: (item: unknown) => (item as { active: boolean }).active, + toContent: (item) => `ID: ${(item as { id: number }).id}, Active: ${(item as { active: boolean }).active}`, + }); + const chunks: Awaited>[] = []; + + for await (const chunk of source.load()) { + chunks.push({ value: chunk, done: false } as const); + } + + expect(chunks).toHaveLength(1); + expect(chunks[0].value.content).toContain('ID: 1, Active: true'); + expect(chunks[0].value.content).toContain('ID: 3, Active: true'); + expect(chunks[0].value.metadata.totalItems).toBe(2); + }); + + it('should include custom metadata', async () => { + testFile = path.join(tempDir, 'meta.json'); + await fs.writeFile(testFile, JSON.stringify({ test: true })); + + const source = new JSONSource(testFile, { + namespace: 'custom-ns', + metadata: { project: 'test', version: 1 }, + toContent: (item) => `Test: ${(item as { test: boolean }).test}`, + }); + const chunks: Awaited>[] = []; + + for await (const chunk of source.load()) { + chunks.push({ value: chunk, done: false } as const); + } + + expect(chunks[0].value.id).toBe('json:custom-ns:0'); + expect(chunks[0].value.content).toBe('Test: true'); + expect(chunks[0].value.metadata.project).toBe('test'); + expect(chunks[0].value.metadata.version).toBe(1); + }); + + it('should throw on invalid JSON', async () => { + testFile = path.join(tempDir, 'invalid.json'); + await fs.writeFile(testFile, 'not valid json'); + + const source = new JSONSource(testFile, { toContent: defaultToContent }); + await expect(async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of source.load()) { + // consume + } + }).rejects.toThrow('Failed to parse JSON file'); + }); + + it('should throw on missing file', async () => { + const source = new JSONSource('/nonexistent/path/file.json', { toContent: defaultToContent }); + await expect(async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of source.load()) { + // consume + } + }).rejects.toThrow(); + }); + }); +}); diff --git a/packages/toolpack-knowledge/src/__tests__/keyword.test.ts b/packages/toolpack-knowledge/src/__tests__/keyword.test.ts index f0a115d..085c9a8 100644 --- a/packages/toolpack-knowledge/src/__tests__/keyword.test.ts +++ b/packages/toolpack-knowledge/src/__tests__/keyword.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { keywordSearch, combineScores } from '../../dist/index.js'; +import { keywordSearch, combineScores } from '../utils/keyword.js'; describe('keywordSearch', () => { it('should return 1.0 for exact matches', () => { diff --git a/packages/toolpack-knowledge/src/__tests__/knowledge.test.ts b/packages/toolpack-knowledge/src/__tests__/knowledge.test.ts index 319f769..4888561 100644 --- a/packages/toolpack-knowledge/src/__tests__/knowledge.test.ts +++ b/packages/toolpack-knowledge/src/__tests__/knowledge.test.ts @@ -302,4 +302,65 @@ describe('Knowledge', () => { expect(results.length).toBe(2); }); }); + + describe('add', () => { + it('should add single content with metadata', async () => { + const kb = await Knowledge.create({ + provider, + sources: [], + embedder: createMockEmbedder(), + description: 'Test', + }); + + const id = await kb.add('Test conversation message', { + role: 'user', + conversationId: 'conv-123', + timestamp: new Date().toISOString(), + }); + + expect(id).toBeDefined(); + expect(typeof id).toBe('string'); + }); + + it('should add content without metadata', async () => { + const kb = await Knowledge.create({ + provider, + sources: [], + embedder: createMockEmbedder(), + description: 'Test', + }); + + const id = await kb.add('Simple content'); + + expect(id).toBeDefined(); + expect(typeof id).toBe('string'); + }); + + it('should throw on embedder failure', async () => { + const failingEmbedder = createMockEmbedder(); + failingEmbedder.embed = vi.fn().mockRejectedValue(new Error('Embedding failed')); + + const kb = await Knowledge.create({ + provider, + sources: [], + embedder: failingEmbedder, + description: 'Test', + }); + + await expect(kb.add('Test content')).rejects.toThrow('Failed to add content'); + }); + + it('should throw on provider failure', async () => { + provider.add = vi.fn().mockRejectedValue(new Error('Provider error')); + + const kb = await Knowledge.create({ + provider, + sources: [], + embedder: createMockEmbedder(), + description: 'Test', + }); + + await expect(kb.add('Test content')).rejects.toThrow('Failed to add content'); + }); + }); }); diff --git a/packages/toolpack-knowledge/src/__tests__/postgres-source.test.ts b/packages/toolpack-knowledge/src/__tests__/postgres-source.test.ts new file mode 100644 index 0000000..28903d6 --- /dev/null +++ b/packages/toolpack-knowledge/src/__tests__/postgres-source.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, vi } from 'vitest'; +import { PostgresSource } from '../sources/postgres.js'; + +const defaultToContent = (row: Record) => + Object.entries(row).map(([k, v]) => `${k}: ${v}`).join(', '); + +describe('PostgresSource', () => { + describe('constructor', () => { + it('should create with connection string and toContent', () => { + const source = new PostgresSource({ + query: 'SELECT * FROM users', + connectionString: 'postgresql://user:pass@localhost/db', + toContent: defaultToContent, + }); + expect(source).toBeDefined(); + }); + + it('should create with individual config options and toContent', () => { + const source = new PostgresSource({ + query: 'SELECT * FROM users', + host: 'localhost', + port: 5432, + database: 'mydb', + user: 'admin', + password: 'secret', + toContent: defaultToContent, + }); + expect(source).toBeDefined(); + }); + + it('should throw without query', () => { + expect(() => { + new PostgresSource({ toContent: defaultToContent } as { query: string; toContent: (row: Record) => string }); + }).toThrow('PostgresSource requires a query'); + }); + + it('should throw without toContent', () => { + expect(() => { + new PostgresSource({ query: 'SELECT 1' } as { query: string; toContent: (row: Record) => string }); + }).toThrow('PostgresSource requires a toContent callback'); + }); + + it('should use default values', () => { + const source = new PostgresSource({ + query: 'SELECT 1', + database: 'test', + user: 'test', + password: 'test', + toContent: defaultToContent, + }); + expect(source).toBeDefined(); + }); + }); + + describe('load', () => { + it('should throw if pg package is not installed', async () => { + // Mock the import to throw + vi.doMock('pg', () => { + throw new Error('Module not found'); + }); + + const source = new PostgresSource({ + query: 'SELECT * FROM test', + connectionString: 'postgresql://localhost/test', + toContent: defaultToContent, + }); + + await expect(async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of source.load()) { + // consume + } + }).rejects.toThrow('requires "pg" package'); + + vi.doUnmock('pg'); + }); + }); +}); diff --git a/packages/toolpack-knowledge/src/__tests__/sqlite-source.test.ts b/packages/toolpack-knowledge/src/__tests__/sqlite-source.test.ts new file mode 100644 index 0000000..b719b2b --- /dev/null +++ b/packages/toolpack-knowledge/src/__tests__/sqlite-source.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { SQLiteSource } from '../sources/sqlite.js'; + +const defaultToContent = (row: Record) => + Object.entries(row).map(([k, v]) => `${k}: ${v}`).join(', '); + +describe('SQLiteSource', () => { + describe('constructor', () => { + it('should throw if toContent is not provided', () => { + expect(() => { + new SQLiteSource('/path/to/db.sqlite', {} as { toContent: (row: Record) => string }); + }).toThrow('SQLiteSource requires a toContent callback'); + }); + + it('should create with database path and toContent', () => { + const source = new SQLiteSource('/path/to/db.sqlite', { + toContent: defaultToContent, + }); + expect(source).toBeDefined(); + }); + + it('should create with options', () => { + const source = new SQLiteSource('/path/to/db.sqlite', { + namespace: 'myapp', + query: 'SELECT * FROM users', + chunkSize: 50, + metadata: { version: 1 }, + toContent: defaultToContent, + }); + expect(source).toBeDefined(); + }); + }); + + describe('load', () => { + it('should throw if better-sqlite3 is not installed', async () => { + const source = new SQLiteSource('/path/to/db.sqlite', { + toContent: defaultToContent, + }); + + // This will fail if better-sqlite3 is not installed + // The error should be about the package not being found + await expect(async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of source.load()) { + // consume + } + }).rejects.toThrow(); + }); + + it('should throw on non-existent database file', async () => { + const source = new SQLiteSource('/nonexistent/path/db.sqlite', { + toContent: defaultToContent, + }); + + await expect(async () => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of source.load()) { + // consume + } + }).rejects.toThrow('SQLite database file not found'); + }); + }); + + describe('loadCSV', () => { + it('should validate preLoadCSV config structure', () => { + const source = new SQLiteSource('/path/to/db.sqlite', { + toContent: defaultToContent, + preLoadCSV: { + tableName: 'users', + csvPath: '/path/to/data.csv', + }, + }); + expect(source).toBeDefined(); + }); + + it('should accept CSV with custom delimiter', () => { + const source = new SQLiteSource('/path/to/db.sqlite', { + toContent: defaultToContent, + preLoadCSV: { + tableName: 'users', + csvPath: '/path/to/data.tsv', + delimiter: '\t', + headers: true, + }, + }); + expect(source).toBeDefined(); + }); + }); +}); diff --git a/packages/toolpack-knowledge/src/embedders/openrouter.ts b/packages/toolpack-knowledge/src/embedders/openrouter.ts new file mode 100644 index 0000000..8066f2d --- /dev/null +++ b/packages/toolpack-knowledge/src/embedders/openrouter.ts @@ -0,0 +1,91 @@ +import OpenAI from 'openai'; +import { Embedder } from '../interfaces.js'; +import { EmbeddingError } from '../errors.js'; + +const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; + +export interface OpenRouterEmbedderOptions { + model: string; + apiKey: string; + /** Override output dimensions for models not in the built-in map */ + dimensions?: number; + retries?: number; + retryDelay?: number; + timeout?: number; +} + +export class OpenRouterEmbedder implements Embedder { + readonly dimensions: number; + private client: OpenAI; + + constructor(private options: OpenRouterEmbedderOptions) { + this.client = new OpenAI({ + apiKey: options.apiKey, + baseURL: OPENROUTER_BASE_URL, + timeout: options.timeout || 30000, + }); + this.dimensions = options.dimensions ?? this.getModelDimensions(options.model); + } + + private getModelDimensions(model: string): number { + const dimensionsMap: Record = { + 'nvidia/llama-nemotron-embed-vl-1b-v2': 4096, + 'nvidia/llama-nemotron-embed-vl-1b-v2:free': 4096, + 'openai/text-embedding-3-small': 1536, + 'openai/text-embedding-3-large': 3072, + 'openai/text-embedding-ada-002': 1536, + }; + const dims = dimensionsMap[model]; + if (dims === undefined) { + throw new EmbeddingError( + `Unknown OpenRouter embedding model '${model}'. Pass 'dimensions' in OpenRouterEmbedderOptions ` + + `or use a known model: ${Object.keys(dimensionsMap).join(', ')}` + ); + } + return dims; + } + + async embed(text: string): Promise { + let lastError: Error | null = null; + const retries = this.options.retries ?? 3; + + for (let attempt = 0; attempt < retries; attempt++) { + try { + const response = await this.client.embeddings.create({ + model: this.options.model, + input: text, + }); + return response.data[0].embedding; + } catch (error) { + lastError = error as Error; + if (attempt < retries - 1) { + await new Promise(resolve => setTimeout(resolve, this.options.retryDelay ?? 1000)); + } + } + } + + throw new EmbeddingError(`OpenRouter embedding failed after ${retries} retries: ${lastError?.message}`); + } + + async embedBatch(texts: string[]): Promise { + let lastError: Error | null = null; + const retries = this.options.retries ?? 3; + + for (let attempt = 0; attempt < retries; attempt++) { + try { + const response = await this.client.embeddings.create({ + model: this.options.model, + input: texts, + }); + return response.data.map(d => d.embedding); + } catch (error) { + lastError = error as Error; + if (attempt < retries - 1) { + await new Promise(resolve => setTimeout(resolve, this.options.retryDelay ?? 1000)); + } + } + } + + throw new EmbeddingError(`OpenRouter batch embedding failed after ${retries} retries: ${lastError?.message}`); + } +} diff --git a/packages/toolpack-knowledge/src/index.ts b/packages/toolpack-knowledge/src/index.ts index 13a0ef3..2e7eeef 100644 --- a/packages/toolpack-knowledge/src/index.ts +++ b/packages/toolpack-knowledge/src/index.ts @@ -17,11 +17,23 @@ export type { WebUrlSourceOptions } from './sources/web-url.js'; export { ApiDataSource } from './sources/api.js'; export type { ApiDataSourceOptions } from './sources/api.js'; +export { JSONSource } from './sources/json.js'; +export type { JSONSourceOptions } from './sources/json.js'; + +export { SQLiteSource } from './sources/sqlite.js'; +export type { SQLiteSourceOptions } from './sources/sqlite.js'; + +export { PostgresSource } from './sources/postgres.js'; +export type { PostgresSourceOptions } from './sources/postgres.js'; + export { OllamaEmbedder } from './embedders/ollama.js'; export type { OllamaEmbedderOptions } from './embedders/ollama.js'; export { OpenAIEmbedder } from './embedders/openai.js'; export type { OpenAIEmbedderOptions } from './embedders/openai.js'; +export { OpenRouterEmbedder } from './embedders/openrouter.js'; +export type { OpenRouterEmbedderOptions } from './embedders/openrouter.js'; + // Utility functions export { keywordSearch, combineScores } from './utils/keyword.js'; diff --git a/packages/toolpack-knowledge/src/knowledge.ts b/packages/toolpack-knowledge/src/knowledge.ts index 13790bd..031575e 100644 --- a/packages/toolpack-knowledge/src/knowledge.ts +++ b/packages/toolpack-knowledge/src/knowledge.ts @@ -1,6 +1,8 @@ +import { randomUUID } from 'crypto'; import { KnowledgeProvider, KnowledgeSource, Embedder, QueryOptions, QueryResult, Chunk } from './interfaces.js'; import { keywordSearch, combineScores } from './utils/keyword.js'; import { matchesFilter } from './utils/cosine.js'; +import { IngestionError } from './errors.js'; export interface KnowledgeOptions { provider: KnowledgeProvider; @@ -297,6 +299,40 @@ export class Knowledge { return embeddedChunks; } + /** + * Add a single content item to the knowledge base without triggering a full re-sync. + * This is useful for runtime additions like conversation history or agent state. + * @param content The text content to add + * @param metadata Optional metadata to attach to the chunk + * @returns The ID of the added chunk + */ + async add(content: string, metadata?: Record): Promise { + try { + const id = randomUUID(); + + // Embed the content + const vector = await this.embedder.embed(content); + + // Create the chunk + const chunk: Chunk = { + id, + content, + metadata: metadata || {}, + vector, + }; + + // Add to provider + await this.provider.add([chunk]); + + return id; + } catch (error) { + throw new IngestionError( + `Failed to add content to knowledge base: ${(error as Error).message}`, + 'add' + ); + } + } + async stop(): Promise { if (this.provider.close) { this.provider.close(); diff --git a/packages/toolpack-knowledge/src/sources/json.ts b/packages/toolpack-knowledge/src/sources/json.ts new file mode 100644 index 0000000..396ed96 --- /dev/null +++ b/packages/toolpack-knowledge/src/sources/json.ts @@ -0,0 +1,98 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { KnowledgeSource, Chunk } from '../interfaces.js'; +import { IngestionError } from '../errors.js'; + +export interface JSONSourceOptions { + namespace?: string; + metadata?: Record; + filter?: (item: unknown) => boolean; + chunkSize?: number; + /** Required. Transform each JSON item into a human-readable string for AI embedding. */ + toContent: (item: unknown) => string; +} + +/** + * Knowledge source for JSON files. + * Supports jq-like filtering and chunking of large arrays. + */ +export class JSONSource implements KnowledgeSource { + private options: Required; + + constructor( + private filePath: string, + options: JSONSourceOptions + ) { + if (!options.toContent) { + throw new IngestionError( + 'JSONSource requires a toContent callback. Example: toContent: (item) => `Name: ${item.name}`', + this.filePath + ); + } + this.options = { + namespace: options.namespace ?? 'json', + metadata: options.metadata ?? {}, + filter: options.filter ?? (() => true), + chunkSize: options.chunkSize ?? 100, + toContent: options.toContent, + }; + } + + async *load(): AsyncIterable { + let data: unknown; + + try { + const content = await fs.readFile(this.filePath, 'utf-8'); + data = JSON.parse(content); + } catch (error) { + throw new IngestionError( + `Failed to parse JSON file: ${(error as Error).message}`, + this.filePath + ); + } + + // Handle array data with optional filtering + if (Array.isArray(data)) { + const filtered = data.filter(this.options.filter); + + // Transform each item using toContent and join + const contentItems = filtered.map(this.options.toContent); + + // Chunk large arrays + for (let i = 0; i < contentItems.length; i += this.options.chunkSize) { + const chunkItems = contentItems.slice(i, i + this.options.chunkSize); + const chunkContent = chunkItems.join('\n\n---\n\n'); + + yield { + id: `json:${this.options.namespace}:${i}`, + content: chunkContent, + metadata: { + ...this.options.metadata, + source: path.basename(this.filePath), + type: 'json_array_chunk', + startIndex: i, + endIndex: Math.min(i + this.options.chunkSize, contentItems.length), + totalItems: contentItems.length, + }, + }; + } + } else { + // Single object - use toContent if it's an object + const content = typeof data === 'object' && data !== null + ? this.options.toContent(data) + : typeof data === 'string' + ? data + : JSON.stringify(data); + + yield { + id: `json:${this.options.namespace}:0`, + content, + metadata: { + ...this.options.metadata, + source: path.basename(this.filePath), + type: 'json_object', + }, + }; + } + } +} diff --git a/packages/toolpack-knowledge/src/sources/postgres.ts b/packages/toolpack-knowledge/src/sources/postgres.ts new file mode 100644 index 0000000..f1bf0c9 --- /dev/null +++ b/packages/toolpack-knowledge/src/sources/postgres.ts @@ -0,0 +1,118 @@ +import { KnowledgeSource, Chunk } from '../interfaces.js'; +import { IngestionError } from '../errors.js'; + +export interface PostgresSourceOptions { + namespace?: string; + metadata?: Record; + query: string; + chunkSize?: number; + /** Required. Transform each database row into a human-readable string for AI embedding. */ + toContent: (row: Record) => string; + connectionString?: string; + host?: string; + port?: number; + database?: string; + user?: string; + password?: string; + ssl?: boolean; +} + +/** + * Knowledge source for PostgreSQL databases. + * Supports SQL queries with optional chunking. + * Note: This requires the 'pg' package to be installed. + */ +export class PostgresSource implements KnowledgeSource { + private options: Required> & + Pick; + + constructor(options: PostgresSourceOptions) { + if (!options.query) { + throw new IngestionError('PostgresSource requires a query', 'config'); + } + if (!options.toContent) { + throw new IngestionError( + 'PostgresSource requires a toContent callback. Example: toContent: (row) => `Name: ${row.name}`', + 'config' + ); + } + + this.options = { + namespace: options.namespace ?? 'postgres', + metadata: options.metadata ?? {}, + chunkSize: options.chunkSize ?? 100, + toContent: options.toContent, + query: options.query, + connectionString: options.connectionString, + host: options.host, + port: options.port, + database: options.database, + user: options.user, + password: options.password, + ssl: options.ssl, + }; + } + + async *load(): AsyncIterable { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let Client: any; + + try { + // Dynamic import to avoid hard dependency + const pg = await import('pg'); + Client = pg.Client; + } catch { + throw new IngestionError( + 'PostgreSQL source requires "pg" package. Install with: npm install pg', + 'config' + ); + } + + // Build connection config + const clientConfig = this.options.connectionString + ? { connectionString: this.options.connectionString } + : { + host: this.options.host ?? 'localhost', + port: this.options.port ?? 5432, + database: this.options.database, + user: this.options.user, + password: this.options.password, + ssl: this.options.ssl, + }; + + const client = new Client(clientConfig); + + try { + await client.connect(); + + // Execute query + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await client.query(this.options.query); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rows = result.rows as Array>; + + // Transform each row using toContent and chunk + const contentItems = rows.map((row) => this.options.toContent(row)); + + for (let i = 0; i < contentItems.length; i += this.options.chunkSize) { + const chunkItems = contentItems.slice(i, i + this.options.chunkSize); + const chunkContent = chunkItems.join('\n\n---\n\n'); + + yield { + id: `postgres:${this.options.namespace}:${i}`, + content: chunkContent, + metadata: { + ...this.options.metadata, + type: 'postgres_query_result', + query: this.options.query, + startIndex: i, + endIndex: Math.min(i + this.options.chunkSize, contentItems.length), + totalRows: contentItems.length, + }, + }; + } + } finally { + await client.end(); + } + } +} diff --git a/packages/toolpack-knowledge/src/sources/sqlite.ts b/packages/toolpack-knowledge/src/sources/sqlite.ts new file mode 100644 index 0000000..dda1bf1 --- /dev/null +++ b/packages/toolpack-knowledge/src/sources/sqlite.ts @@ -0,0 +1,153 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { KnowledgeSource, Chunk } from '../interfaces.js'; +import { IngestionError } from '../errors.js'; + +export interface SQLiteSourceOptions { + namespace?: string; + metadata?: Record; + query?: string; + chunkSize?: number; + /** Required. Transform each database row into a human-readable string for AI embedding. */ + toContent: (row: Record) => string; + preLoadCSV?: { + tableName: string; + csvPath: string; + delimiter?: string; + headers?: boolean; + }; +} + +/** + * Knowledge source for SQLite databases. + * Supports SQL queries and optional CSV/TSV pre-loading. + * Note: This requires the 'better-sqlite3' package to be installed. + */ +export class SQLiteSource implements KnowledgeSource { + private options: Required> & + Pick; + + constructor( + private dbPath: string, + options: SQLiteSourceOptions + ) { + if (!options.toContent) { + throw new IngestionError( + 'SQLiteSource requires a toContent callback. Example: toContent: (row) => `Name: ${row.name}`', + this.dbPath + ); + } + this.options = { + namespace: options.namespace ?? 'sqlite', + metadata: options.metadata ?? {}, + chunkSize: options.chunkSize ?? 100, + toContent: options.toContent, + query: options.query, + preLoadCSV: options.preLoadCSV, + }; + } + + async *load(): AsyncIterable { + let Database: new (path: string) => { exec: (sql: string) => void; prepare: (sql: string) => { all: () => unknown[] } }; + + try { + // Dynamic import to avoid hard dependency + const sqlite3 = await import('better-sqlite3'); + Database = sqlite3.default; + } catch { + throw new IngestionError( + 'SQLite source requires "better-sqlite3" package. Install with: npm install better-sqlite3', + this.dbPath + ); + } + + // Check if database file exists + try { + await fs.access(this.dbPath); + } catch { + throw new IngestionError('SQLite database file not found', this.dbPath); + } + + const db = new Database(this.dbPath); + + try { + // Pre-load CSV if specified + if (this.options.preLoadCSV) { + await this.loadCSV(db, this.options.preLoadCSV); + } + + // Execute query and yield results + const query = this.options.query ?? 'SELECT * FROM sqlite_master WHERE type = "table"'; + const stmt = db.prepare(query); + const rows = stmt.all(); + + // Transform each row using toContent and chunk + const contentItems = rows.map((row) => this.options.toContent(row as Record)); + + for (let i = 0; i < contentItems.length; i += this.options.chunkSize) { + const chunkItems = contentItems.slice(i, i + this.options.chunkSize); + const chunkContent = chunkItems.join('\n\n---\n\n'); + + yield { + id: `sqlite:${this.options.namespace}:${i}`, + content: chunkContent, + metadata: { + ...this.options.metadata, + source: path.basename(this.dbPath), + type: 'sqlite_query_result', + query, + startIndex: i, + endIndex: Math.min(i + this.options.chunkSize, contentItems.length), + totalRows: contentItems.length, + }, + }; + } + } finally { + db.exec('VACUUM;'); + // Note: better-sqlite3 closes automatically when garbage collected + } + } + + private async loadCSV( + db: { exec: (sql: string) => void }, + config: NonNullable + ): Promise { + const fs = await import('fs'); + const csvContent = await fs.promises.readFile(config.csvPath, 'utf-8'); + + const delimiter = config.delimiter ?? ','; + const lines = csvContent.split('\n').filter(line => line.trim()); + + if (lines.length === 0) { + return; + } + + let headers: string[]; + let dataStartIndex: number; + + if (config.headers !== false) { + headers = lines[0].split(delimiter).map(h => h.trim().replace(/^["']|["']$/g, '')); + dataStartIndex = 1; + } else { + // Generate column names if no headers + const firstRow = lines[0].split(delimiter); + headers = firstRow.map((_, i) => `col${i}`); + dataStartIndex = 0; + } + + // Create table with sanitized table name (alphanumeric and underscore only) + const sanitizedTableName = config.tableName.replace(/[^a-zA-Z0-9_]/g, '_'); + const columns = headers.map(h => `"${h.replace(/"/g, '""')}" TEXT`).join(', '); + db.exec(`DROP TABLE IF EXISTS "${sanitizedTableName}";`); + db.exec(`CREATE TABLE "${sanitizedTableName}" (${columns});`); + + // Insert data using prepared statement (type assertion needed for better-sqlite3 API) + const placeholders = headers.map(() => '?').join(', '); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const insertStmt = (db as any).prepare(`INSERT INTO "${sanitizedTableName}" VALUES (${placeholders})`); + for (let i = dataStartIndex; i < lines.length; i++) { + const values = lines[i].split(delimiter).map(v => v.trim().replace(/^["']|["']$/g, '')); + insertStmt.run(values); + } + } +} diff --git a/packages/toolpack-sdk/README.md b/packages/toolpack-sdk/README.md index bd5ed98..2f08f97 100644 --- a/packages/toolpack-sdk/README.md +++ b/packages/toolpack-sdk/README.md @@ -1,6 +1,6 @@ # Toolpack SDK -A unified TypeScript/Node.js SDK for building AI-powered applications with multiple providers, 90 built-in tools, a workflow engine, and a flexible mode system — all through a single API. +A unified TypeScript/Node.js SDK for building AI-powered applications with multiple providers, 97 built-in tools, a workflow engine, and a flexible mode system — all through a single API. [![npm version](https://img.shields.io/npm/v/toolpack-sdk.svg)](https://www.npmjs.com/package/toolpack-sdk) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) @@ -18,7 +18,7 @@ A unified TypeScript/Node.js SDK for building AI-powered applications with multi - **Mode System** — Built-in Agent and Chat modes, plus `createMode()` for custom modes with tool filtering - **HITL Confirmation** — Human-in-the-loop approval for high-risk operations with configurable bypass rules - **Custom Providers** — Bring your own provider by implementing the `ProviderAdapter` interface -- **90 Built-in Tools** across 11 categories: +- **97 Built-in Tools** across 12 categories: - **MCP Tool Server Integration** — dynamically bridge external Model Context Protocol servers into Toolpack as first-class tools via `createMcpToolProject()` and `disconnectMcpToolProject()`. | Category | Tools | Description | @@ -31,6 +31,7 @@ A unified TypeScript/Node.js SDK for building AI-powered applications with multi | **`http-tools`** | 5 | HTTP requests — GET, POST, PUT, DELETE, download | | **`web-tools`** | 9 | Web interaction — fetch, search (Tavily/Brave/DuckDuckGo), scrape, extract links, map, metadata, sitemap, feed, screenshot | | **`system-tools`** | 5 | System info — env vars, cwd, disk usage, system info, set env | +| **`github-tools`** | 9 | GitHub operations — PR reviews, review threads, file diffs, issue comments, GraphQL, repo contents | | **`diff-tools`** | 3 | Patch operations — create, apply, and preview diffs | | **`cloud-tools`** | 3 | Deployments — deploy, status, list (via Netlify) | | **`k8s-tools`** | 11 | Kubernetes cluster inspection and management via kubectl | @@ -60,7 +61,7 @@ const sdk = await Toolpack.init({ anthropic: {}, // Reads ANTHROPIC_API_KEY from env }, defaultProvider: 'openai', - tools: true, // Load all 90 built-in tools + tools: true, // Load all 97 built-in tools defaultMode: 'agent', // Agent mode with workflow engine }); @@ -548,7 +549,7 @@ client.on('tool:failed', (event) => { /* ... */ }); ## Custom Tools -In addition to the 90 built-in tools, you can create and register your own custom tool projects using `createToolProject()`: +In addition to the 97 built-in tools, you can create and register your own custom tool projects using `createToolProject()`: ```typescript import { Toolpack, createToolProject } from 'toolpack-sdk'; @@ -646,6 +647,355 @@ const response = await toolpack.chat('How do I configure authentication?'); See the [Knowledge package README](../toolpack-knowledge/README.md) for full documentation. +## AI Agents (@toolpack-sdk/agents) + +Build production-ready AI agents with channels, workflows, and event-driven architecture using the companion `@toolpack-sdk/agents` package: + +```bash +npm install @toolpack-sdk/agents +``` + +### What are Agents? + +Agents are autonomous AI systems that: +- **Listen** for events from channels (Slack, webhooks, schedules, etc.) +- **Process** messages using the Toolpack SDK +- **Execute** tasks with full tool access +- **Respond** back through the same or different channels +- **Remember** conversations using knowledge bases + +### Quick Start + +```typescript +import { Toolpack } from 'toolpack-sdk'; +import { BaseAgent, AgentRegistry, SlackChannel } from '@toolpack-sdk/agents'; + +// 1. Create a custom agent +class SupportAgent extends BaseAgent { + name = 'support-agent'; + description = 'Customer support agent that answers questions'; + mode = 'chat'; + + async invokeAgent(input) { + const result = await this.run(input.message); + await this.sendTo('slack-support', result.output); + return result; + } +} + +// 2. Set up channels +const slackChannel = new SlackChannel({ + name: 'slack-support', + token: process.env.SLACK_BOT_TOKEN, + signingSecret: process.env.SLACK_SIGNING_SECRET, +}); + +// 3. Register agent and channels +const registry = new AgentRegistry([ + { agent: SupportAgent, channels: [slackChannel] }, +]); + +// 4. Initialize Toolpack with agents +const sdk = await Toolpack.init({ + provider: 'openai', + tools: true, + agents: registry, +}); + +// Agents now listen and respond automatically! +``` + +### Built-in Agents + +The package includes 4 production-ready agents you can use directly or extend: + +#### ResearchAgent +```typescript +import { ResearchAgent } from '@toolpack-sdk/agents'; + +const agent = new ResearchAgent(sdk); +const result = await agent.invokeAgent({ + message: 'Summarize recent developments in edge AI', +}); +``` +- **Mode:** `agent` +- **Tools:** web.search, web.fetch, web.scrape +- **Use Cases:** Market research, competitive analysis, trend monitoring + +#### CodingAgent +```typescript +import { CodingAgent } from '@toolpack-sdk/agents'; + +const agent = new CodingAgent(sdk); +const result = await agent.invokeAgent({ + message: 'Refactor the auth module to use the new SDK pattern', +}); +``` +- **Mode:** `coding` +- **Tools:** fs.*, coding.*, git.*, exec.* +- **Use Cases:** Code generation, refactoring, debugging, test writing + +#### DataAgent +```typescript +import { DataAgent } from '@toolpack-sdk/agents'; + +const agent = new DataAgent(sdk); +const result = await agent.invokeAgent({ + message: 'Generate a weekly summary of signups by region', +}); +``` +- **Mode:** `agent` +- **Tools:** db.*, fs.*, http.* +- **Use Cases:** Database queries, reporting, data analysis, CSV generation + +#### BrowserAgent +```typescript +import { BrowserAgent } from '@toolpack-sdk/agents'; + +const agent = new BrowserAgent(sdk); +const result = await agent.invokeAgent({ + message: 'Extract all product prices from acme.com/products', +}); +``` +- **Mode:** `chat` +- **Tools:** web.fetch, web.screenshot, web.extract_links +- **Use Cases:** Web scraping, form filling, content extraction + +### Channels + +Channels connect agents to the outside world. The package includes 7 built-in channels: + +#### SlackChannel (Two-way) +```typescript +import { SlackChannel } from '@toolpack-sdk/agents'; + +const slack = new SlackChannel({ + name: 'slack-support', + token: process.env.SLACK_BOT_TOKEN, + signingSecret: process.env.SLACK_SIGNING_SECRET, +}); +``` +- ✅ Receives messages from Slack +- ✅ Replies in threads +- ✅ Supports `ask()` for human input + +#### TelegramChannel (Two-way) +```typescript +import { TelegramChannel } from '@toolpack-sdk/agents'; + +const telegram = new TelegramChannel({ + name: 'telegram-bot', + token: process.env.TELEGRAM_BOT_TOKEN, +}); +``` +- ✅ Receives messages from Telegram +- ✅ Replies to users +- ✅ Supports `ask()` for human input + +#### WebhookChannel (Two-way) +```typescript +import { WebhookChannel } from '@toolpack-sdk/agents'; + +const webhook = new WebhookChannel({ + name: 'github-webhook', + path: '/webhook/github', + port: 3000, + secret: process.env.WEBHOOK_SECRET, +}); +``` +- ✅ Receives HTTP POST webhooks +- ✅ Signature verification +- ✅ Supports `ask()` for human input + +#### ScheduledChannel (Trigger-only) +```typescript +import { ScheduledChannel } from '@toolpack-sdk/agents'; + +const scheduler = new ScheduledChannel({ + name: 'daily-report', + cron: '0 9 * * 1-5', // 9am weekdays + notify: 'webhook:https://hooks.example.com/daily-report', + message: 'Generate the daily sales report', +}); +// For Slack delivery, attach a named SlackChannel to the same agent and +// call `this.sendTo('', output)` from within `run()`. +``` +- ⏰ Triggers agents on cron schedules +- ✅ Full cron expression support (ranges, steps, lists, combinations) +- ❌ No `ask()` support (no human recipient) + +#### DiscordChannel (Two-way) +```typescript +import { DiscordChannel } from '@toolpack-sdk/agents'; + +const discord = new DiscordChannel({ + name: 'discord-bot', + token: process.env.DISCORD_BOT_TOKEN, + guildId: 'your-guild-id', + channelId: 'your-channel-id', +}); +``` +- ✅ Receives messages from Discord +- ✅ Replies in threads +- ✅ Supports `ask()` for human input + +#### EmailChannel (Outbound-only) +```typescript +import { EmailChannel } from '@toolpack-sdk/agents'; + +const email = new EmailChannel({ + name: 'email-alerts', + from: 'bot@acme.com', + to: 'team@acme.com', + smtp: { + host: 'smtp.gmail.com', + port: 587, + auth: { user: 'bot@acme.com', pass: process.env.SMTP_PASSWORD }, + }, +}); +``` +- 📧 Sends emails via SMTP +- ❌ No `ask()` support (outbound-only) + +#### SMSChannel (Configurable) +```typescript +import { SMSChannel } from '@toolpack-sdk/agents'; + +// Two-way with webhook +const sms = new SMSChannel({ + name: 'sms-alerts', + accountSid: process.env.TWILIO_ACCOUNT_SID, + authToken: process.env.TWILIO_AUTH_TOKEN, + from: '+1234567890', + webhookPath: '/sms/webhook', // Enables two-way + port: 3000, +}); + +// Outbound-only +const smsOutbound = new SMSChannel({ + name: 'sms-notifications', + accountSid: process.env.TWILIO_ACCOUNT_SID, + authToken: process.env.TWILIO_AUTH_TOKEN, + from: '+1234567890', + to: '+0987654321', // Fixed recipient +}); +``` +- 📱 Twilio SMS integration +- ✅ Two-way when `webhookPath` is set +- ❌ Outbound-only without webhook + +### Agent Lifecycle & Events + +Agents emit events at each stage of execution: + +```typescript +const agent = new MyAgent(sdk); + +agent.on('agent:start', (input) => { + console.log('Agent started:', input.message); +}); + +agent.on('agent:complete', (result) => { + console.log('Agent completed:', result.output); +}); + +agent.on('agent:error', (error) => { + console.error('Agent error:', error); +}); +``` + +### Knowledge Integration + +Agents can use knowledge bases for conversation memory and RAG: + +```typescript +import { Knowledge, MemoryProvider, OllamaEmbedder } from '@toolpack-sdk/knowledge'; +import { BaseAgent } from '@toolpack-sdk/agents'; + +class SmartAgent extends BaseAgent { + name = 'smart-agent'; + description = 'Agent with memory'; + mode = 'chat'; + + constructor(toolpack) { + super(toolpack); + // Set up knowledge base + this.knowledge = await Knowledge.create({ + provider: new MemoryProvider(), + embedder: new OllamaEmbedder({ model: 'nomic-embed-text' }), + }); + } + + async invokeAgent(input) { + // Conversation history is automatically loaded from knowledge + const result = await this.run(input.message); + return result; + } +} +``` + +### Multi-Channel Routing + +Agents can send output to different channels: + +```typescript +class MultiChannelAgent extends BaseAgent { + name = 'multi-agent'; + description = 'Routes to multiple channels'; + mode = 'agent'; + + async invokeAgent(input) { + const result = await this.run(input.message); + + // Send to multiple channels + await this.sendTo('slack:#general', result.output); + await this.sendTo('email-team', result.output); + await this.sendTo('sms-alerts', 'Task completed!'); + + return result; + } +} +``` + +### Extending Built-in Agents + +```typescript +import { ResearchAgent } from '@toolpack-sdk/agents'; + +class FintechResearchAgent extends ResearchAgent { + systemPrompt = `You are a research agent focused on fintech. + Always cite sources and flag regulatory implications.`; + provider = 'anthropic'; + model = 'claude-sonnet-4-20250514'; + + async onComplete(result) { + // Store research in knowledge base + if (this.knowledge) { + await this.knowledge.add(result.output, { + category: 'research', + topic: 'fintech', + }); + } + + // Send to Slack + await this.sendTo('slack-research', result.output); + } +} +``` + +### Features + +- ✅ **7 Built-in Channels** — Slack, Telegram, Discord, Email, SMS, Webhook, Scheduled +- ✅ **4 Built-in Agents** — Research, Coding, Data, Browser +- ✅ **Event-Driven** — Full lifecycle events for monitoring +- ✅ **Knowledge Integration** — Conversation memory and RAG +- ✅ **Multi-Channel Routing** — Send to any registered channel +- ✅ **Human-in-the-Loop** — `ask()` support for two-way channels +- ✅ **Type-Safe** — Full TypeScript support +- ✅ **199 Tests Passing** — Production-ready + +See the [Agents package README](../toolpack-agents/README.md) for full documentation. + ## Multimodal Support The SDK supports multimodal inputs (text + images) across all vision-capable providers. Images can be provided in three formats: @@ -926,6 +1276,7 @@ interface CompletionRequest { temperature?: number; max_tokens?: number; tools?: ToolCallRequest[]; + requestTools?: RequestToolDefinition[]; // Request-scoped tools tool_choice?: 'auto' | 'none' | 'required'; } @@ -956,6 +1307,115 @@ interface ProviderModelInfo { } ``` +### Request-Scoped Tools + +Request-scoped tools are dynamic tools attached to a single completion request. Unlike globally registered tools in the ToolRegistry, they: + +- **Don't pollute the shared registry** — Each request can have its own tools +- **Can close over request-specific state** — e.g., `conversationId`, user context +- **Are safe for multi-agent/multi-request usage** — No cross-request contamination +- **Execute through the same SDK orchestration** — Events, logging, HITL all work + +#### Built-in Request-Scoped Tools + +**Knowledge Tools** (when `knowledge` is configured): +- `knowledge_search` — Search the knowledge base for relevant information +- `knowledge_add` — Add new content to the knowledge base at runtime + +**Conversation Tools** (when using `ConversationHistory`): +- `conversation_search` — Search conversation history for past messages + +#### Creating Custom Request Tools + +```typescript +import { RequestToolDefinition, ConversationHistory } from 'toolpack-sdk'; + +// Example: Session-specific calculator +const createCalculatorTool = (sessionId: string): RequestToolDefinition => ({ + name: 'calculate', + displayName: 'Calculator', + description: 'Perform mathematical calculations', + category: 'math', + parameters: { + type: 'object', + properties: { + expression: { type: 'string', description: 'Math expression to evaluate' }, + }, + required: ['expression'], + }, + execute: async (args) => { + // Can safely close over sessionId + console.log(`Session ${sessionId}: calculating ${args.expression}`); + + // Simple eval (use a proper math library in production) + const result = eval(args.expression); + return { result, sessionId }; + }, +}); + +// Use in a request +const result = await sdk.generate({ + messages: [{ role: 'user', content: 'What is 15 * 23?' }], + model: 'gpt-4', + requestTools: [createCalculatorTool('user-123')], +}); +``` + +#### Using ConversationHistory with Request Tools + +```typescript +import { ConversationHistory } from 'toolpack-sdk'; + +const history = new ConversationHistory('./chat.db'); + +// Add some messages +await history.addUserMessage('conv-1', 'What is the API rate limit?'); +await history.addAssistantMessage('conv-1', 'The rate limit is 100 requests per minute.'); + +// Use conversation search in a request +const result = await sdk.generate({ + messages: [ + { role: 'user', content: 'What did we discuss about rate limits?' } + ], + model: 'gpt-4', + requestTools: [ + history.toTool('conv-1'), // Scoped to conversation 'conv-1' + ], +}); + +// AI can now call conversation_search to find the earlier discussion +``` + +#### Request Tools vs Registry Tools + +| Feature | Request Tools | Registry Tools | +|---------|---------------|----------------| +| **Scope** | Single request | All requests | +| **State** | Can close over request state | Stateless | +| **Registration** | Per-request via `requestTools` | Global via `ToolRegistry` | +| **Use Case** | Dynamic, stateful tools | Reusable, static tools | +| **Priority** | Higher (checked first) | Lower | +| **Examples** | `conversation_search`, `knowledge_add` | `fs.read_file`, `web.search` | + +#### Automatic Guidance Injection + +When request-scoped tools are present, the SDK automatically injects usage guidance into the system prompt: + +``` +Knowledge Base: +- Use `knowledge_search` when you need factual or domain-specific information. +- Use `knowledge_add` when you learn durable information that should be saved. + +Conversation History: +- Only recent messages may be present in context. +- Use `conversation_search` to find details from earlier in this conversation. +``` + +This guidance is: +- **Per-request** — Only injected when tools are actually present +- **Derived from effective tool set** — Reflects the actual tools available +- **Idempotent** — Won't duplicate if already present + ## Error Handling The SDK provides typed error classes for common failure scenarios: @@ -1011,7 +1471,7 @@ toolpack-sdk/ │ │ └── ollama/ # Ollama adapter + provider (auto-discovery) │ ├── modes/ # Mode system (Agent, Chat, createMode) │ ├── workflows/ # Workflow engine (planner, step executor, progress) -│ ├── tools/ # 90 built-in tools + registry + router + BM25 search +│ ├── tools/ # 97 built-in tools + registry + router + BM25 search │ │ ├── fs-tools/ # File system (18 tools) │ │ ├── coding-tools/ # Code analysis (12 tools) │ │ ├── git-tools/ # Git operations (9 tools) diff --git a/packages/toolpack-sdk/package.json b/packages/toolpack-sdk/package.json index d2cece0..431c965 100644 --- a/packages/toolpack-sdk/package.json +++ b/packages/toolpack-sdk/package.json @@ -1,7 +1,7 @@ { "name": "toolpack-sdk", "version": "1.4.0", - "description": "Unified TypeScript SDK for AI providers (OpenAI, Anthropic, Gemini, Ollama) with 90 built-in tools, workflow engine, and mode system for building AI-powered applications", + "description": "Unified TypeScript SDK for AI providers (OpenAI, Anthropic, Gemini, Ollama) with 97 built-in tools, workflow engine, and mode system for building AI-powered applications", "engines": { "node": ">=20" }, diff --git a/packages/toolpack-sdk/src/client/index.ts b/packages/toolpack-sdk/src/client/index.ts index 9d1c69b..b752afb 100644 --- a/packages/toolpack-sdk/src/client/index.ts +++ b/packages/toolpack-sdk/src/client/index.ts @@ -1,6 +1,6 @@ import { EventEmitter } from 'events'; import { ProviderAdapter } from "../providers/base/index.js"; -import { CompletionRequest, CompletionResponse, CompletionChunk, ToolCallRequest, ToolCallResult, EmbeddingRequest, EmbeddingResponse, ToolProgressEvent, ToolLogEvent, OnToolConfirmCallback, ToolConfirmationRequestedEvent, ToolConfirmationResolvedEvent, ContextWindowConfig, ProviderModelInfo } from "../types/index.js"; +import { CompletionRequest, CompletionResponse, CompletionChunk, ToolCallRequest, ToolCallResult, EmbeddingRequest, EmbeddingResponse, ToolProgressEvent, ToolLogEvent, OnToolConfirmCallback, ToolConfirmationRequestedEvent, ToolConfirmationResolvedEvent, RequestToolDefinition, ContextWindowConfig, ProviderModelInfo } from "../types/index.js"; import { SDKError, ProviderError } from "../errors/index.js"; import { ContextWindowExceededError, SummarizationError } from '../errors/context-window-errors.js'; import { countTokens, getSafeOutputReserve } from '../utils/token-counter.js'; @@ -13,7 +13,7 @@ import type { ToolsConfig, ToolSchema, ToolContext, ToolDefinition } from "../to import { DEFAULT_TOOLS_CONFIG } from "../tools/types.js"; import type { HitlConfig } from '../providers/config.js'; import { ModeConfig } from '../modes/mode-types.js'; -import { BM25SearchEngine, isToolSearchTool, generateToolCategoriesPrompt } from '../tools/search/index.js'; +import { BM25SearchEngine, isToolSearchTool, getToolSearchSchema, generateToolCategoriesPrompt } from '../tools/search/index.js'; import { generateBaseAgentContext } from './base-agent-context.js'; import { QueryClassifier } from './query-classifier.js'; import { ToolOrchestrator } from './tool-orchestrator.js'; @@ -36,6 +36,11 @@ function logRequestMessages(requestId: string, messages: CompletionRequest['mess }); } +interface EnrichedRequestResult { + request: CompletionRequest; + requestToolMap: Map; +} + function inferNeedsTools(messages: CompletionRequest['messages']): boolean { const text = extractLastUserText(messages).toLowerCase(); if (!text) return false; @@ -614,7 +619,9 @@ export class AIClient extends EventEmitter { // Resolve tools to send with the request const resolvedProviderName = providerName || this.defaultProvider; - let enrichedRequest = await this.enrichRequestWithTools(modeAwareRequest); + const initialEnrichment = await this.enrichRequestWithTools(modeAwareRequest); + let enrichedRequest = initialEnrichment.request; + const requestToolMap = initialEnrichment.requestToolMap; enrichedRequest = await this.enforceContextWindow(enrichedRequest, provider); const policy = (process.env.TOOLPACK_SDK_TOOL_CHOICE_POLICY || this.toolsConfig.toolChoicePolicy || 'auto') as any; @@ -652,7 +659,7 @@ export class AIClient extends EventEmitter { } const providerClass = (provider as any)?.constructor?.name || 'UnknownProvider'; - const outboundReq: any = { ...enrichedRequest, __toolpack_request_id: requestId }; + const outboundReq: any = { ...this.stripRequestTools(enrichedRequest), __toolpack_request_id: requestId }; logInfo(`[AIClient][${requestId}] generate() start provider=${resolvedProviderName} class=${providerClass} model=${enrichedRequest.model} messages=${enrichedRequest.messages.length} tools=${enrichedRequest.tools?.length || 0} tool_choice=${(enrichedRequest as any).tool_choice ?? 'unset'} policy=${policy} needsTools=${needsTools} autoExecute=${this.toolsConfig.enabled && this.toolsConfig.autoExecute}`); logRequestMessages(requestId, enrichedRequest.messages); @@ -662,7 +669,7 @@ export class AIClient extends EventEmitter { logDebug(`[AIClient][${requestId}] generate() initial response finish_reason=${(response as any).finish_reason ?? 'unknown'} tool_calls=${response.tool_calls?.length || 0} content_preview=${safePreview(response.content || '', 200)}`); // Auto-execute tool call loop - if (this.toolsConfig.enabled && this.toolsConfig.autoExecute && this.toolRegistry) { + if (this.toolsConfig.autoExecute && (this.toolRegistry || requestToolMap.size > 0)) { // Classify query to adjust maxToolRounds const userMessage = extractLastUserText(enrichedRequest.messages); const classification = this.queryClassifier.classify(userMessage); @@ -733,7 +740,7 @@ export class AIClient extends EventEmitter { logInfo(`[AIClient][${requestId}] Using parallel execution for ${toolCallsToExecute.length} tools`); const toolResults = await this.toolOrchestrator.executeWithDependencies( toolCallsToExecute, - (toolCall) => this.executeTool(toolCall), + (toolCall) => this.executeTool(toolCall, requestToolMap), 5 // maxConcurrent ); @@ -792,7 +799,7 @@ export class AIClient extends EventEmitter { continue; } - const result = await this.executeTool(toolCall); + const result = await this.executeTool(toolCall, requestToolMap); const resultStr = typeof result === 'string' ? result : JSON.stringify(result); // Check budget before adding @@ -826,7 +833,7 @@ export class AIClient extends EventEmitter { // Call the model again with updated messages const rawFollowupReq: any = { ...enrichedRequest, messages, __toolpack_request_id: requestId }; // Re-enrich to include any tools discovered in the previous round - let followupReq = await this.enrichRequestWithTools(rawFollowupReq); + let followupReq = this.stripRequestTools((await this.enrichRequestWithTools(rawFollowupReq)).request); followupReq = await this.enforceContextWindow(followupReq, provider); if ((followupReq as any).tool_choice === 'required') { @@ -864,7 +871,9 @@ export class AIClient extends EventEmitter { modeAwareRequest = this.injectOverrideSystemPrompt(modeAwareRequest); modeAwareRequest = this.injectModeSystemPrompt(modeAwareRequest); - let enrichedRequest = await this.enrichRequestWithTools(modeAwareRequest); + const initialEnrichment = await this.enrichRequestWithTools(modeAwareRequest); + let enrichedRequest = initialEnrichment.request; + const requestToolMap = initialEnrichment.requestToolMap; enrichedRequest = await this.enforceContextWindow(enrichedRequest, provider); const policy = (process.env.TOOLPACK_SDK_TOOL_CHOICE_POLICY || this.toolsConfig.toolChoicePolicy || 'auto') as any; @@ -902,12 +911,12 @@ export class AIClient extends EventEmitter { } const providerClass = (provider as any)?.constructor?.name || 'UnknownProvider'; - const baseReq: any = { ...enrichedRequest, __toolpack_request_id: requestId }; + const baseReq: any = { ...this.stripRequestTools(enrichedRequest), __toolpack_request_id: requestId }; logInfo(`[AIClient][${requestId}] stream() start provider=${resolvedProviderName} class=${providerClass} model=${enrichedRequest.model} messages=${enrichedRequest.messages.length} tools=${enrichedRequest.tools?.length || 0} tool_choice=${(enrichedRequest as any).tool_choice ?? 'unset'} policy=${policy} needsTools=${needsTools} autoExecute=${this.toolsConfig.enabled && this.toolsConfig.autoExecute}`); logRequestMessages(requestId, enrichedRequest.messages); - if (!this.toolsConfig.enabled || !this.toolsConfig.autoExecute || !this.toolRegistry) { + if (!this.toolsConfig.autoExecute || (!this.toolRegistry && requestToolMap.size === 0)) { yield* provider.stream(baseReq); return; } @@ -940,9 +949,9 @@ export class AIClient extends EventEmitter { logInfo(`[AIClient][${requestId}] stream() round_start ${rounds}/${maxRounds}`); let lastFinishReason: string | null = null; - const rawRoundReq: any = { ...baseReq, messages }; + const rawRoundReq: any = { ...enrichedRequest, messages }; // Re-enrich to include any newly discovered tools from previous rounds - let roundReq = await this.enrichRequestWithTools(rawRoundReq); + let roundReq = this.stripRequestTools((await this.enrichRequestWithTools(rawRoundReq)).request); roundReq = await this.enforceContextWindow(roundReq, provider); if (rounds > 0 && (roundReq as any).tool_choice === 'required') { @@ -1053,7 +1062,7 @@ export class AIClient extends EventEmitter { if (!toolDone) heartbeatChunks.push({ delta: '' }); }, 500); - const result = await this.executeTool(toolCall); + const result = await this.executeTool(toolCall, requestToolMap); toolDone = true; clearInterval(heartbeatInterval); const duration = Date.now() - startTime; @@ -1132,16 +1141,20 @@ export class AIClient extends EventEmitter { * Enrich a request with tools based on the router config. * Applies mode-based tool filtering when an active mode is set. */ - private async enrichRequestWithTools(request: CompletionRequest): Promise { + private async enrichRequestWithTools(request: CompletionRequest): Promise { // If mode blocks ALL tools, return request with no tools if (this.activeMode?.blockAllTools) { logInfo(`[AIClient] Mode "${this.activeMode.displayName}" blocks all tools`); - return request; + return { request, requestToolMap: new Map() }; } - if (!this.toolsConfig.enabled || (!this.toolRegistry && (request.tools?.length || 0) === 0)) { - logDebug(`[AIClient] Tools disabled or no registry`); - return request; + const requestToolMap = this.buildRequestToolMap(request.requestTools); + const requestToolSchemas = Array.from(requestToolMap.values()).map(tool => this.requestToolToSchema(tool)); + const hasRequestTools = requestToolMap.size > 0; + + if (!this.toolsConfig.enabled && !hasRequestTools) { + logDebug(`[AIClient] Tools disabled and no request-scoped tools`); + return { request, requestToolMap }; } // Merge mode-specific tool search config with global config @@ -1163,7 +1176,12 @@ export class AIClient extends EventEmitter { if (request.tools && request.tools.length > 0) { if (!resolvedToolsConfig.toolSearch?.enabled || !this.toolRegistry) { logDebug(`[AIClient] Request already has ${request.tools.length} tools`); - return request; + const tools = this.mergeToolCallRequests(request.tools, this.schemasToToolCallRequests(requestToolSchemas)); + const nextRequest = tools === request.tools ? request : { ...request, tools }; + return { + request: this.injectRequestToolGuidance(nextRequest, tools), + requestToolMap, + }; } let schemas = await this.toolRouter.resolve( @@ -1197,24 +1215,40 @@ export class AIClient extends EventEmitter { if (newTools.length === 0) { logDebug(`[AIClient] Request already has ${request.tools.length} tools (no new discoveries)`); - return request; + const tools = this.mergeToolCallRequests(request.tools, this.schemasToToolCallRequests(requestToolSchemas)); + const nextRequest = tools === request.tools ? request : { ...request, tools }; + return { + request: this.injectRequestToolGuidance(nextRequest, tools), + requestToolMap, + }; } let enrichedRequest: CompletionRequest = { ...request, - tools: [...request.tools, ...newTools], + tools: this.mergeToolCallRequests( + [...request.tools, ...newTools], + this.schemasToToolCallRequests(requestToolSchemas) + ), }; if (resolvedToolsConfig.toolSearch?.enabled && this.toolRegistry) { enrichedRequest = this.injectToolSearchPrompt(enrichedRequest); } - return enrichedRequest; + return { + request: this.injectRequestToolGuidance(enrichedRequest, enrichedRequest.tools), + requestToolMap, + }; } if (!this.toolRegistry) { logDebug('[AIClient] Tool registry not configured, skipping tool resolution'); - return request; + const tools = this.schemasToToolCallRequests(requestToolSchemas); + const nextRequest = tools.length > 0 ? { ...request, tools } : request; + return { + request: this.injectRequestToolGuidance(nextRequest, tools), + requestToolMap, + }; } const activeRegistry = this.toolRegistry; @@ -1237,27 +1271,143 @@ export class AIClient extends EventEmitter { } } - if (schemas.length === 0) { - return request; + const tools = this.schemasToToolCallRequests(this.mergeSchemas(schemas, requestToolSchemas)); + + if (tools.length === 0) { + return { request, requestToolMap }; + } + + let enrichedRequest: CompletionRequest = { ...request, tools }; + + // Inject Tool Search system prompt if enabled + if (this.toolsConfig.toolSearch?.enabled && activeRegistry) { + enrichedRequest = this.injectToolSearchPrompt(enrichedRequest); } - const tools: ToolCallRequest[] = schemas.map(s => ({ + return { + request: this.injectRequestToolGuidance(enrichedRequest, tools), + requestToolMap, + }; + } + + private buildRequestToolMap(requestTools?: RequestToolDefinition[]): Map { + const map = new Map(); + for (const tool of requestTools || []) { + map.set(tool.name, tool); + } + return map; + } + + private requestToolToSchema(tool: RequestToolDefinition): ToolSchema { + return { + name: tool.name, + displayName: tool.displayName, + description: tool.description, + parameters: tool.parameters as any, + category: tool.category, + cacheable: tool.cacheable, + }; + } + + private mergeSchemas(base: ToolSchema[], overrides: ToolSchema[]): ToolSchema[] { + const merged = new Map(); + for (const schema of base) { + merged.set(schema.name, schema); + } + for (const schema of overrides) { + merged.set(schema.name, schema); + } + return Array.from(merged.values()); + } + + private schemasToToolCallRequests(schemas: ToolSchema[]): ToolCallRequest[] { + return schemas.map(schema => ({ type: 'function', function: { - name: s.name, - description: s.description, - parameters: s.parameters, + name: schema.name, + description: schema.description, + parameters: schema.parameters, }, })); + } - let enrichedRequest: CompletionRequest = { ...request, tools }; + private mergeToolCallRequests(base: ToolCallRequest[], overrides: ToolCallRequest[]): ToolCallRequest[] { + if (overrides.length === 0) { + return base; + } + const merged = new Map(); + for (const tool of base) { + merged.set(tool.function.name, tool); + } + for (const tool of overrides) { + merged.set(tool.function.name, tool); + } + return Array.from(merged.values()); + } - // Inject Tool Search system prompt if enabled - if (this.toolsConfig.toolSearch?.enabled && activeRegistry) { - enrichedRequest = this.injectToolSearchPrompt(enrichedRequest); + private injectRequestToolGuidance(request: CompletionRequest, effectiveTools?: ToolCallRequest[]): CompletionRequest { + const toolNames = new Set((effectiveTools || request.tools || []).map(tool => tool.function.name)); + if (toolNames.size === 0) { + return request; + } + + // Use a marker to detect if guidance has already been injected + const GUIDANCE_MARKER = ''; + + const sections: string[] = []; + + if (toolNames.has('knowledge_search') || toolNames.has('knowledge_add')) { + const lines = ['Knowledge Base:']; + if (toolNames.has('knowledge_search')) { + lines.push('- Use `knowledge_search` when you need factual or domain-specific information that may already be stored.'); + } + if (toolNames.has('knowledge_add')) { + lines.push('- Use `knowledge_add` when you encounter a durable fact, user preference, or decision that future conversations should know. Do not add confidential information, routine task outputs, or context that is specific to this conversation only.'); + } + sections.push(lines.join('\n')); + } + + if (toolNames.has('conversation_search')) { + sections.push( + 'Conversation History:\n- Only recent messages may be present in context.\n- Use `conversation_search` to find relevant details from earlier in this conversation when needed.' + ); } - return enrichedRequest; + if (sections.length === 0) { + return request; + } + + const guidance = `${GUIDANCE_MARKER}\n${sections.join('\n\n')}`; + const systemIndex = request.messages.findIndex(message => message.role === 'system'); + + if (systemIndex >= 0) { + const messages = request.messages.map((message, index) => { + if (index !== systemIndex) return message; + const existingContent = typeof message.content === 'string' ? message.content : ''; + + // Check for marker instead of full text for more robust deduplication + if (existingContent.includes(GUIDANCE_MARKER)) { + return message; // Already injected + } + + return { + ...message, + content: `${existingContent}\n\n${guidance}`.trim(), + }; + }); + return { ...request, messages }; + } + + return { + ...request, + messages: [{ role: 'system', content: guidance }, ...request.messages], + }; + } + + private stripRequestTools(request: CompletionRequest): CompletionRequest { + const { requestTools, ...rest } = request; + void requestTools; + return rest; } /** @@ -1270,6 +1420,20 @@ export class AIClient extends EventEmitter { if (mode.blockedTools.includes(schema.name)) return false; if (mode.blockedToolCategories.includes(schema.category)) return false; + // Keep tool.search available in tool-search mode even when category allowlists are restrictive. + // Explicit blocks above still win. + if (isToolSearchTool(schema.name)) { + const toolSearchEnabledInMode = mode.toolSearch?.enabled; + const toolSearchEnabled = + toolSearchEnabledInMode !== undefined + ? toolSearchEnabledInMode + : (this.toolsConfig.toolSearch?.enabled ?? false); + + if (toolSearchEnabled) { + return true; + } + } + // If allowlists are specified, tool must match at least one const hasAllowedTools = mode.allowedTools.length > 0; const hasAllowedCategories = mode.allowedToolCategories.length > 0; @@ -1479,7 +1643,7 @@ NEVER guess or hallucinate tool names. ALWAYS use tool.search to discover tools * Execute a single tool call via the registry. * Emits 'tool:started', 'tool:completed', and 'tool:failed' events. */ - private async executeTool(toolCall: ToolCallResult): Promise { + private async executeTool(toolCall: ToolCallResult, requestToolMap: Map): Promise { const startTime = Date.now(); // Emit started event @@ -1492,7 +1656,10 @@ NEVER guess or hallucinate tool names. ALWAYS use tool.search to discover tools logInfo(`[AIClient] Executing tool: ${toolCall.name} with args: ${safePreview(toolCall.arguments, 500)}`); - if (!this.toolRegistry) { + const requestTool = requestToolMap.get(toolCall.name); + const registryTool = requestTool ? undefined : this.toolRegistry?.get(toolCall.name); + + if (!requestTool && !this.toolRegistry) { const error = 'No tool registry configured'; this.emit('tool:failed', { toolName: toolCall.name, @@ -1529,7 +1696,7 @@ NEVER guess or hallucinate tool names. ALWAYS use tool.search to discover tools return result; } - const tool = this.toolRegistry.get(toolCall.name); + const tool = requestTool || registryTool; if (!tool) { logWarn(`[AIClient] Tool '${toolCall.name}' not found in registry`); @@ -1554,27 +1721,27 @@ NEVER guess or hallucinate tool names. ALWAYS use tool.search to discover tools let args = toolCall.arguments; // Human-in-the-loop confirmation check - if (tool.confirmation && this.onToolConfirm && !this.isBypassed(tool)) { + if (registryTool?.confirmation && this.onToolConfirm && !this.isBypassed(registryTool)) { // Emit confirmation requested event this.emit('tool:confirmation_requested', { - tool, + tool: registryTool, args, - level: tool.confirmation.level, - reason: tool.confirmation.reason, + level: registryTool.confirmation.level, + reason: registryTool.confirmation.reason, } as ToolConfirmationRequestedEvent); // Wait for user decision - const decision = await this.onToolConfirm(tool, args, { + const decision = await this.onToolConfirm(registryTool, args, { roundNumber: this.currentRound, conversationId: this.conversationId, }); // Emit confirmation resolved event this.emit('tool:confirmation_resolved', { - tool, + tool: registryTool, args, - level: tool.confirmation.level, - reason: tool.confirmation.reason, + level: registryTool.confirmation.level, + reason: registryTool.confirmation.reason, decision, } as ToolConfirmationResolvedEvent); @@ -1612,7 +1779,9 @@ NEVER guess or hallucinate tool names. ALWAYS use tool.search to discover tools config: this.toolsConfig?.additionalConfigurations ?? {}, log: (msg) => logInfo(`[Tool] ${msg}`), }; - const result = await tool.execute(args, ctx); + const result = requestTool + ? await requestTool.execute(args) + : await tool.execute(args, ctx); const duration = Date.now() - startTime; // Emit completed event @@ -1635,11 +1804,12 @@ NEVER guess or hallucinate tool names. ALWAYS use tool.search to discover tools timestamp: Date.now(), } as ToolLogEvent); - logInfo(`[AIClient] Tool ${toolCall.name} executed successfully in ${duration}ms result_len=${result?.length ?? 0}`); + const resultLength = typeof result === 'string' ? result.length : JSON.stringify(result).length; + logInfo(`[AIClient] Tool ${toolCall.name} executed successfully in ${duration}ms result_len=${resultLength}`); if (shouldLog('debug')) { logDebug(`[AIClient] Tool ${toolCall.name} result_preview=${safePreview(result, 400)}`); } - return result; + return typeof result === 'string' ? result : JSON.stringify(result); } catch (error: any) { const duration = Date.now() - startTime; const errorMsg = error.message || 'Tool execution failed'; @@ -1752,10 +1922,44 @@ NEVER guess or hallucinate tool names. ALWAYS use tool.search to discover tools private executeToolSearch(args: Record): string { const { query, category } = args; const limit = this.toolsConfig.toolSearch?.searchResultLimit ?? 5; + const requestedCategory = typeof category === 'string' && category.length > 0 ? category : undefined; + + if (this.activeMode) { + const searchAllowed = this.filterSchemasByMode([getToolSearchSchema()], this.activeMode).length > 0; + if (!searchAllowed) { + logWarn('[AIClient] tool.search blocked by active mode'); + return JSON.stringify({ + query, + found: 0, + tools: [], + hint: 'tool.search is not allowed in the current mode.', + }); + } + } + + logInfo(`[AIClient] Executing tool.search: query="${query}" category=${requestedCategory || 'all'} limit=${limit}`); + + // Oversample so mode filtering (allowed/blocked tools and categories) does not starve the + // final result set when allowlists are restrictive. We slice down to the configured limit below. + const oversampleLimit = this.activeMode ? Math.max(limit * 4, limit) : limit; + let results = this.bm25Engine.search(query, { limit: oversampleLimit, category: requestedCategory }); - logInfo(`[AIClient] Executing tool.search: query="${query}" category=${category || 'all'} limit=${limit}`); + if (this.activeMode && results.length > 0) { + const allowedSchemas = this.filterSchemasByMode(results.map(result => result.tool), this.activeMode); + const allowedToolNames = new Set(allowedSchemas.map(schema => schema.name)); + const beforeCount = results.length; - const results = this.bm25Engine.search(query, { limit, category }); + results = results.filter(result => allowedToolNames.has(result.toolName)); + + const filteredCount = beforeCount - results.length; + if (filteredCount > 0) { + logDebug(`[AIClient] tool.search filtered out ${filteredCount} disallowed results for mode "${this.activeMode.displayName}"`); + } + } + + if (results.length > limit) { + results = results.slice(0, limit); + } // Record discovered tools in the cache const toolNames = results.map(r => r.toolName); diff --git a/packages/toolpack-sdk/src/conversation/conv-types.ts b/packages/toolpack-sdk/src/conversation/conv-types.ts new file mode 100644 index 0000000..71d8d86 --- /dev/null +++ b/packages/toolpack-sdk/src/conversation/conv-types.ts @@ -0,0 +1,128 @@ +import type { Participant } from './participant.js'; + +/** + * Coarse scope of a stored message. + * + * - `thread` — a reply inside a specific thread (Slack thread, email thread) + * - `channel` — top-level message in a channel / group chat + * - `dm` — direct / private message between two participants + */ +export type ConversationScope = 'thread' | 'channel' | 'dm'; + +/** + * A single stored message in conversation history. + * + * This is the canonical storage shape. It is deliberately richer than + * the LLM's role-based format — the prompt assembler projects it into + * whatever the provider expects at render time. + */ +export interface StoredMessage { + /** Stable, unique message id. Used for dedup at capture time. */ + id: string; + + /** + * Conversation key. Identifies the thread / DM / channel this message + * belongs to. + */ + conversationId: string; + + /** Who sent this message. */ + participant: Participant; + + /** Plain-text content of the message. */ + content: string; + + /** ISO 8601 timestamp of when the message was received/sent. */ + timestamp: string; + + /** Coarse scope used by the assembler to filter by context type. */ + scope: ConversationScope; + + metadata?: { + /** Platform channel type, e.g. 'im' for Slack DMs, 'private' for Telegram DMs. */ + channelType?: string; + /** Thread timestamp / id within a channel (e.g. Slack thread_ts). */ + threadId?: string; + /** Platform-specific message id for dedup and linking. */ + messageId?: string; + /** Participant ids explicitly @-mentioned in this message. */ + mentions?: string[]; + /** Whether this message is a rolling summary replacing older turns. */ + isSummary?: boolean; + /** Human-readable channel or group name (e.g. '#general', 'Project Kore'). */ + channelName?: string; + /** Platform-specific channel identifier (e.g. Slack 'C12345', Telegram chat id). */ + channelId?: string; + }; +} + +/** Options for retrieving messages from the store. */ +export interface GetOptions { + /** Filter to a specific scope within the conversation. */ + scope?: ConversationScope; + + /** Only return messages at or after this ISO timestamp. */ + sinceTimestamp?: string; + + /** Maximum number of messages to return (most recent N). */ + limit?: number; + + /** + * When set, only return messages whose `participant.id` is in this set. + * Used by the assembler's addressed-only mode. + */ + participantIds?: string[]; +} + +/** Options for the conversation search tool. */ +export interface ConversationSearchOptions { + /** Maximum number of results to return. Default: 10. */ + limit?: number; + + /** + * Rough token cap for total search results. + * The store truncates content to fit within this budget. + * Default: 2000. + */ + tokenCap?: number; +} + +/** Options for the prompt assembler (used by toolpack-agents). */ +export interface AssemblerOptions { + scope?: ConversationScope; + addressedOnlyMode?: boolean; + tokenBudget?: number; + rollingSummaryThreshold?: number; + timeWindowMinutes?: number; + maxTurnsToLoad?: number; + agentAliases?: string[]; +} + +/** A single message entry in the assembled prompt, ready to send to the LLM. */ +export interface PromptMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +/** The output of the prompt assembler. */ +export interface AssembledPrompt { + messages: PromptMessage[]; + estimatedTokens: number; + turnsLoaded: number; + hasSummary: boolean; +} + +/** + * Interface for conversation history storage. + * + * Implementations must be: + * - **Append-only safe**: `append()` must be idempotent on duplicate `id`. + * - **Ordered**: `get()` returns messages in ascending timestamp order. + * - **Scope-aware**: `get()` must respect `options.scope` when provided. + */ +export interface ConversationStore { + append(message: StoredMessage): Promise; + get(conversationId: string, options?: GetOptions): Promise; + search(conversationId: string, query: string, options?: ConversationSearchOptions): Promise; + deleteMessages(conversationId: string, ids: string[]): Promise; +} diff --git a/packages/toolpack-sdk/src/conversation/conversation.test.ts b/packages/toolpack-sdk/src/conversation/conversation.test.ts new file mode 100644 index 0000000..c990642 --- /dev/null +++ b/packages/toolpack-sdk/src/conversation/conversation.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { InMemoryConversationStore } from './store.js'; +import type { StoredMessage } from './conv-types.js'; + +function msg(overrides: Partial & { id: string }): StoredMessage { + return { + conversationId: 'conv-1', + participant: { kind: 'user', id: 'u1', displayName: 'Alice' }, + content: 'hello', + timestamp: new Date().toISOString(), + scope: 'channel', + ...overrides, + }; +} + +describe('InMemoryConversationStore', () => { + let store: InMemoryConversationStore; + + beforeEach(() => { + store = new InMemoryConversationStore(); + }); + + describe('append', () => { + it('should add a message', async () => { + await store.append(msg({ id: '1', content: 'Hello' })); + const messages = await store.get('conv-1'); + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('Hello'); + }); + + it('should be idempotent on duplicate id', async () => { + await store.append(msg({ id: '1', content: 'Hello' })); + await store.append(msg({ id: '1', content: 'Hello again' })); + const messages = await store.get('conv-1'); + expect(messages).toHaveLength(1); + }); + + it('should maintain ascending timestamp order', async () => { + await store.append(msg({ id: '2', timestamp: '2024-01-01T00:00:02Z', content: 'B' })); + await store.append(msg({ id: '1', timestamp: '2024-01-01T00:00:01Z', content: 'A' })); + const messages = await store.get('conv-1'); + expect(messages[0].content).toBe('A'); + expect(messages[1].content).toBe('B'); + }); + }); + + describe('get', () => { + beforeEach(async () => { + await store.append(msg({ id: '1', scope: 'channel', content: 'channel msg' })); + await store.append(msg({ id: '2', scope: 'thread', content: 'thread msg' })); + await store.append(msg({ id: '3', scope: 'dm', content: 'dm msg' })); + }); + + it('should return all messages without filter', async () => { + const messages = await store.get('conv-1'); + expect(messages).toHaveLength(3); + }); + + it('should filter by scope', async () => { + const messages = await store.get('conv-1', { scope: 'thread' }); + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('thread msg'); + }); + + it('should return empty for unknown conversation', async () => { + const messages = await store.get('unknown-conv'); + expect(messages).toHaveLength(0); + }); + + it('should apply limit to most recent N', async () => { + const messages = await store.get('conv-1', { limit: 2 }); + expect(messages).toHaveLength(2); + }); + + it('should filter by participantIds', async () => { + await store.append(msg({ id: '4', participant: { kind: 'agent', id: 'bot' }, content: 'bot msg' })); + const messages = await store.get('conv-1', { participantIds: ['bot'] }); + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('bot msg'); + }); + }); + + describe('search', () => { + beforeEach(async () => { + await store.append(msg({ id: '1', content: 'The quick brown fox' })); + await store.append(msg({ id: '2', content: 'jumped over the lazy dog' })); + await store.append(msg({ id: '3', content: 'foxes are cunning' })); + }); + + it('should return matching messages', async () => { + const results = await store.search('conv-1', 'fox'); + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results.every(r => r.content.toLowerCase().includes('fox'))).toBe(true); + }); + + it('should return empty for no match', async () => { + const results = await store.search('conv-1', 'zebra'); + expect(results).toHaveLength(0); + }); + + it('should respect limit', async () => { + const results = await store.search('conv-1', 'fox', { limit: 1 }); + expect(results).toHaveLength(1); + }); + }); + + describe('deleteMessages', () => { + it('should remove specified messages', async () => { + await store.append(msg({ id: '1', content: 'A' })); + await store.append(msg({ id: '2', content: 'B' })); + await store.deleteMessages('conv-1', ['1']); + const messages = await store.get('conv-1'); + expect(messages).toHaveLength(1); + expect(messages[0].content).toBe('B'); + }); + + it('should be a no-op for unknown ids', async () => { + await store.append(msg({ id: '1', content: 'A' })); + await store.deleteMessages('conv-1', ['nonexistent']); + const messages = await store.get('conv-1'); + expect(messages).toHaveLength(1); + }); + }); + + describe('clearConversation', () => { + it('should remove all messages for a conversation', async () => { + await store.append(msg({ id: '1', content: 'A' })); + store.clearConversation('conv-1'); + const messages = await store.get('conv-1'); + expect(messages).toHaveLength(0); + }); + }); + + describe('LRU eviction', () => { + it('should evict least-recently-used conversation when capacity exceeded', async () => { + const smallStore = new InMemoryConversationStore({ maxConversations: 2 }); + await smallStore.append(msg({ id: '1', conversationId: 'a' })); + await smallStore.append(msg({ id: '2', conversationId: 'b' })); + await smallStore.get('a'); + await smallStore.append(msg({ id: '3', conversationId: 'c' })); + + expect(await smallStore.get('a')).toHaveLength(1); + expect(await smallStore.get('c')).toHaveLength(1); + }); + }); + + describe('maxMessagesPerConversation', () => { + it('should drop oldest messages when cap is exceeded', async () => { + const capped = new InMemoryConversationStore({ maxMessagesPerConversation: 3 }); + for (let i = 1; i <= 5; i++) { + await capped.append(msg({ + id: String(i), + content: `msg ${i}`, + timestamp: `2024-01-01T00:00:0${i}Z`, + })); + } + const messages = await capped.get('conv-1'); + expect(messages).toHaveLength(3); + expect(messages[0].content).toBe('msg 3'); + expect(messages[2].content).toBe('msg 5'); + }); + }); + + describe('isolation between conversations', () => { + it('should keep conversations separate', async () => { + await store.append(msg({ id: '1', conversationId: 'conv-a', content: 'A' })); + await store.append(msg({ id: '2', conversationId: 'conv-b', content: 'B' })); + + const a = await store.get('conv-a'); + const b = await store.get('conv-b'); + + expect(a).toHaveLength(1); + expect(b).toHaveLength(1); + expect(a[0].content).toBe('A'); + expect(b[0].content).toBe('B'); + }); + }); +}); diff --git a/packages/toolpack-sdk/src/conversation/index.ts b/packages/toolpack-sdk/src/conversation/index.ts new file mode 100644 index 0000000..b0270c2 --- /dev/null +++ b/packages/toolpack-sdk/src/conversation/index.ts @@ -0,0 +1,17 @@ +export type { Participant } from './participant.js'; + +export type { + ConversationScope, + StoredMessage, + GetOptions, + ConversationSearchOptions, + AssemblerOptions, + PromptMessage, + AssembledPrompt, + ConversationStore, +} from './conv-types.js'; + +export { + InMemoryConversationStore, + type InMemoryConversationStoreConfig, +} from './store.js'; diff --git a/packages/toolpack-sdk/src/conversation/participant.ts b/packages/toolpack-sdk/src/conversation/participant.ts new file mode 100644 index 0000000..ce459ff --- /dev/null +++ b/packages/toolpack-sdk/src/conversation/participant.ts @@ -0,0 +1,21 @@ +/** + * A participant in a conversation — a human user, another agent, or the + * system itself. Stored alongside each `StoredMessage` so the prompt + * assembler can reconstruct who said what without extra lookups. + */ +export interface Participant { + /** Coarse participant kind */ + kind: 'system' | 'user' | 'agent'; + + /** Stable identifier for this participant (platform-specific id or agent name) */ + id: string; + + /** Human-readable display name, resolved lazily. Falls back to `id` if unset. */ + displayName?: string; + + /** For `kind: 'agent'` only: an optional role label for rendering */ + agentType?: string; + + /** Optional free-form metadata (e.g. platform-specific profile info) */ + metadata?: Record; +} diff --git a/packages/toolpack-sdk/src/conversation/store.ts b/packages/toolpack-sdk/src/conversation/store.ts new file mode 100644 index 0000000..770252e --- /dev/null +++ b/packages/toolpack-sdk/src/conversation/store.ts @@ -0,0 +1,158 @@ +import type { ConversationStore, StoredMessage, GetOptions, ConversationSearchOptions } from './conv-types.js'; + +class ConversationLRU { + private readonly capacity: number; + private readonly map: Map; + + constructor(capacity: number) { + this.capacity = capacity; + this.map = new Map(); + } + + get(key: string): StoredMessage[] | undefined { + const value = this.map.get(key); + if (value === undefined) return undefined; + this.map.delete(key); + this.map.set(key, value); + return value; + } + + set(key: string, value: StoredMessage[]): void { + if (this.map.has(key)) { + this.map.delete(key); + } else if (this.map.size >= this.capacity) { + const oldest = this.map.keys().next().value; + if (oldest !== undefined) { + this.map.delete(oldest); + } + } + this.map.set(key, value); + } + + has(key: string): boolean { + return this.map.has(key); + } + + get size(): number { + return this.map.size; + } +} + +export interface InMemoryConversationStoreConfig { + /** Maximum conversations to keep in memory. Default: 500. */ + maxConversations?: number; + /** Maximum messages per conversation. Default: 500. */ + maxMessagesPerConversation?: number; +} + +/** + * In-memory implementation of `ConversationStore`. + * + * Good for single-process deployments, local development, and tests. + * Memory is bounded by `maxConversations × maxMessagesPerConversation`. + * + * **Not suitable for multi-process or serverless deployments** — each + * process has its own isolated store. For those environments, implement + * `ConversationStore` against a shared database. + */ +export class InMemoryConversationStore implements ConversationStore { + private readonly lru: ConversationLRU; + private readonly maxMessagesPerConversation: number; + + constructor(config: InMemoryConversationStoreConfig = {}) { + this.lru = new ConversationLRU(config.maxConversations ?? 500); + this.maxMessagesPerConversation = config.maxMessagesPerConversation ?? 500; + } + + async append(message: StoredMessage): Promise { + let messages = this.lru.get(message.conversationId); + + if (!messages) { + messages = []; + this.lru.set(message.conversationId, messages); + } + + if (messages.some(m => m.id === message.id)) { + return; + } + + messages.push(message); + messages.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + + if (messages.length > this.maxMessagesPerConversation) { + messages.splice(0, messages.length - this.maxMessagesPerConversation); + } + } + + async get(conversationId: string, options: GetOptions = {}): Promise { + const messages = this.lru.get(conversationId) ?? []; + let result = messages.slice(); + + if (options.scope !== undefined) { + result = result.filter(m => m.scope === options.scope); + } + + if (options.sinceTimestamp !== undefined) { + result = result.filter(m => m.timestamp >= options.sinceTimestamp!); + } + + if (options.participantIds !== undefined && options.participantIds.length > 0) { + const ids = new Set(options.participantIds); + result = result.filter(m => ids.has(m.participant.id)); + } + + if (options.limit !== undefined && result.length > options.limit) { + result = result.slice(result.length - options.limit); + } + + return result; + } + + async search( + conversationId: string, + query: string, + options: ConversationSearchOptions = {} + ): Promise { + const messages = this.lru.get(conversationId) ?? []; + const queryLower = query.toLowerCase(); + const limit = options.limit ?? 10; + const tokenCap = options.tokenCap ?? 2000; + + const matches = messages + .filter(m => m.content.toLowerCase().includes(queryLower)) + .slice() + .reverse(); + + const results: StoredMessage[] = []; + let tokenCount = 0; + + for (const msg of matches) { + if (results.length >= limit) break; + + const msgTokens = Math.ceil(msg.content.length / 4); + if (results.length > 0 && tokenCount + msgTokens > tokenCap) break; + + results.push(msg); + tokenCount += msgTokens; + } + + return results; + } + + async deleteMessages(conversationId: string, ids: string[]): Promise { + const messages = this.lru.get(conversationId); + if (!messages || ids.length === 0) return; + + const idSet = new Set(ids); + const kept = messages.filter(m => !idSet.has(m.id)); + this.lru.set(conversationId, kept); + } + + clearConversation(conversationId: string): void { + this.lru.set(conversationId, []); + } + + get conversationCount(): number { + return this.lru.size; + } +} diff --git a/packages/toolpack-sdk/src/index.ts b/packages/toolpack-sdk/src/index.ts index 0a0eb9d..17c1a04 100644 --- a/packages/toolpack-sdk/src/index.ts +++ b/packages/toolpack-sdk/src/index.ts @@ -6,10 +6,11 @@ export * from './tools/index.js'; export * from './modes/index.js'; export * from './workflows/index.js'; export * from './toolpack.js'; +export * from './conversation/index.js'; export * from './utils/home-config.js'; export * from './utils/runtime-config-loader.js'; export * from './utils/token-counter.js'; export * from './utils/message-pruner.js'; export * from './utils/message-summarizer.js'; export * from './utils/context-window-state.js'; -export * from './mcp/index.js'; \ No newline at end of file +export * from './mcp/index.js'; diff --git a/packages/toolpack-sdk/src/modes/index.ts b/packages/toolpack-sdk/src/modes/index.ts index 2924be0..0135666 100644 --- a/packages/toolpack-sdk/src/modes/index.ts +++ b/packages/toolpack-sdk/src/modes/index.ts @@ -4,6 +4,7 @@ export { createMode } from './create-mode.js'; export { AGENT_MODE, CHAT_MODE, + CODING_MODE, BUILT_IN_MODES, DEFAULT_MODE_NAME, } from './built-in-modes.js'; diff --git a/packages/toolpack-sdk/src/providers/index.ts b/packages/toolpack-sdk/src/providers/index.ts index a84a8ca..f7b06cc 100644 --- a/packages/toolpack-sdk/src/providers/index.ts +++ b/packages/toolpack-sdk/src/providers/index.ts @@ -25,5 +25,7 @@ export { getRegisteredSlmModels, getDefaultSlmModel, isRegisteredSlm } from "./o export type { SlmModelEntry } from "./ollama/slm-registry.js"; // OpenAI export * from './openai/index.js'; +// OpenRouter +export * from './openrouter/index.js'; // Media Utilities export * from './media-utils.js'; diff --git a/packages/toolpack-sdk/src/providers/openrouter/index.ts b/packages/toolpack-sdk/src/providers/openrouter/index.ts new file mode 100644 index 0000000..eb363b2 --- /dev/null +++ b/packages/toolpack-sdk/src/providers/openrouter/index.ts @@ -0,0 +1,100 @@ +import { OpenAIAdapter } from '../openai/index.js'; +import { ProviderModelInfo, CompletionRequest, CompletionResponse, CompletionChunk } from '../../types/index.js'; + +const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1'; + +export interface OpenRouterOptions { + siteUrl?: string; + siteName?: string; +} + +export class OpenRouterAdapter extends OpenAIAdapter { + name = 'openrouter'; + private readonly _apiKey: string; + + constructor(apiKey: string, options: OpenRouterOptions = {}) { + super(apiKey, OPENROUTER_BASE_URL); + this._apiKey = apiKey; + // Attribution headers (HTTP-Referer, X-Title) are best-effort and only matter for + // the OpenRouter leaderboard — they don't affect routing or pricing. + // Injecting them requires a protected client in the parent; skip for now. + void options; + } + + getDisplayName(): string { + return 'OpenRouter'; + } + + supportsFileUpload(): boolean { + return false; + } + + async generate(request: CompletionRequest): Promise { + return super.generate(this.normalizeRequest(request)); + } + + async *stream(request: CompletionRequest): AsyncGenerator { + yield* super.stream(this.normalizeRequest(request)); + } + + // OpenRouter passes tool_choice straight to the model endpoint with no translation. + // Models like Nemotron reject tool_choice: 'none' with a 404. Strip tools entirely + // instead — same effect, universally supported. + private normalizeRequest(request: CompletionRequest): CompletionRequest { + if (request.tool_choice === 'none') { + return { ...request, tools: undefined, tool_choice: undefined }; + } + return request; + } + + async getModels(): Promise { + try { + const res = await fetch(`${OPENROUTER_BASE_URL}/models`, { + headers: { Authorization: `Bearer ${this._apiKey}` }, + }); + if (!res.ok) return []; + const json = await res.json() as { data: any[] }; + return json.data.map(m => this.mapModel(m)); + } catch { + return []; + } + } + + private mapModel(m: any): ProviderModelInfo { + const modality: string = m.architecture?.modality ?? 'text->text'; + const hasVision = modality.includes('image'); + return { + id: m.id, + displayName: m.name ?? m.id, + capabilities: { + chat: true, + streaming: true, + toolCalling: true, + embeddings: false, + vision: hasVision, + }, + contextWindow: m.context_length ?? undefined, + maxOutputTokens: m.top_provider?.max_completion_tokens ?? undefined, + inputModalities: hasVision ? ['text', 'image'] : ['text'], + outputModalities: ['text'], + reasoningTier: null, + costTier: this.deriveCostTier(m.pricing), + }; + } + + // OpenRouter pricing.prompt is cost per token in USD. + // Multiply by 1e6 to get cost per 1M tokens for comparison. + // Thresholds calibrated against real prices (May 2026): + // low < $1/1M (Llama 3, Haiku, GPT-4.1 Mini) + // medium < $5/1M (GPT-4.1, Claude Sonnet) + // high < $20/1M (GPT-4o, Claude Opus) + // premium >= $20/1M (o3, frontier reasoning models) + private deriveCostTier(pricing?: { prompt?: string }): string { + if (!pricing?.prompt) return 'unknown'; + const costPerM = parseFloat(pricing.prompt) * 1_000_000; + if (costPerM < 1) return 'low'; + if (costPerM < 5) return 'medium'; + if (costPerM < 20) return 'high'; + return 'premium'; + } +} diff --git a/packages/toolpack-sdk/src/providers/provider-logger.ts b/packages/toolpack-sdk/src/providers/provider-logger.ts index a56a045..cf89b87 100644 --- a/packages/toolpack-sdk/src/providers/provider-logger.ts +++ b/packages/toolpack-sdk/src/providers/provider-logger.ts @@ -1,5 +1,5 @@ -import { appendFileSync } from 'fs'; -import { join } from 'path'; +import { appendFileSync, mkdirSync } from 'fs'; +import { dirname, isAbsolute, join, resolve } from 'path'; export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace'; @@ -15,6 +15,7 @@ export const LEVEL_VALUES: Record = { let _enabled = false; let _level: LogLevel = 'info'; let _logFile = join(process.cwd(), 'toolpack-sdk.log'); +let _console = false; export interface LoggingConfig { /** Enable file logging. Default: false */ @@ -23,6 +24,8 @@ export interface LoggingConfig { filePath?: string; /** Log level. Default: 'info' */ level?: LogLevel; + /** Mirror log output to console (stderr for error/warn, stdout for others). Default: false */ + console?: boolean; } function parseLevel(value: string | undefined): LogLevel | undefined { @@ -63,6 +66,29 @@ export function initLogger(config?: LoggingConfig): void { if (process.env.TOOLPACK_SDK_LOG_LEVEL) { _level = parseLevel(process.env.TOOLPACK_SDK_LOG_LEVEL) || _level; } + if (process.env.TOOLPACK_SDK_LOG_CONSOLE !== undefined) { + _console = process.env.TOOLPACK_SDK_LOG_CONSOLE === 'true'; + } + if (config?.console !== undefined && process.env.TOOLPACK_SDK_LOG_CONSOLE === undefined) { + _console = config.console; + } + + // Normalize log file path and ensure its parent directory exists so appendFileSync + // doesn't throw ENOENT for relative/nested paths like "./toolpack/logs/kael-debug.log". + if (_enabled) { + try { + _logFile = isAbsolute(_logFile) ? _logFile : resolve(process.cwd(), _logFile); + mkdirSync(dirname(_logFile), { recursive: true }); + // Emit a sentinel line so it's obvious logging is working. + appendFileSync( + _logFile, + `[${new Date().toISOString()}] [INFO] [Logger] initialized level=${_level} file=${_logFile}\n` + ); + } catch (err) { + console.warn(`[Toolpack Warning] Failed to initialize log file "${_logFile}": ${(err as Error).message}`); + _enabled = false; + } + } } // ── Public API (unchanged signatures) ──────────────────────────── @@ -83,8 +109,12 @@ export function shouldLog(level: LogLevel): boolean { function writeLog(level: LogLevel, message: string): void { if (!shouldLog(level)) return; const timestamp = new Date().toISOString(); - const entry = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`; - appendFileSync(_logFile, entry); + const entry = `[${timestamp}] [${level.toUpperCase()}] ${redact(message)}`; + appendFileSync(_logFile, entry + '\n'); + if (_console) { + const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; + fn(entry); + } } // ── Level API ──────────────────────────────────────────────────── @@ -110,7 +140,12 @@ export function redact(text: string): string { .replace(/\bsk-[A-Za-z0-9_-]{10,}\b/g, '[REDACTED]') .replace(/\bsk-proj-[A-Za-z0-9_-]{10,}\b/g, '[REDACTED]') .replace(/\bAIza[0-9A-Za-z_-]{10,}\b/g, '[REDACTED]') - .replace(/\bBearer\s+[A-Za-z0-9._-]{10,}\b/g, 'Bearer [REDACTED]'); + .replace(/\bBearer\s+[A-Za-z0-9._-]{10,}\b/g, 'Bearer [REDACTED]') + // GitHub tokens + .replace(/\bghs_[A-Za-z0-9]{10,}\b/g, 'ghs_[REDACTED]') + .replace(/\bghp_[A-Za-z0-9]{10,}\b/g, 'ghp_[REDACTED]') + .replace(/\bghu_[A-Za-z0-9]{10,}\b/g, 'ghu_[REDACTED]') + .replace(/\bghr_[A-Za-z0-9]{10,}\b/g, 'ghr_[REDACTED]'); } export function safePreview(value: unknown, maxLen = 200): string { diff --git a/packages/toolpack-sdk/src/toolpack.ts b/packages/toolpack-sdk/src/toolpack.ts index d625919..820366e 100644 --- a/packages/toolpack-sdk/src/toolpack.ts +++ b/packages/toolpack-sdk/src/toolpack.ts @@ -8,16 +8,16 @@ import { EmbeddingRequest, EmbeddingResponse, } from './providers/base/index.js'; -import { ProviderInfo, ProviderModelInfo, ContextWindowConfig } from "./types/index.js"; +import { ProviderInfo, ProviderModelInfo, RequestToolDefinition, ContextWindowConfig } from "./types/index.js"; import { OpenAIAdapter } from './providers/openai/index.js'; import { AnthropicAdapter } from './providers/anthropic/index.js'; import { GeminiAdapter } from './providers/gemini/index.js'; import { OllamaAdapter, OllamaProvider } from './providers/ollama/index.js'; +import { OpenRouterAdapter } from './providers/openrouter/index.js'; import { getOllamaBaseUrl, loadConfig, discoverConfigPath } from './providers/config.js'; import { initLogger, logWarn,logError,logInfo } from './providers/provider-logger.js'; import { ToolRegistry } from './tools/registry.js'; import { loadToolsConfig, loadFullConfig, ToolProject } from './tools/index.js'; -import { ToolDefinition } from './tools/types.js'; import { ModeConfig } from './modes/mode-types.js'; import { ModeRegistry } from './modes/mode-registry.js'; import { DEFAULT_MODE_NAME } from './modes/built-in-modes.js'; @@ -39,6 +39,12 @@ export interface ProviderOptions { /** Base URL override (for OpenAI-compatible endpoints or custom Ollama host) */ baseUrl?: string; + + /** OpenRouter only: your site URL for the leaderboard/attribution header */ + siteUrl?: string; + + /** OpenRouter only: your site name for the leaderboard/attribution header */ + siteName?: string; } export interface ToolpackInitConfig { @@ -101,12 +107,13 @@ export interface ToolpackInitConfig { /** * Optional Knowledge instance for RAG (Retrieval-Augmented Generation). - * When provided, the knowledge base will be registered as a tool that the AI can use to search documentation. + * When provided, knowledge_search and knowledge_add tools are automatically available + * as request-scoped tools that the AI can use to retrieve and store information. * Can be null if initialization fails - will be gracefully skipped. * * Accepts any object with a `toTool()` method (e.g. `Knowledge` from `@toolpack-sdk/knowledge`). */ - knowledge?: KnowledgeInstance | null; + knowledge?: KnowledgeInstance | KnowledgeInstance[] | null; /** * Human-in-the-loop configuration for tool confirmation. @@ -147,6 +154,7 @@ export interface KnowledgeInstance { }; execute: (params: { query: string; limit?: number; threshold?: number; filter?: Record }) => Promise; }; + add(content: string, metadata?: Record): Promise; query(text: string, options?: Record): Promise; stop(): Promise; } @@ -156,6 +164,7 @@ export class Toolpack extends EventEmitter { private activeProviderName: string; private modeRegistry: ModeRegistry; private workflowExecutor: WorkflowExecutor; + private knowledgeLayers: KnowledgeInstance[] = []; public customProviderNames: Set = new Set(); private mcpToolProject: ToolProject | null = null; @@ -176,6 +185,151 @@ export class Toolpack extends EventEmitter { this.forwardWorkflowEvents(); } + private buildKnowledgeRequestTools(): RequestToolDefinition[] { + if (this.knowledgeLayers.length === 0) { + return []; + } + + // Single layer: delegate directly to its tool (preserves original behavior) + if (this.knowledgeLayers.length === 1) { + const knowledgeSearchTool = this.knowledgeLayers[0].toTool(); + const knowledgeAddTool: RequestToolDefinition = { + name: 'knowledge_add', + displayName: 'Add to Knowledge', + description: 'Add important new information to the knowledge base for future reference.', + category: 'knowledge', + parameters: { + type: 'object', + properties: { + content: { + type: 'string', + description: 'The content to add to the knowledge base.', + }, + metadata: { + type: 'object', + description: 'Optional metadata such as source, category, or tags.', + }, + }, + required: ['content'], + }, + execute: async (args: Record) => { + const id = await this.knowledgeLayers[0].add(args.content, args.metadata); + return { + success: true, + id, + message: 'Content added to knowledge base successfully.', + }; + }, + }; + return [knowledgeSearchTool as unknown as RequestToolDefinition, knowledgeAddTool]; + } + + // Multiple layers: merge search results; add always targets first layer + const knowledgeSearchTool: RequestToolDefinition = { + name: 'knowledge_search', + displayName: 'Knowledge Search', + description: `Search across ${this.knowledgeLayers.length} knowledge layers for relevant information.`, + category: 'search', + cacheable: false, + parameters: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Search query to find relevant information', + }, + limit: { + type: 'number', + description: 'Maximum number of results to return (default: 10)', + }, + threshold: { + type: 'number', + description: 'Minimum similarity threshold 0-1 (default: 0.7)', + }, + filter: { + type: 'object', + description: 'Optional metadata filters', + }, + }, + required: ['query'], + }, + execute: async (args: Record) => { + // Delegate to each KB's own tool so the result shape matches the + // single-layer path exactly ({ content, score, metadata, ... }). + // Pass params through verbatim so each KB applies its own defaults. + const perLayerResults = await Promise.all( + this.knowledgeLayers.map(async (kb, index) => { + const tool = kb.toTool(); + const hits = await tool.execute({ + query: args.query, + limit: args.limit, + threshold: args.threshold, + filter: args.filter, + }); + return (hits as any[]).map(h => ({ ...h, _layer: index })); + }) + ); + + const allHits = perLayerResults.flat(); + allHits.sort((a: any, b: any) => (b.score ?? 0) - (a.score ?? 0)); + + // Cap the merged list; if no limit was requested, fall back to 10 + // (matches the tool's documented default). + const cap = args.limit ?? 10; + return allHits.slice(0, cap); + }, + }; + + const knowledgeAddTool: RequestToolDefinition = { + name: 'knowledge_add', + displayName: 'Add to Knowledge', + description: 'Add important new information to the primary knowledge base for future reference.', + category: 'knowledge', + parameters: { + type: 'object', + properties: { + content: { + type: 'string', + description: 'The content to add to the knowledge base.', + }, + metadata: { + type: 'object', + description: 'Optional metadata such as source, category, or tags.', + }, + }, + required: ['content'], + }, + execute: async (args: Record) => { + // Always add to the first (primary) layer + const id = await this.knowledgeLayers[0].add(args.content, args.metadata); + return { + success: true, + id, + message: 'Content added to knowledge base successfully.', + }; + }, + }; + + return [knowledgeSearchTool, knowledgeAddTool]; + } + + private prepareRequest(request: CompletionRequest): CompletionRequest { + const requestTools = [...this.buildKnowledgeRequestTools(), ...(request.requestTools || [])]; + if (requestTools.length === 0) { + return request; + } + + const merged = new Map(); + for (const tool of requestTools) { + merged.set(tool.name, tool); + } + + return { + ...request, + requestTools: Array.from(merged.values()), + }; + } + /** * Initialize the Toolpack SDK. * @@ -199,30 +353,6 @@ export class Toolpack extends EventEmitter { await registry.loadProjects(config.customTools); } - // Register knowledge base as a tool if provided - if (config.knowledge && typeof config.knowledge.toTool === 'function') { - try { - const knowledgeTool = config.knowledge.toTool(); - const knowledgeProject: ToolProject = { - manifest: { - key: 'knowledge', - name: 'knowledge', - displayName: 'Knowledge Base', - version: '1.0.0', - description: 'RAG-powered knowledge base search', - tools: ['knowledge_search'], - category: 'search', - }, - tools: [knowledgeTool as unknown as ToolDefinition], - }; - await registry.loadProjects([knowledgeProject]); - logInfo('[Knowledge] Registered knowledge_search tool'); - } catch (error) { - logError(`[Knowledge] Failed to register knowledge tool: ${error}`); - // Continue without knowledge tool rather than failing completely - } - } - // Load MCP tools from config if provided let mcpToolProject: ToolProject | null = null; const mcpConfig = config.mcp || fullConfig.mcp; @@ -387,6 +517,15 @@ export class Toolpack extends EventEmitter { }); const instance = new Toolpack(client, defaultProviderName, modeRegistry); + // Normalize knowledge to array; null becomes empty array for clean iteration. + // Filter out null/undefined entries and any entry missing the expected methods + // so that a bad item at config-time can't crash us at tool-execution time. + const k = config.knowledge; + const rawLayers = k == null ? [] : (Array.isArray(k) ? k : [k]); + instance.knowledgeLayers = rawLayers.filter( + (x): x is KnowledgeInstance => + !!x && typeof (x as KnowledgeInstance).toTool === 'function' + ); instance.customProviderNames = customProviderNames; instance.mcpToolProject = mcpToolProject; // 5. Set default mode (and workflow config) @@ -399,7 +538,6 @@ export class Toolpack extends EventEmitter { } } - return instance; } @@ -408,7 +546,7 @@ export class Toolpack extends EventEmitter { */ private static async createProvider(name: string, opts: ProviderOptions, configPath?: string, skipIfNoKey = false): Promise { // 1. API Providers - if (['openai', 'anthropic', 'gemini'].includes(name)) { + if (['openai', 'anthropic', 'gemini', 'openrouter'].includes(name)) { const envKey = `TOOLPACK_${name.toUpperCase()}_KEY`; const apiKey = opts.apiKey || process.env[envKey] || process.env[`${name.toUpperCase()}_API_KEY`]; @@ -423,6 +561,7 @@ export class Toolpack extends EventEmitter { case 'openai': return new OpenAIAdapter(apiKey, opts.baseUrl); case 'anthropic': return new AnthropicAdapter(apiKey, opts.baseUrl); case 'gemini': return new GeminiAdapter(apiKey); + case 'openrouter': return new OpenRouterAdapter(apiKey, { siteUrl: opts.siteUrl, siteName: opts.siteName }); } } @@ -463,6 +602,8 @@ export class Toolpack extends EventEmitter { req = request; } + req = this.prepareRequest(req); + const mode = this.getMode(); if (mode?.workflow?.planning?.enabled || mode?.workflow?.steps?.enabled) { // Workflow mode: use WorkflowExecutor @@ -532,17 +673,18 @@ export class Toolpack extends EventEmitter { } async *stream(request: CompletionRequest, providerName?: string): AsyncGenerator { + const preparedRequest = this.prepareRequest(request); const mode = this.getMode(); const provider = providerName || this.activeProviderName; // If mode has workflow enabled, use WorkflowExecutor.stream() if (mode?.workflow?.planning?.enabled || mode?.workflow?.steps?.enabled) { - yield* this.workflowExecutor.stream(request, provider); + yield* this.workflowExecutor.stream(preparedRequest, provider); return; } // Direct streaming (no workflow) - yield* this.client.stream(request, providerName); + yield* this.client.stream(preparedRequest, providerName); } async embed(request: EmbeddingRequest, providerName?: string): Promise { diff --git a/packages/toolpack-sdk/src/tools/github-tools/auth.ts b/packages/toolpack-sdk/src/tools/github-tools/auth.ts new file mode 100644 index 0000000..d63de21 --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/auth.ts @@ -0,0 +1,149 @@ +/** + * GitHub token resolution for toolpack github-tools. + * + * Resolution order (first match wins): + * 1. Explicit token passed by the caller (args.token) + * 2. GITHUB_PAT environment variable + * 3. GitHub App installation token — minted from GITHUB_APP_ID + + * GITHUB_APP_PRIVATE_KEY; installationId is looked up via the + * repo name when not supplied directly. + * + * Tokens are cached by installationId (50-minute TTL; GitHub tokens last 60). + */ + +import * as crypto from 'crypto'; + +interface CachedToken { + token: string; + expiresAt: number; // epoch ms +} + +const tokenCache = new Map(); +const installationIdCache = new Map(); // repo → installationId + +/** + * Resolve a GitHub API token from multiple sources. + * + * @param repo - "owner/name" used only for App installation lookup. + * @param explicitToken - Token passed directly in tool args (highest priority). + */ +export async function resolveGithubToken( + repo?: string, + explicitToken?: string, +): Promise { + if (explicitToken) return explicitToken; + + const pat = process.env.GITHUB_PAT; + if (pat) return pat; + + const appId = process.env.GITHUB_APP_ID; + const privateKey = process.env.GITHUB_APP_PRIVATE_KEY?.replace(/\\n/g, '\n'); + if (!appId || !privateKey) { + throw new Error( + 'No GitHub token available. Set GITHUB_PAT, or GITHUB_APP_ID + GITHUB_APP_PRIVATE_KEY.', + ); + } + + const installationId = await lookupInstallationId(appId, privateKey, repo); + return mintInstallationToken(appId, privateKey, installationId); +} + +// ── Internal ────────────────────────────────────────────────────────────────── + +async function lookupInstallationId( + appId: string, + privateKey: string, + repo: string | undefined, +): Promise { + if (!repo) { + throw new Error( + 'Cannot resolve GitHub App installation token without a repo name. Pass args.repo or set GITHUB_PAT.', + ); + } + + const cached = installationIdCache.get(repo); + if (cached !== undefined) return cached; + + const jwt = signAppJwt(appId, privateKey); + const res = await fetch(`https://api.github.com/repos/${repo}/installation`, { + headers: { + Authorization: `Bearer ${jwt}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + + if (!res.ok) { + const body = await res.text(); + throw new Error( + `Failed to look up installation for ${repo} (${res.status}): ${body}`, + ); + } + + const data = (await res.json()) as { id: number }; + installationIdCache.set(repo, data.id); + return data.id; +} + +async function mintInstallationToken( + appId: string, + privateKey: string, + installationId: number, +): Promise { + const cached = tokenCache.get(installationId); + if (cached && cached.expiresAt > Date.now() + 60_000) { + return cached.token; + } + + const jwt = signAppJwt(appId, privateKey); + const res = await fetch( + `https://api.github.com/app/installations/${installationId}/access_tokens`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${jwt}`, + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + }, + ); + + if (!res.ok) { + const body = await res.text(); + throw new Error(`Failed to mint installation token (${res.status}): ${body}`); + } + + const data = (await res.json()) as { token: string; expires_at: string }; + tokenCache.set(installationId, { + token: data.token, + expiresAt: new Date(data.expires_at).getTime(), + }); + return data.token; +} + +function signAppJwt(appId: string, privateKey: string): string { + const now = Math.floor(Date.now() / 1000); + const header = { alg: 'RS256', typ: 'JWT' }; + const payload = { iat: now - 30, exp: now + 9 * 60, iss: appId }; + + const enc = (obj: unknown): string => + Buffer.from(JSON.stringify(obj)) + .toString('base64') + .replace(/=+$/, '') + .replace(/\+/g, '-') + .replace(/\//g, '_'); + + const data = `${enc(header)}.${enc(payload)}`; + const signer = crypto.createSign('RSA-SHA256'); + signer.update(data); + signer.end(); + return ( + `${data}.` + + signer + .sign(privateKey) + .toString('base64') + .replace(/=+$/, '') + .replace(/\+/g, '-') + .replace(/\//g, '_') + ); +} diff --git a/packages/toolpack-sdk/src/tools/github-tools/common.ts b/packages/toolpack-sdk/src/tools/github-tools/common.ts new file mode 100644 index 0000000..5fc427d --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/common.ts @@ -0,0 +1,9 @@ +export function buildHeaders(token?: string, extra?: Record): Record { + const headers: Record = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...(extra || {}), + }; + if (token) headers.Authorization = `Bearer ${token}`; + return headers; +} diff --git a/packages/toolpack-sdk/src/tools/github-tools/index.ts b/packages/toolpack-sdk/src/tools/github-tools/index.ts new file mode 100644 index 0000000..0f9f094 --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/index.ts @@ -0,0 +1,54 @@ +import { ToolProject } from '../types.js'; +import { githubGraphqlExecuteTool } from './tools/graphql-execute/index.js'; +import { githubContentsGetTextTool } from './tools/contents-get-text/index.js'; +import { githubPrReviewThreadsListTool } from './tools/pr-review-threads-list/index.js'; +import { githubPrReviewThreadsResolveTool } from './tools/pr-review-threads-resolve/index.js'; +import { githubPrReviewCommentsReplyTool } from './tools/pr-review-comments-reply/index.js'; +import { githubPrDiffGetTool } from './tools/pr-diff-get/index.js'; +import { githubPrFilesListTool } from './tools/pr-files-list/index.js'; +import { githubPrReviewsSubmitTool } from './tools/pr-reviews-submit/index.js'; +import { githubIssuesCommentsCreateTool } from './tools/issues-comments-create/index.js'; +export { githubGraphqlExecuteTool } from './tools/graphql-execute/index.js'; +export { githubContentsGetTextTool } from './tools/contents-get-text/index.js'; +export { githubPrReviewThreadsListTool } from './tools/pr-review-threads-list/index.js'; +export { githubPrReviewThreadsResolveTool } from './tools/pr-review-threads-resolve/index.js'; +export { githubPrReviewCommentsReplyTool } from './tools/pr-review-comments-reply/index.js'; +export { githubPrDiffGetTool } from './tools/pr-diff-get/index.js'; +export { githubPrFilesListTool } from './tools/pr-files-list/index.js'; +export { githubPrReviewsSubmitTool } from './tools/pr-reviews-submit/index.js'; +export { githubIssuesCommentsCreateTool } from './tools/issues-comments-create/index.js'; + +export const githubToolsProject: ToolProject = { + manifest: { + key: 'github', + name: 'github-tools', + displayName: 'GitHub', + version: '1.0.0', + description: 'GitHub GraphQL/REST tools for PR threads, comments, and contents.', + author: 'Toolpack', + tools: [ + 'github.graphql.execute', + 'github.contents.getText', + 'github.pr.reviewThreads.list', + 'github.pr.reviewThreads.resolve', + 'github.pr.reviewComments.reply', + 'github.pr.diff.get', + 'github.pr.files.list', + 'github.pr.reviews.submit', + 'github.issues.comments.create', + ], + category: 'network', + }, + tools: [ + githubGraphqlExecuteTool, + githubContentsGetTextTool, + githubPrReviewThreadsListTool, + githubPrReviewThreadsResolveTool, + githubPrReviewCommentsReplyTool, + githubPrDiffGetTool, + githubPrFilesListTool, + githubPrReviewsSubmitTool, + githubIssuesCommentsCreateTool, + ], + dependencies: {}, +}; diff --git a/packages/toolpack-sdk/src/tools/github-tools/tools/contents-get-text/index.ts b/packages/toolpack-sdk/src/tools/github-tools/tools/contents-get-text/index.ts new file mode 100644 index 0000000..c6f94ca --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/tools/contents-get-text/index.ts @@ -0,0 +1,47 @@ +import { ToolDefinition } from '../../../types.js'; +import { name, displayName, description, parameters, category } from './schema.js'; +import { logDebug } from '../../../../providers/provider-logger.js'; +import { buildHeaders } from '../../common.js'; +import { resolveGithubToken } from '../../auth.js'; +import { Buffer } from 'node:buffer'; + +async function execute(args: Record): Promise { + const repo = args.repo as string; + const path = args.path as string; + const ref = args.ref as string | undefined; + const token = await resolveGithubToken(repo, args.token as string | undefined); + const maxBytes = args.maxBytes ? Number(args.maxBytes) : undefined; + const encodedPath = path.split('/').map((s) => encodeURIComponent(s)).join('/'); + const url = `https://api.github.com/repos/${repo}/contents/${encodedPath}${ref ? `?ref=${encodeURIComponent(ref)}` : ''}`; + logDebug(`[github.contents.getText] repo=${repo} path=${path} ref=${ref ?? ''}`); + + const resp = await fetch(url, { method: 'GET', headers: buildHeaders(token) }); + const text = await resp.text(); + if (!resp.ok) return `HTTP ${resp.status} ${resp.statusText}\n${text}`; + + try { + const json = JSON.parse(text) as any; + const b64 = json?.content as string | undefined; + if (typeof b64 === 'string') { + const raw = Buffer.from(b64.replace(/\n/g, ''), 'base64').toString('utf8'); + if (maxBytes && Buffer.byteLength(raw, 'utf8') > maxBytes) { + const slice = Buffer.from(raw, 'utf8').subarray(0, maxBytes).toString('utf8'); + const footer = `\n… [truncated, ${maxBytes} of ${Buffer.byteLength(raw, 'utf8')} bytes]`; + return `HTTP ${resp.status} ${resp.statusText}\n${slice}${footer}`; + } + return `HTTP ${resp.status} ${resp.statusText}\n${raw}`; + } + } catch { + // ignore + } + return `HTTP ${resp.status} ${resp.statusText}\n${text}`; +} + +export const githubContentsGetTextTool: ToolDefinition = { + name, + displayName, + description, + parameters, + category, + execute, +}; diff --git a/packages/toolpack-sdk/src/tools/github-tools/tools/contents-get-text/schema.ts b/packages/toolpack-sdk/src/tools/github-tools/tools/contents-get-text/schema.ts new file mode 100644 index 0000000..0f6db84 --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/tools/contents-get-text/schema.ts @@ -0,0 +1,18 @@ +import { ToolParameters } from '../../../types.js'; + +export const name = 'github.contents.getText'; +export const displayName = 'Get Repo File (Text)'; +export const description = 'Fetch file content (decoded text) via the GitHub Contents API.'; +export const category = 'github'; + +export const parameters: ToolParameters = { + type: 'object', + properties: { + repo: { type: 'string', description: 'owner/name (e.g. octo/repo)' }, + path: { type: 'string', description: 'File path within the repo' }, + ref: { type: 'string', description: 'Branch, tag, or commit sha' }, + token: { type: 'string', description: 'GitHub token (App installation or PAT)' }, + maxBytes: { type: 'integer', description: 'Optional max bytes of decoded text to return; if exceeded, result is truncated with a footer.' }, + }, + required: ['repo', 'path'], +}; diff --git a/packages/toolpack-sdk/src/tools/github-tools/tools/graphql-execute/index.ts b/packages/toolpack-sdk/src/tools/github-tools/tools/graphql-execute/index.ts new file mode 100644 index 0000000..04ea8a2 --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/tools/graphql-execute/index.ts @@ -0,0 +1,37 @@ +import { ToolDefinition } from '../../../types.js'; +import { name, displayName, description, parameters, category } from './schema.js'; +import { logDebug } from '../../../../providers/provider-logger.js'; +import { buildHeaders } from '../../common.js'; +import { resolveGithubToken } from '../../auth.js'; + +async function execute(args: Record): Promise { + const query = args.query as string; + const variables = (args.variables ?? {}) as Record; + const token = await resolveGithubToken(args.repo as string | undefined, args.token as string | undefined); + logDebug(`[github.graphql.execute] query_len=${query?.length ?? 0}`); + + const resp = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: buildHeaders(token, { 'Content-Type': 'application/json' }), + body: JSON.stringify({ query, variables }), + }); + const text = await resp.text(); + try { + const json = JSON.parse(text) as any; + if (json && Array.isArray(json.errors) && json.errors.length > 0) { + logDebug(`[github.graphql.execute] errors=${json.errors.length}`); + } + } catch { + // non-JSON body; ignore + } + return `HTTP ${resp.status} ${resp.statusText}\n${text}`; +} + +export const githubGraphqlExecuteTool: ToolDefinition = { + name, + displayName, + description, + parameters, + category, + execute, +}; diff --git a/packages/toolpack-sdk/src/tools/github-tools/tools/graphql-execute/schema.ts b/packages/toolpack-sdk/src/tools/github-tools/tools/graphql-execute/schema.ts new file mode 100644 index 0000000..cbb88ea --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/tools/graphql-execute/schema.ts @@ -0,0 +1,23 @@ +import { ToolParameters } from '../../../types.js'; + +export const name = 'github.graphql.execute'; +export const displayName = 'GitHub GraphQL'; +export const description = [ + 'Execute a GitHub GraphQL query or mutation with standard headers.', + 'NOTE: GitHub App installation tokens (ghs_*) cannot call certain write mutations.', + 'The following mutations require a PAT and will return FORBIDDEN with an App token:', + 'resolveReviewThread, unresolveReviewThread.', + 'If using an App token, avoid these mutations and use fallback strategies (replies, new comments).', +].join(' '); +export const category = 'github'; + +export const parameters: ToolParameters = { + type: 'object', + properties: { + query: { type: 'string', description: 'GraphQL query string' }, + variables: { type: 'object', description: 'Optional GraphQL variables' }, + repo: { type: 'string', description: 'owner/name — used for token resolution when no explicit token is provided' }, + token: { type: 'string', description: 'GitHub token (App installation or PAT). Optional — omit to auto-resolve from server credentials.' }, + }, + required: ['query'], +}; diff --git a/packages/toolpack-sdk/src/tools/github-tools/tools/issues-comments-create/index.ts b/packages/toolpack-sdk/src/tools/github-tools/tools/issues-comments-create/index.ts new file mode 100644 index 0000000..f7d8129 --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/tools/issues-comments-create/index.ts @@ -0,0 +1,30 @@ +import { ToolDefinition } from '../../../types.js'; +import { name, displayName, description, parameters, category } from './schema.js'; +import { buildHeaders } from '../../common.js'; +import { logDebug } from '../../../../providers/provider-logger.js'; +import { resolveGithubToken } from '../../auth.js'; + +async function execute(args: Record): Promise { + const repo = String(args.repo); + const number = Number(args.number); + const body = String(args.body); + const token = await resolveGithubToken(repo, args.token as string | undefined); + const url = `https://api.github.com/repos/${repo}/issues/${number}/comments`; + logDebug(`[github.issues.comments.create] repo=${repo} number=${number}`); + const resp = await fetch(url, { + method: 'POST', + headers: buildHeaders(token, { 'Content-Type': 'application/json' }), + body: JSON.stringify({ body }), + }); + const text = await resp.text(); + return `HTTP ${resp.status} ${resp.statusText}\n${text}`; +} + +export const githubIssuesCommentsCreateTool: ToolDefinition = { + name, + displayName, + description, + parameters, + category, + execute, +}; diff --git a/packages/toolpack-sdk/src/tools/github-tools/tools/issues-comments-create/schema.ts b/packages/toolpack-sdk/src/tools/github-tools/tools/issues-comments-create/schema.ts new file mode 100644 index 0000000..daaa24f --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/tools/issues-comments-create/schema.ts @@ -0,0 +1,17 @@ +import { ToolParameters } from '../../../types.js'; + +export const name = 'github.issues.comments.create'; +export const displayName = 'Create Issue/PR Comment'; +export const description = 'Create a comment on an issue or pull request (conversation tab).'; +export const category = 'github'; + +export const parameters: ToolParameters = { + type: 'object', + properties: { + repo: { type: 'string', description: 'owner/name' }, + number: { type: 'integer', description: 'Issue or PR number' }, + body: { type: 'string', description: 'Comment body' }, + token: { type: 'string', description: 'GitHub token (App installation or PAT)' }, + }, + required: ['repo', 'number', 'body'], +}; diff --git a/packages/toolpack-sdk/src/tools/github-tools/tools/pr-diff-get/index.ts b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-diff-get/index.ts new file mode 100644 index 0000000..833f385 --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-diff-get/index.ts @@ -0,0 +1,35 @@ +import { ToolDefinition } from '../../../types.js'; +import { name, displayName, description, parameters, category } from './schema.js'; +import { buildHeaders } from '../../common.js'; +import { logDebug } from '../../../../providers/provider-logger.js'; +import { resolveGithubToken } from '../../auth.js'; +import { Buffer } from 'node:buffer'; + +async function execute(args: Record): Promise { + const repo = String(args.repo); + const number = Number(args.number); + const token = await resolveGithubToken(repo, args.token as string | undefined); + const maxBytes = args.maxBytes ? Number(args.maxBytes) : undefined; + const url = `https://api.github.com/repos/${repo}/pulls/${number}`; + logDebug(`[github.pr.diff.get] repo=${repo} pr=${number}`); + const resp = await fetch(url, { + method: 'GET', + headers: buildHeaders(token, { Accept: 'application/vnd.github.v3.diff' }), + }); + const body = await resp.text(); + if (maxBytes && Buffer.byteLength(body, 'utf8') > maxBytes) { + const slice = Buffer.from(body, 'utf8').subarray(0, maxBytes).toString('utf8'); + const footer = `\n… [truncated, ${maxBytes} of ${Buffer.byteLength(body, 'utf8')} bytes]`; + return `HTTP ${resp.status} ${resp.statusText}\n${slice}${footer}`; + } + return `HTTP ${resp.status} ${resp.statusText}\n${body}`; +} + +export const githubPrDiffGetTool: ToolDefinition = { + name, + displayName, + description, + parameters, + category, + execute, +}; diff --git a/packages/toolpack-sdk/src/tools/github-tools/tools/pr-diff-get/schema.ts b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-diff-get/schema.ts new file mode 100644 index 0000000..beae22d --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-diff-get/schema.ts @@ -0,0 +1,17 @@ +import { ToolParameters } from '../../../types.js'; + +export const name = 'github.pr.diff.get'; +export const displayName = 'Get PR Diff'; +export const description = 'Fetch the unified diff for a pull request (text/patch).'; +export const category = 'github'; + +export const parameters: ToolParameters = { + type: 'object', + properties: { + repo: { type: 'string', description: 'owner/name' }, + number: { type: 'integer', description: 'PR number' }, + token: { type: 'string', description: 'GitHub token (App installation or PAT)' }, + maxBytes: { type: 'integer', description: 'Optional max bytes of diff to return; if exceeded, result is truncated with a footer.' }, + }, + required: ['repo', 'number'], +}; diff --git a/packages/toolpack-sdk/src/tools/github-tools/tools/pr-files-list/index.ts b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-files-list/index.ts new file mode 100644 index 0000000..da42092 --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-files-list/index.ts @@ -0,0 +1,33 @@ +import { ToolDefinition } from '../../../types.js'; +import { name, displayName, description, parameters, category } from './schema.js'; +import { buildHeaders } from '../../common.js'; +import { logDebug } from '../../../../providers/provider-logger.js'; +import { resolveGithubToken } from '../../auth.js'; + +async function execute(args: Record): Promise { + const repo = String(args.repo); + const number = Number(args.number); + const token = await resolveGithubToken(repo, args.token as string | undefined); + const perPage = args.perPage ? Number(args.perPage) : undefined; + const page = args.page ? Number(args.page) : undefined; + const qp = new URLSearchParams(); + if (perPage) qp.set('per_page', String(perPage)); + if (page) qp.set('page', String(page)); + const url = `https://api.github.com/repos/${repo}/pulls/${number}/files${qp.toString() ? `?${qp.toString()}` : ''}`; + logDebug(`[github.pr.files.list] repo=${repo} pr=${number} perPage=${perPage ?? ''} page=${page ?? ''}`); + const resp = await fetch(url, { + method: 'GET', + headers: buildHeaders(token), + }); + const text = await resp.text(); + return `HTTP ${resp.status} ${resp.statusText}\n${text}`; +} + +export const githubPrFilesListTool: ToolDefinition = { + name, + displayName, + description, + parameters, + category, + execute, +}; diff --git a/packages/toolpack-sdk/src/tools/github-tools/tools/pr-files-list/schema.ts b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-files-list/schema.ts new file mode 100644 index 0000000..ce04c36 --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-files-list/schema.ts @@ -0,0 +1,18 @@ +import { ToolParameters } from '../../../types.js'; + +export const name = 'github.pr.files.list'; +export const displayName = 'List PR Files'; +export const description = 'List files changed in a PR with positions metadata.'; +export const category = 'github'; + +export const parameters: ToolParameters = { + type: 'object', + properties: { + repo: { type: 'string', description: 'owner/name' }, + number: { type: 'integer', description: 'PR number' }, + token: { type: 'string', description: 'GitHub token (App installation or PAT)' }, + perPage: { type: 'integer', description: 'Results per page (max 100)' }, + page: { type: 'integer', description: 'Page number' }, + }, + required: ['repo', 'number'], +}; diff --git a/packages/toolpack-sdk/src/tools/github-tools/tools/pr-review-comments-reply/index.ts b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-review-comments-reply/index.ts new file mode 100644 index 0000000..6622f13 --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-review-comments-reply/index.ts @@ -0,0 +1,31 @@ +import { ToolDefinition } from '../../../types.js'; +import { name, displayName, description, parameters, category } from './schema.js'; +import { logDebug } from '../../../../providers/provider-logger.js'; +import { buildHeaders } from '../../common.js'; +import { resolveGithubToken } from '../../auth.js'; + +async function execute(args: Record): Promise { + const repo = String(args.repo); + const number = Number(args.number); + const inReplyTo = Number(args.inReplyTo); + const body = String(args.body); + const token = await resolveGithubToken(repo, args.token as string | undefined); + const url = `https://api.github.com/repos/${repo}/pulls/${number}/comments/${inReplyTo}/replies`; + logDebug(`[github.pr.reviewComments.reply] repo=${repo} inReplyTo=${inReplyTo}`); + const resp = await fetch(url, { + method: 'POST', + headers: buildHeaders(token, { 'Content-Type': 'application/json' }), + body: JSON.stringify({ body }), + }); + const text = await resp.text(); + return `HTTP ${resp.status} ${resp.statusText}\n${text}`; +} + +export const githubPrReviewCommentsReplyTool: ToolDefinition = { + name, + displayName, + description, + parameters, + category, + execute, +}; diff --git a/packages/toolpack-sdk/src/tools/github-tools/tools/pr-review-comments-reply/schema.ts b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-review-comments-reply/schema.ts new file mode 100644 index 0000000..d2b05c7 --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-review-comments-reply/schema.ts @@ -0,0 +1,18 @@ +import { ToolParameters } from '../../../types.js'; + +export const name = 'github.pr.reviewComments.reply'; +export const displayName = 'Reply to Review Comment'; +export const description = 'Reply within an existing PR review thread to maintain continuity.'; +export const category = 'github'; + +export const parameters: ToolParameters = { + type: 'object', + properties: { + repo: { type: 'string', description: 'owner/name' }, + number: { type: 'integer', description: 'PR number' }, + inReplyTo: { type: 'integer', description: 'databaseId of the review comment to reply to' }, + body: { type: 'string', description: 'Reply body' }, + token: { type: 'string', description: 'GitHub token (App installation or PAT)' }, + }, + required: ['repo', 'number', 'inReplyTo', 'body'], +}; diff --git a/packages/toolpack-sdk/src/tools/github-tools/tools/pr-review-threads-list/index.ts b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-review-threads-list/index.ts new file mode 100644 index 0000000..ecdd5e7 --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-review-threads-list/index.ts @@ -0,0 +1,61 @@ +import { ToolDefinition } from '../../../types.js'; +import { name, displayName, description, parameters, category } from './schema.js'; +import { buildHeaders } from '../../common.js'; +import { logDebug } from '../../../../providers/provider-logger.js'; +import { resolveGithubToken } from '../../auth.js'; + +async function execute(args: Record): Promise { + const [owner, repoName] = String(args.repo).split('/'); + const number = Number(args.number); + const token = await resolveGithubToken(args.repo as string, args.token as string | undefined); + const unresolvedOnly = Boolean(args.unresolvedOnly); + const first = args.first ? Number(args.first) : 100; + const after = args.after ? String(args.after) : undefined; + const commentsFirst = args.commentsFirst ? Number(args.commentsFirst) : 20; + const includeMeta = Boolean(args.includeMeta); + logDebug(`[github.pr.reviewThreads.list] repo=${owner}/${repoName} pr=${number} unresolvedOnly=${unresolvedOnly} first=${first} after=${after ?? ''} commentsFirst=${commentsFirst} includeMeta=${includeMeta}`); + const query = `query($owner:String!,$name:String!,$number:Int!,$first:Int!,$after:String,$commentsFirst:Int!){ + repository(owner:$owner,name:$name){ + pullRequest(number:$number){ + headRefOid + reviewThreads(first:$first, after:$after){ + pageInfo{ hasNextPage endCursor } + nodes{ id isResolved isOutdated comments(first:$commentsFirst){ nodes{ databaseId body author{login} path } } } + } + } + } + }`; + const resp = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: buildHeaders(token, { 'Content-Type': 'application/json' }), + body: JSON.stringify({ query, variables: { owner, name: repoName, number, first, after, commentsFirst } }), + }); + const raw = await resp.text(); + if (!resp.ok) return `HTTP ${resp.status} ${resp.statusText}\n${raw}`; + try { + const json = JSON.parse(raw) as any; + if (json && Array.isArray(json.errors) && json.errors.length > 0) { + logDebug(`[github.pr.reviewThreads.list] errors=${json.errors.length}`); + } + const pr = json?.data?.repository?.pullRequest; + const pageInfo = pr?.reviewThreads?.pageInfo ?? { hasNextPage: false, endCursor: null }; + let nodes = pr?.reviewThreads?.nodes ?? []; + if (unresolvedOnly) nodes = nodes.filter((n: any) => n?.isResolved === false); + if (includeMeta) { + const result = { headRefOid: pr?.headRefOid, threads: nodes, pageInfo }; + return `HTTP 200 OK\n${JSON.stringify(result)}`; + } + return `HTTP 200 OK\n${JSON.stringify(nodes)}`; + } catch { + return `HTTP ${resp.status} ${resp.statusText}\n${raw}`; + } +} + +export const githubPrReviewThreadsListTool: ToolDefinition = { + name, + displayName, + description, + parameters, + category, + execute, +}; diff --git a/packages/toolpack-sdk/src/tools/github-tools/tools/pr-review-threads-list/schema.ts b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-review-threads-list/schema.ts new file mode 100644 index 0000000..21dbb29 --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-review-threads-list/schema.ts @@ -0,0 +1,21 @@ +import { ToolParameters } from '../../../types.js'; + +export const name = 'github.pr.reviewThreads.list'; +export const displayName = 'List PR Review Threads'; +export const description = 'List PR review threads via GraphQL (optionally unresolved only).'; +export const category = 'github'; + +export const parameters: ToolParameters = { + type: 'object', + properties: { + repo: { type: 'string', description: 'owner/name' }, + number: { type: 'integer', description: 'PR number' }, + token: { type: 'string', description: 'GitHub token (App installation or PAT)' }, + unresolvedOnly: { type: 'boolean', description: 'If true, filter unresolved threads only' }, + first: { type: 'integer', description: 'Threads page size (max 100). Default 100.' }, + after: { type: 'string', description: 'Cursor for pagination (GraphQL pageInfo.endCursor).' }, + commentsFirst: { type: 'integer', description: 'Comments per thread (max 100). Default 20.' }, + includeMeta: { type: 'boolean', description: 'If true, return { headRefOid, threads, pageInfo } instead of array.' }, + }, + required: ['repo', 'number'], +}; diff --git a/packages/toolpack-sdk/src/tools/github-tools/tools/pr-review-threads-resolve/index.ts b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-review-threads-resolve/index.ts new file mode 100644 index 0000000..b736d82 --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-review-threads-resolve/index.ts @@ -0,0 +1,44 @@ +import { ToolDefinition } from '../../../types.js'; +import { name, displayName, description, parameters, category } from './schema.js'; +import { buildHeaders } from '../../common.js'; +import { logDebug } from '../../../../providers/provider-logger.js'; +import { resolveGithubToken } from '../../auth.js'; + +async function execute(args: Record): Promise { + const id = String(args.threadId); + const token = await resolveGithubToken(args.repo as string | undefined, args.token as string | undefined); + logDebug(`[github.pr.reviewThreads.resolve] threadId=${id}`); + const mutation = 'mutation($id:ID!){ resolveReviewThread(input:{threadId:$id}) { thread { id isResolved } } }'; + const resp = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: buildHeaders(token, { 'Content-Type': 'application/json' }), + body: JSON.stringify({ query: mutation, variables: { id } }), + }); + const text = await resp.text(); + // GitHub App installation tokens (ghs_*) cannot resolve review threads — + // the resolveReviewThread mutation requires a user identity (PAT or user OAuth token). + // Detect this and return an actionable message so the AI can gracefully fall back. + if (resp.ok) { + try { + const json = JSON.parse(text) as { errors?: Array<{ type?: string; message?: string }> }; + const forbidden = json.errors?.find((e) => e.type === 'FORBIDDEN'); + if (forbidden) { + return [ + 'TOOL_UNSUPPORTED_FOR_TOKEN_TYPE: resolveReviewThread is not available for GitHub App installation tokens (ghs_*).', + 'GitHub requires a user identity (PAT with repo scope) for this operation.', + 'Fallback action: post a reply on the thread acknowledging the fix and ask the author to resolve it manually.', + ].join(' '); + } + } catch { /* non-JSON — fall through to raw response */ } + } + return `HTTP ${resp.status} ${resp.statusText}\n${text}`; +} + +export const githubPrReviewThreadsResolveTool: ToolDefinition = { + name, + displayName, + description, + parameters, + category, + execute, +}; diff --git a/packages/toolpack-sdk/src/tools/github-tools/tools/pr-review-threads-resolve/schema.ts b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-review-threads-resolve/schema.ts new file mode 100644 index 0000000..e35672a --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-review-threads-resolve/schema.ts @@ -0,0 +1,22 @@ +import { ToolParameters } from '../../../types.js'; + +export const name = 'github.pr.reviewThreads.resolve'; +export const displayName = 'Resolve Review Thread'; +export const description = [ + 'Resolve a PR review thread via GraphQL resolveReviewThread mutation.', + 'IMPORTANT: GitHub App installation tokens (ghs_*) cannot call this mutation — GitHub returns FORBIDDEN.', + 'This tool only works with a Personal Access Token (PAT) that has repo scope.', + 'If you are using a GitHub App installation token, do NOT call this tool.', + 'Instead, post a reply on the thread acknowledging the fix and ask the author to resolve it manually.', +].join(' '); +export const category = 'github'; + +export const parameters: ToolParameters = { + type: 'object', + properties: { + threadId: { type: 'string', description: 'GraphQL node ID of the review thread' }, + repo: { type: 'string', description: 'owner/name — used for token resolution when no explicit token is provided' }, + token: { type: 'string', description: 'GitHub token — MUST be a PAT with repo scope. App installation tokens (ghs_*) will receive FORBIDDEN.' }, + }, + required: ['threadId'], +}; diff --git a/packages/toolpack-sdk/src/tools/github-tools/tools/pr-reviews-submit/index.ts b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-reviews-submit/index.ts new file mode 100644 index 0000000..a2f8253 --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-reviews-submit/index.ts @@ -0,0 +1,38 @@ +import { ToolDefinition } from '../../../types.js'; +import { name, displayName, description, parameters, category } from './schema.js'; +import { buildHeaders } from '../../common.js'; +import { logDebug } from '../../../../providers/provider-logger.js'; +import { resolveGithubToken } from '../../auth.js'; + +async function execute(args: Record): Promise { + const repo = String(args.repo); + const number = Number(args.number); + const event = String(args.event); + const body = args.body ? String(args.body) : undefined; + const comments = Array.isArray(args.comments) ? args.comments : undefined; + const token = await resolveGithubToken(repo, args.token as string | undefined); + + const url = `https://api.github.com/repos/${repo}/pulls/${number}/reviews`; + logDebug(`[github.pr.reviews.submit] repo=${repo} pr=${number} event=${event} comments=${comments?.length ?? 0}`); + + const payload: any = { event }; + if (body) payload.body = body; + if (comments && comments.length > 0) payload.comments = comments; + + const resp = await fetch(url, { + method: 'POST', + headers: buildHeaders(token, { 'Content-Type': 'application/json' }), + body: JSON.stringify(payload), + }); + const text = await resp.text(); + return `HTTP ${resp.status} ${resp.statusText}\n${text}`; +} + +export const githubPrReviewsSubmitTool: ToolDefinition = { + name, + displayName, + description, + parameters, + category, + execute, +}; diff --git a/packages/toolpack-sdk/src/tools/github-tools/tools/pr-reviews-submit/schema.ts b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-reviews-submit/schema.ts new file mode 100644 index 0000000..bd2d545 --- /dev/null +++ b/packages/toolpack-sdk/src/tools/github-tools/tools/pr-reviews-submit/schema.ts @@ -0,0 +1,25 @@ +import { ToolParameters } from '../../../types.js'; + +export const name = 'github.pr.reviews.submit'; +export const displayName = 'Submit PR Review'; +export const description = 'Submit a PR review (APPROVE, REQUEST_CHANGES, or COMMENT), optionally with inline comments.'; +export const category = 'github'; + +export const parameters: ToolParameters = { + type: 'object', + properties: { + repo: { type: 'string', description: 'owner/name' }, + number: { type: 'integer', description: 'PR number' }, + event: { type: 'string', description: 'Review event', enum: ['APPROVE','REQUEST_CHANGES','COMMENT'] }, + body: { type: 'string', description: 'Top-level review body', }, + comments: { type: 'array', description: 'Optional inline comments array', items: { + type: 'object', properties: { + path: { type: 'string', description: 'File path' }, + position: { type: 'integer', description: 'Position in the diff' }, + body: { type: 'string', description: 'Comment body' }, + }, required: ['path','position','body'] + }}, + token: { type: 'string', description: 'GitHub token (App installation or PAT)' }, + }, + required: ['repo', 'number', 'event'], +}; diff --git a/packages/toolpack-sdk/src/tools/index.ts b/packages/toolpack-sdk/src/tools/index.ts index 6889679..8e4e9e6 100644 --- a/packages/toolpack-sdk/src/tools/index.ts +++ b/packages/toolpack-sdk/src/tools/index.ts @@ -36,6 +36,19 @@ export { httpGetTool, httpPostTool, httpPutTool, httpDeleteTool, httpDownloadTool, } from './http-tools/index.js'; +// github-tools +export { + githubToolsProject, + githubGraphqlExecuteTool, + githubContentsGetTextTool, + githubPrReviewThreadsListTool, + githubPrReviewThreadsResolveTool, + githubPrReviewCommentsReplyTool, + githubPrDiffGetTool, + githubPrFilesListTool, + githubPrReviewsSubmitTool, +} from './github-tools/index.js'; + // web-tools export { webToolsProject, diff --git a/packages/toolpack-sdk/src/tools/registry.ts b/packages/toolpack-sdk/src/tools/registry.ts index fa60d3b..173b9af 100644 --- a/packages/toolpack-sdk/src/tools/registry.ts +++ b/packages/toolpack-sdk/src/tools/registry.ts @@ -228,12 +228,13 @@ export class ToolRegistry { const { execToolsProject } = await import('./exec-tools/index.js'); const { systemToolsProject } = await import('./system-tools/index.js'); const { httpToolsProject } = await import('./http-tools/index.js'); + const { githubToolsProject } = await import('./github-tools/index.js'); const { webToolsProject } = await import('./web-tools/index.js'); const { codingToolsProject } = await import('./coding-tools/index.js'); const { gitToolsProject } = await import('./git-tools/index.js'); const { diffToolsProject } = await import('./diff-tools/index.js'); const { dbToolsProject } = await import('./db-tools/index.js'); const { cloudToolsProject } = await import('./cloud-tools/index.js'); - await this.loadProjects([fsToolsProject, execToolsProject, systemToolsProject, httpToolsProject, webToolsProject, codingToolsProject, gitToolsProject, diffToolsProject, dbToolsProject, cloudToolsProject]); + await this.loadProjects([fsToolsProject, execToolsProject, systemToolsProject, httpToolsProject, githubToolsProject, webToolsProject, codingToolsProject, gitToolsProject, diffToolsProject, dbToolsProject, cloudToolsProject]); } } diff --git a/packages/toolpack-sdk/src/types/index.ts b/packages/toolpack-sdk/src/types/index.ts index fee33ed..a99ec2a 100644 --- a/packages/toolpack-sdk/src/types/index.ts +++ b/packages/toolpack-sdk/src/types/index.ts @@ -90,6 +90,17 @@ export interface ToolCallRequest { function: ToolCallFunction; } +export interface RequestToolDefinition { + name: string; + displayName: string; + description: string; + parameters: Record; + category: string; + execute: (args: Record) => Promise; + cacheable?: boolean; + confirmation?: import('../tools/types.js').ToolConfirmation; +} + export interface ToolCallResult { id: string; name: string; @@ -107,6 +118,7 @@ export interface CompletionRequest { response_format?: 'text' | 'json_object'; stream?: boolean; tools?: ToolCallRequest[]; + requestTools?: RequestToolDefinition[]; tool_choice?: 'auto' | 'none' | 'required'; /** AbortSignal to cancel the request */ signal?: AbortSignal; diff --git a/packages/toolpack-sdk/tests/integration/knowledge-tools.test.ts b/packages/toolpack-sdk/tests/integration/knowledge-tools.test.ts new file mode 100644 index 0000000..da58142 --- /dev/null +++ b/packages/toolpack-sdk/tests/integration/knowledge-tools.test.ts @@ -0,0 +1,588 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { Toolpack } from '../../src/toolpack.js'; +import type { KnowledgeInstance } from '../../src/toolpack.js'; +import type { RequestToolDefinition } from '../../src/types/index.js'; + +describe('Knowledge Tools Integration', () => { + let mockKnowledge: KnowledgeInstance; + let addedChunks: Array<{ id: string; content: string; metadata?: Record }>; + + beforeEach(() => { + addedChunks = []; + + mockKnowledge = { + toTool: vi.fn().mockReturnValue({ + name: 'knowledge_search', + displayName: 'Knowledge Search', + description: 'Search the knowledge base for relevant information', + category: 'search', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query' }, + limit: { type: 'number', description: 'Maximum results' }, + }, + required: ['query'], + }, + execute: vi.fn().mockImplementation(async (args: { query: string; limit?: number }) => { + // Simple search implementation for testing + const results = addedChunks.filter(chunk => + chunk.content.toLowerCase().includes(args.query.toLowerCase()) + ).slice(0, args.limit || 5); + + return results.map(chunk => ({ + id: chunk.id, + content: chunk.content, + metadata: chunk.metadata, + score: 0.9, + })); + }), + }), + add: vi.fn().mockImplementation(async (content: string, metadata?: Record) => { + const id = `chunk-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + addedChunks.push({ id, content, metadata }); + return id; + }), + query: vi.fn().mockResolvedValue([]), + stop: vi.fn().mockResolvedValue(undefined), + }; + }); + + describe('knowledge_add tool', () => { + it('should create knowledge_add tool when knowledge is configured', async () => { + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + knowledge: mockKnowledge, + }); + + // Access the private prepareRequest method for testing + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + // Verify both knowledge tools are present + expect(request.requestTools).toBeDefined(); + expect(request.requestTools).toHaveLength(2); + + const knowledgeSearchTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_search'); + const knowledgeAddTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_add'); + + expect(knowledgeSearchTool).toBeDefined(); + expect(knowledgeAddTool).toBeDefined(); + }); + + it('should have correct structure for knowledge_add tool', async () => { + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + knowledge: mockKnowledge, + }); + + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + const knowledgeAddTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_add'); + + expect(knowledgeAddTool).toMatchObject({ + name: 'knowledge_add', + displayName: 'Add to Knowledge', + description: 'Add important new information to the knowledge base for future reference.', + category: 'knowledge', + }); + + expect(knowledgeAddTool?.parameters).toEqual({ + type: 'object', + properties: { + content: { + type: 'string', + description: 'The content to add to the knowledge base.', + }, + metadata: { + type: 'object', + description: 'Optional metadata such as source, category, or tags.', + }, + }, + required: ['content'], + }); + + expect(typeof knowledgeAddTool?.execute).toBe('function'); + }); + + it('should add content to knowledge base via knowledge_add tool', async () => { + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + knowledge: mockKnowledge, + }); + + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + const knowledgeAddTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_add'); + expect(knowledgeAddTool).toBeDefined(); + + // Execute the tool to add content + const result = await knowledgeAddTool!.execute({ + content: 'The API rate limit is 100 requests per minute', + metadata: { source: 'documentation', category: 'api' }, + }); + + // Verify add was called with correct arguments + expect(mockKnowledge.add).toHaveBeenCalledWith( + 'The API rate limit is 100 requests per minute', + { source: 'documentation', category: 'api' } + ); + + // Verify response structure + expect(result).toMatchObject({ + success: true, + message: 'Content added to knowledge base successfully.', + }); + expect(result.id).toBeDefined(); + expect(typeof result.id).toBe('string'); + }); + + it('should add content without metadata', async () => { + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + knowledge: mockKnowledge, + }); + + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + const knowledgeAddTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_add'); + + const result = await knowledgeAddTool!.execute({ + content: 'Important information without metadata', + }); + + expect(mockKnowledge.add).toHaveBeenCalledWith( + 'Important information without metadata', + undefined + ); + + expect(result).toMatchObject({ + success: true, + message: 'Content added to knowledge base successfully.', + }); + }); + }); + + describe('knowledge_add and knowledge_search integration', () => { + it('should add content and then find it via search', async () => { + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + knowledge: mockKnowledge, + }); + + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + const knowledgeAddTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_add'); + const knowledgeSearchTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_search'); + + expect(knowledgeAddTool).toBeDefined(); + expect(knowledgeSearchTool).toBeDefined(); + + // Add content + await knowledgeAddTool!.execute({ + content: 'The API rate limit is 100 requests per minute', + metadata: { source: 'documentation' }, + }); + + await knowledgeAddTool!.execute({ + content: 'Authentication requires an API key in the header', + metadata: { source: 'documentation' }, + }); + + await knowledgeAddTool!.execute({ + content: 'The database supports PostgreSQL and MySQL', + metadata: { source: 'technical-specs' }, + }); + + // Search for added content + const searchResults = await knowledgeSearchTool!.execute({ + query: 'API', + limit: 10, + }); + + // Verify search finds the added content + expect(searchResults).toHaveLength(2); + expect(searchResults[0].content).toContain('rate limit'); + expect(searchResults[1].content).toContain('Authentication'); + expect(searchResults[0].metadata).toEqual({ source: 'documentation' }); + }); + + it('should handle multiple add operations and search correctly', async () => { + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + knowledge: mockKnowledge, + }); + + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + const knowledgeAddTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_add'); + const knowledgeSearchTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_search'); + + // Add multiple pieces of information + const topics = [ + 'Python is a high-level programming language', + 'JavaScript runs in the browser and on Node.js', + 'TypeScript adds static typing to JavaScript', + 'Rust is a systems programming language', + ]; + + for (const topic of topics) { + await knowledgeAddTool!.execute({ content: topic }); + } + + // Search for JavaScript-related content + const jsResults = await knowledgeSearchTool!.execute({ + query: 'JavaScript', + limit: 5, + }); + + expect(jsResults).toHaveLength(2); + expect(jsResults.some((r: any) => r.content.includes('browser'))).toBe(true); + expect(jsResults.some((r: any) => r.content.includes('TypeScript'))).toBe(true); + + // Search for programming languages + const langResults = await knowledgeSearchTool!.execute({ + query: 'programming language', + limit: 5, + }); + + // At least Python and Rust should match + expect(langResults.length).toBeGreaterThanOrEqual(2); + }); + + it('should return empty results when search finds nothing', async () => { + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + knowledge: mockKnowledge, + }); + + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + const knowledgeSearchTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_search'); + + // Search without adding any content + const results = await knowledgeSearchTool!.execute({ + query: 'nonexistent content', + limit: 5, + }); + + expect(results).toHaveLength(0); + }); + }); + + describe('knowledge tools not present when knowledge is not configured', () => { + it('should not include knowledge tools when knowledge is not provided', async () => { + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + // No knowledge configured + }); + + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + // Should have no request tools + expect(request.requestTools).toBeUndefined(); + }); + + it('should not include knowledge tools when knowledge is null', async () => { + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + knowledge: null, + }); + + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + expect(request.requestTools).toBeUndefined(); + }); + + it('should not include knowledge tools when knowledge is empty array', async () => { + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + knowledge: [], + }); + + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + expect(request.requestTools).toBeUndefined(); + }); + }); + + describe('multiple knowledge layers (array)', () => { + let primaryKB: KnowledgeInstance; + let secondaryKB: KnowledgeInstance; + + beforeEach(() => { + // Primary KB returns high scores + primaryKB = { + toTool: vi.fn().mockReturnValue({ + name: 'knowledge_search', + displayName: 'Knowledge Search', + description: 'Primary knowledge', + category: 'search', + parameters: { type: 'object', properties: {}, required: [] }, + execute: vi.fn().mockImplementation(async () => [ + { id: 'p1', content: 'primary result A', score: 0.95, metadata: { source: 'primary' } }, + { id: 'p2', content: 'primary result B', score: 0.85, metadata: { source: 'primary' } }, + ]), + }), + add: vi.fn().mockResolvedValue('primary-chunk-id'), + query: vi.fn().mockResolvedValue([]), + stop: vi.fn().mockResolvedValue(undefined), + }; + + // Secondary KB returns lower scores + secondaryKB = { + toTool: vi.fn().mockReturnValue({ + name: 'knowledge_search', + displayName: 'Knowledge Search', + description: 'Secondary knowledge', + category: 'search', + parameters: { type: 'object', properties: {}, required: [] }, + execute: vi.fn().mockImplementation(async () => [ + { id: 's1', content: 'secondary result C', score: 0.90, metadata: { source: 'secondary' } }, + { id: 's2', content: 'secondary result D', score: 0.80, metadata: { source: 'secondary' } }, + ]), + }), + add: vi.fn().mockResolvedValue('secondary-chunk-id'), + query: vi.fn().mockResolvedValue([]), + stop: vi.fn().mockResolvedValue(undefined), + }; + }); + + it('should merge and sort results from multiple layers by score', async () => { + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + knowledge: [primaryKB, secondaryKB], + }); + + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + const searchTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_search'); + expect(searchTool).toBeDefined(); + + const results = await searchTool!.execute({ query: 'test' }); + + // Should be sorted by score descending + expect(results[0].score).toBe(0.95); + expect(results[1].score).toBe(0.90); + expect(results[2].score).toBe(0.85); + expect(results[3].score).toBe(0.80); + }); + + it('should tag each result with _layer index', async () => { + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + knowledge: [primaryKB, secondaryKB], + }); + + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + const searchTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_search'); + const results = await searchTool!.execute({ query: 'test' }); + + // primaryKB results should have _layer: 0, secondaryKB _layer: 1 + expect(results[0]._layer).toBe(0); // 0.95 from primary + expect(results[1]._layer).toBe(1); // 0.90 from secondary + expect(results[2]._layer).toBe(0); // 0.85 from primary + expect(results[3]._layer).toBe(1); // 0.80 from secondary + }); + + it('should respect limit across merged results', async () => { + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + knowledge: [primaryKB, secondaryKB], + }); + + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + const searchTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_search'); + const results = await searchTool!.execute({ query: 'test', limit: 2 }); + + expect(results).toHaveLength(2); + expect(results[0].score).toBe(0.95); + expect(results[1].score).toBe(0.90); + }); + + it('should default limit to 10 when not specified', async () => { + primaryKB.toTool = vi.fn().mockReturnValue({ + name: 'knowledge_search', + displayName: 'Knowledge Search', + description: 'Primary', + category: 'search', + parameters: { type: 'object', properties: {}, required: [] }, + execute: vi.fn().mockImplementation(async () => + Array.from({ length: 8 }, (_, i) => ({ id: `p${i}`, content: 'item', score: 0.9 - i * 0.01 })) + ), + }); + + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + knowledge: [primaryKB, secondaryKB], + }); + + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + const searchTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_search'); + const results = await searchTool!.execute({ query: 'test' }); // no limit + + // 8 + 4 = 12 possible, capped at 10 + expect(results.length).toBeLessThanOrEqual(10); + }); + + it('should have correct tool description mentioning multiple layers', async () => { + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + knowledge: [primaryKB, secondaryKB], + }); + + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + const searchTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_search'); + expect(searchTool!.description).toContain('2 knowledge layers'); + }); + + it('should add content to the first (primary) layer only', async () => { + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + knowledge: [primaryKB, secondaryKB], + }); + + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + const addTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_add'); + expect(addTool).toBeDefined(); + expect(addTool!.description).toContain('primary knowledge base'); + + await addTool!.execute({ content: 'new chunk', metadata: { tag: 'test' } }); + + expect(primaryKB.add).toHaveBeenCalledWith('new chunk', { tag: 'test' }); + expect(secondaryKB.add).not.toHaveBeenCalled(); + }); + + it('should filter out null/undefined entries in array', async () => { + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + knowledge: [primaryKB, null, secondaryKB, undefined] as any, + }); + + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + // Should still work with just the valid entries + const searchTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_search'); + expect(searchTool).toBeDefined(); + + const results = await searchTool!.execute({ query: 'test' }); + // Should merge from 2 valid KBs + expect(results).toHaveLength(4); + }); + + it('should filter out entries missing toTool method', async () => { + const invalidKB = { add: vi.fn(), query: vi.fn(), stop: vi.fn() } as any; // no toTool + + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + knowledge: [primaryKB, invalidKB, secondaryKB] as any, + }); + + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + const searchTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_search'); + const results = await searchTool!.execute({ query: 'test' }); + + // Should merge from 2 valid KBs, invalid entry ignored + expect(results).toHaveLength(4); + }); + + it('should behave identically to single KB when array has one element', async () => { + const toolpack = await Toolpack.init({ + provider: 'openai', + apiKey: 'test-key', + knowledge: [mockKnowledge], + }); + + const request = (toolpack as any).prepareRequest({ + messages: [{ role: 'user', content: 'test' }], + model: 'gpt-4', + }); + + // Should have same tools as single-KB path + expect(request.requestTools).toHaveLength(2); + + const searchTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_search'); + const addTool = request.requestTools?.find((t: RequestToolDefinition) => t.name === 'knowledge_add'); + + expect(searchTool).toBeDefined(); + expect(addTool).toBeDefined(); + }); + }); +}); diff --git a/packages/toolpack-sdk/tests/unit/client.test.ts b/packages/toolpack-sdk/tests/unit/client.test.ts index c1bee44..f919010 100644 --- a/packages/toolpack-sdk/tests/unit/client.test.ts +++ b/packages/toolpack-sdk/tests/unit/client.test.ts @@ -1,24 +1,41 @@ import { describe, it, expect, vi } from 'vitest'; import { AIClient } from '../../src/client'; import { ProviderAdapter, CompletionRequest, CompletionResponse, CompletionChunk, EmbeddingRequest, EmbeddingResponse } from '../../src/providers/base'; +import { ToolRegistry } from '../../src/tools/registry'; +import { ToolDefinition, DEFAULT_TOOL_SEARCH_CONFIG, DEFAULT_TOOLS_CONFIG } from '../../src/tools/types'; // A simple mock provider that just returns the received request so we can inspect it -class MockProvider implements ProviderAdapter { +class MockProvider extends ProviderAdapter { async generate(request: CompletionRequest): Promise { // Return the request serialized in the content so we can inspect it return { content: JSON.stringify(request), }; } + async *stream(request: CompletionRequest): AsyncGenerator { yield { delta: JSON.stringify(request) }; - yield { finish_reason: 'stop' }; + yield { delta: '', finish_reason: 'stop' }; } async embed(request: EmbeddingRequest): Promise { return { embeddings: [] }; } } +function makeTestTool(name: string, category: string, description: string): ToolDefinition { + return { + name, + displayName: name, + description, + category, + parameters: { + type: 'object', + properties: {}, + }, + execute: async () => '', + }; +} + describe('AIClient - System Prompt Injection', () => { it('should inject Base Agent Context by default', async () => { const client = new AIClient({ @@ -130,8 +147,14 @@ describe('AIClient - System Prompt Injection', () => { client.setMode({ name: 'no-context', displayName: 'Test', + description: 'Test mode', systemPrompt: 'Only me.', baseContext: false, + allowedTools: [], + blockedTools: [], + allowedToolCategories: [], + blockedToolCategories: [], + blockAllTools: false, }); const response = await client.generate({ @@ -156,8 +179,14 @@ describe('AIClient - System Prompt Injection', () => { client.setMode({ name: 'no-wd', displayName: 'Test', + description: 'Test mode', systemPrompt: 'Hello.', baseContext: { includeWorkingDirectory: false, includeToolCategories: true }, + allowedTools: [], + blockedTools: [], + allowedToolCategories: [], + blockedToolCategories: [], + blockAllTools: false, }); const response = await client.generate({ @@ -181,8 +210,14 @@ describe('AIClient - System Prompt Injection', () => { client.setMode({ name: 'custom-ctx', displayName: 'Test', + description: 'Test mode', systemPrompt: 'Hello.', baseContext: { custom: 'Custom built base context entirely.' }, + allowedTools: [], + blockedTools: [], + allowedToolCategories: [], + blockedToolCategories: [], + blockAllTools: false, }); const response = await client.generate({ @@ -197,4 +232,188 @@ describe('AIClient - System Prompt Injection', () => { expect(systemMessage.content).toContain('Custom built base context entirely.'); expect(systemMessage.content).toContain('Hello.'); }); + + it('should inject request-tool guidance and strip requestTools from provider payload', async () => { + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + disableBaseContext: true, + }); + + const response = await client.generate({ + messages: [{ role: 'user', content: 'test' }], + model: 'test-model', + requestTools: [ + { + name: 'knowledge_search', + displayName: 'Knowledge Search', + description: 'Search the knowledge base', + category: 'search', + parameters: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + execute: vi.fn(), + }, + { + name: 'conversation_search', + displayName: 'Conversation Search', + description: 'Search earlier conversation', + category: 'search', + parameters: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + execute: vi.fn(), + }, + ], + }); + + const request = JSON.parse(response.content || '{}'); + const systemMessage = request.messages.find((m: any) => m.role === 'system'); + + expect(systemMessage).toBeDefined(); + expect(systemMessage.content).toContain('knowledge_search'); + expect(systemMessage.content).toContain('conversation_search'); + expect(request.requestTools).toBeUndefined(); + expect(request.tools).toHaveLength(2); + }); + + it('should not duplicate guidance when marker is already present', async () => { + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + }); + + const response = await client.generate({ + messages: [ + { + role: 'system', + content: 'Existing prompt.\n\n\nKnowledge Base:\n- Use `knowledge_search` when you need factual or domain-specific information that may already be stored.' + }, + { role: 'user', content: 'test' } + ], + model: 'test-model', + requestTools: [ + { + name: 'knowledge_search', + displayName: 'Knowledge Search', + description: 'Search knowledge', + category: 'search', + parameters: { + type: 'object', + properties: { + query: { type: 'string', description: 'Search query' }, + }, + required: ['query'], + }, + execute: async () => ({}), + }, + ], + }); + + const request = JSON.parse(response.content || '{}'); + const systemMessage = request.messages.find((m: any) => m.role === 'system'); + + expect(systemMessage).toBeDefined(); + + // Count occurrences of the marker - should only appear once + const markerCount = (systemMessage.content.match(//g) || []).length; + expect(markerCount).toBe(1); + + // Count occurrences of "Knowledge Base:" - should only appear once + const knowledgeBaseCount = (systemMessage.content.match(/Knowledge Base:/g) || []).length; + expect(knowledgeBaseCount).toBe(1); + }); + + it('should keep tool.search available when mode restricts allowedToolCategories', async () => { + const registry = new ToolRegistry(); + registry.register(makeTestTool('web.search', 'network', 'Search the web for current information')); + registry.register(makeTestTool('fs.read_file', 'filesystem', 'Read local files')); + + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + disableBaseContext: true, + toolRegistry: registry, + toolsConfig: { + ...DEFAULT_TOOLS_CONFIG, + toolSearch: { + ...DEFAULT_TOOL_SEARCH_CONFIG, + enabled: true, + alwaysLoadedTools: [], + alwaysLoadedCategories: [], + }, + }, + }); + + client.setMode({ + name: 'network-only', + displayName: 'Network Only', + description: 'Only network tools should be callable', + systemPrompt: 'Use tools when needed.', + allowedTools: [], + blockedTools: [], + allowedToolCategories: ['network'], + blockedToolCategories: [], + blockAllTools: false, + toolSearch: { enabled: true }, + }); + + const response = await client.generate({ + messages: [{ role: 'user', content: 'What is the news today?' }], + model: 'test-model', + }); + + const request = JSON.parse(response.content || '{}'); + const toolNames = (request.tools || []).map((tool: any) => tool.function.name); + + expect(toolNames).toContain('tool.search'); + }); + + it('should make tool.search results respect mode allowed categories', () => { + const registry = new ToolRegistry(); + registry.register(makeTestTool('web.search', 'network', 'Search the web for headlines and current news')); + registry.register(makeTestTool('fs.read_file', 'filesystem', 'Read files from disk and local folders')); + + const client = new AIClient({ + providers: { mock: new MockProvider() }, + defaultProvider: 'mock', + disableBaseContext: true, + toolRegistry: registry, + toolsConfig: { + ...DEFAULT_TOOLS_CONFIG, + toolSearch: { + ...DEFAULT_TOOL_SEARCH_CONFIG, + enabled: true, + alwaysLoadedTools: [], + alwaysLoadedCategories: [], + }, + }, + }); + + client.setMode({ + name: 'network-only', + displayName: 'Network Only', + description: 'Only network tools should be searchable', + systemPrompt: 'Use tools when needed.', + allowedTools: [], + blockedTools: [], + allowedToolCategories: ['network'], + blockedToolCategories: [], + blockAllTools: false, + toolSearch: { enabled: true }, + }); + + const raw = (client as any).executeToolSearch({ query: 'search files and news' }); + const parsed = JSON.parse(raw); + const resultNames = parsed.tools.map((tool: any) => tool.name); + const resultCategories = parsed.tools.map((tool: any) => tool.category); + + expect(resultNames).toContain('web.search'); + expect(resultNames).not.toContain('fs.read_file'); + expect(resultCategories.every((category: string) => category === 'network')).toBe(true); + }); }); diff --git a/packages/toolpack-sdk/tests/unit/openrouter-adapter.test.ts b/packages/toolpack-sdk/tests/unit/openrouter-adapter.test.ts new file mode 100644 index 0000000..a268fec --- /dev/null +++ b/packages/toolpack-sdk/tests/unit/openrouter-adapter.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +describe('OpenRouterAdapter', () => { + let OpenRouterAdapter: any; + let mockCreate: any; + + beforeEach(async () => { + vi.resetModules(); + + mockCreate = vi.fn(); + vi.doMock('openai', () => { + class MockOpenAI { + chat = { completions: { create: mockCreate } }; + embeddings = { create: vi.fn().mockResolvedValue({ data: [{ embedding: [0.1, 0.2] }], usage: { prompt_tokens: 5, total_tokens: 5 } }) }; + static APIError = class APIError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.status = status; + } + }; + } + return { default: MockOpenAI }; + }); + + vi.doMock('../../src/providers/provider-logger', () => ({ + log: vi.fn(), + logError: vi.fn(), + logWarn: vi.fn(), + logInfo: vi.fn(), + logDebug: vi.fn(), + logTrace: vi.fn(), + safePreview: vi.fn((v: any) => String(v).slice(0, 50)), + logMessagePreview: vi.fn(), + isVerbose: vi.fn(() => false), + shouldLog: vi.fn(() => true), + getLogLevel: vi.fn(() => 3), + })); + + const mod = await import('../../src/providers/openrouter/index'); + OpenRouterAdapter = mod.OpenRouterAdapter; + }); + + describe('identity', () => { + it('should have name = openrouter', () => { + const adapter = new OpenRouterAdapter('test-key'); + expect(adapter.name).toBe('openrouter'); + }); + + it('should have display name OpenRouter', () => { + const adapter = new OpenRouterAdapter('test-key'); + expect(adapter.getDisplayName()).toBe('OpenRouter'); + }); + + it('supportsFileUpload() should be false', () => { + const adapter = new OpenRouterAdapter('test-key'); + expect(adapter.supportsFileUpload()).toBe(false); + }); + }); + + describe('generate()', () => { + it('should convert response to CompletionResponse format', async () => { + mockCreate.mockResolvedValue({ + choices: [{ + message: { content: 'Hello from OpenRouter!', tool_calls: null }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }); + + const adapter = new OpenRouterAdapter('test-key'); + const response = await adapter.generate({ + messages: [{ role: 'user', content: 'Hi' }], + model: 'anthropic/claude-sonnet-4-6', + }); + + expect(response.content).toBe('Hello from OpenRouter!'); + expect(response.finish_reason).toBe('stop'); + expect(response.usage?.total_tokens).toBe(15); + }); + + it('should handle tool calls in response', async () => { + mockCreate.mockResolvedValue({ + choices: [{ + message: { + content: null, + tool_calls: [{ + id: 'call_abc', + function: { name: 'fs_read_file', arguments: '{"path":"/test.txt"}' }, + }], + }, + finish_reason: 'tool_calls', + }], + usage: { prompt_tokens: 20, completion_tokens: 10, total_tokens: 30 }, + }); + + const adapter = new OpenRouterAdapter('test-key'); + const response = await adapter.generate({ + messages: [{ role: 'user', content: 'Read test.txt' }], + model: 'openai/gpt-4.1', + tools: [{ + type: 'function', + function: { name: 'fs.read_file', description: 'Read a file', parameters: { type: 'object', properties: { path: { type: 'string' } } } }, + }], + }); + + expect(response.tool_calls).toHaveLength(1); + expect(response.tool_calls![0].id).toBe('call_abc'); + expect(response.tool_calls![0].name).toBe('fs.read_file'); + expect(response.tool_calls![0].arguments).toEqual({ path: '/test.txt' }); + }); + }); + + describe('stream()', () => { + it('should yield text deltas', async () => { + const chunks = [ + { choices: [{ delta: { content: 'Hello ' }, finish_reason: null }], usage: null }, + { choices: [{ delta: { content: 'world' }, finish_reason: null }], usage: null }, + { choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 } }, + ]; + + mockCreate.mockResolvedValue({ + [Symbol.asyncIterator]: async function* () { + for (const c of chunks) yield c; + }, + }); + + const adapter = new OpenRouterAdapter('test-key'); + const results: any[] = []; + for await (const chunk of adapter.stream({ + messages: [{ role: 'user', content: 'Hi' }], + model: 'meta-llama/llama-3.3-70b-instruct', + })) { + results.push(chunk); + } + + const text = results.filter(c => c.delta).map(c => c.delta).join(''); + expect(text).toBe('Hello world'); + + const stopChunk = results.find(c => c.finish_reason === 'stop'); + expect(stopChunk).toBeDefined(); + }); + }); + + describe('getModels()', () => { + it('should fetch and map models from OpenRouter API', async () => { + const mockModels = { + data: [ + { + id: 'anthropic/claude-sonnet-4-6', + name: 'Claude Sonnet 4.6', + context_length: 200000, + architecture: { modality: 'text+image->text' }, + pricing: { prompt: '0.000003' }, + top_provider: { max_completion_tokens: 8192 }, + }, + { + id: 'meta-llama/llama-3.3-70b-instruct', + name: 'Llama 3.3 70B Instruct', + context_length: 131072, + architecture: { modality: 'text->text' }, + pricing: { prompt: '0.00000059' }, + top_provider: { max_completion_tokens: 32768 }, + }, + ], + }; + + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => mockModels, + }); + + const adapter = new OpenRouterAdapter('test-key'); + const models = await adapter.getModels(); + + expect(models).toHaveLength(2); + + const claude = models.find(m => m.id === 'anthropic/claude-sonnet-4-6')!; + expect(claude.displayName).toBe('Claude Sonnet 4.6'); + expect(claude.capabilities.vision).toBe(true); + expect(claude.contextWindow).toBe(200000); + expect(claude.costTier).toBe('medium'); // $3/1M + + const llama = models.find(m => m.id === 'meta-llama/llama-3.3-70b-instruct')!; + expect(llama.capabilities.vision).toBe(false); + expect(llama.costTier).toBe('low'); + }); + + it('should return empty array when fetch fails', async () => { + global.fetch = vi.fn().mockRejectedValue(new Error('network error')); + + const adapter = new OpenRouterAdapter('test-key'); + const models = await adapter.getModels(); + expect(models).toEqual([]); + }); + + it('should return empty array on non-ok response', async () => { + global.fetch = vi.fn().mockResolvedValue({ ok: false }); + + const adapter = new OpenRouterAdapter('test-key'); + const models = await adapter.getModels(); + expect(models).toEqual([]); + }); + }); + + describe('deriveCostTier (via getModels)', () => { + it.each([ + ['0.0000001', 'low'], // $0.10 / 1M + ['0.0000009', 'low'], // $0.90 / 1M — just under $1 threshold + ['0.000001', 'medium'], // $1.00 / 1M — hits medium tier + ['0.000003', 'medium'], // $3.00 / 1M + ['0.000004', 'medium'], // $4.00 / 1M — just under $5 threshold + ['0.000005', 'high'], // $5.00 / 1M — hits high tier + ['0.000015', 'high'], // $15.00 / 1M + ['0.00002', 'premium'], // $20.00 / 1M — hits premium tier + ['0.0001', 'premium'], // $100.00 / 1M + ])('pricing.prompt=%s → costTier=%s', async (prompt, expectedTier) => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [{ id: 'test/model', name: 'Test', context_length: 4096, architecture: { modality: 'text->text' }, pricing: { prompt } }], + }), + }); + + const adapter = new OpenRouterAdapter('test-key'); + const [model] = await adapter.getModels(); + expect(model.costTier).toBe(expectedTier); + }); + }); +}); diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..6ab57ca --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,278 @@ +# Version Update Script + +## Overview + +The `update-version.js` script updates versions across all Toolpack packages and their peer dependencies in a single command. + +## What It Updates + +### 1. Package Versions +- `packages/toolpack-sdk/package.json` +- `packages/toolpack-knowledge/package.json` +- `packages/toolpack-agents/package.json` +- `samples/toolpack-cli/package.json` + +### 2. Peer Dependencies +- `@toolpack-sdk/agents` peer dependencies: + - `toolpack-sdk` → `^{version}` + - `@toolpack-sdk/knowledge` → `^{version}` + +### 3. Display Versions +- `samples/toolpack-cli/source/components/AppInfo.tsx` + +## Usage + +```bash +# Basic usage +node scripts/update-version.js + +# Examples +node scripts/update-version.js 1.4.0 +node scripts/update-version.js 2.0.0-beta.1 +node scripts/update-version.js 1.3.1-SNAPSHOT.13042026 + +# Via npm script +npm run version 1.4.0 +``` + +## Version Format + +The script validates version format using semantic versioning: + +``` +X.Y.Z # Standard release (e.g., 1.4.0) +X.Y.Z-suffix # Pre-release (e.g., 2.0.0-beta.1) +X.Y.Z-SNAPSHOT.date # Snapshot (e.g., 1.4.0-SNAPSHOT.13042026) +``` + +## Output Example + +```bash +$ node scripts/update-version.js 1.4.0 + +🔄 Updating version to 1.4.0... + +📦 Step 1: Updating package versions + +✅ toolpack-sdk + 1.3.0 → 1.4.0 +✅ @toolpack-sdk/knowledge + 1.3.0 → 1.4.0 +✅ @toolpack-sdk/agents + 1.3.0 → 1.4.0 +✅ toolpack-cli + 1.3.0 → 1.4.0 +✅ AppInfo.tsx + const version = 'v1.3.0'; → const version = 'v1.4.0'; + +✨ Updated 5/5 package versions + +📦 Step 2: Updating peer dependencies + +✅ @toolpack-sdk/agents peer dependencies: + toolpack-sdk: ^1.3.0 → ^1.4.0 + @toolpack-sdk/knowledge: ^1.3.0 → ^1.4.0 + +✨ Version update complete! + +💡 Next steps: + 1. Review changes: git diff + 2. Build packages: npm run build + 3. Run tests: npm test + 4. Commit: git commit -am "chore: bump version to 1.4.0" + 5. Tag: git tag v1.4.0 + 6. Push: git push && git push --tags +``` + +## What Gets Updated + +### Before +```json +// packages/toolpack-sdk/package.json +{ + "name": "toolpack-sdk", + "version": "1.3.0" +} + +// packages/toolpack-knowledge/package.json +{ + "name": "@toolpack-sdk/knowledge", + "version": "1.3.0" +} + +// packages/toolpack-agents/package.json +{ + "name": "@toolpack-sdk/agents", + "version": "1.3.0", + "peerDependencies": { + "toolpack-sdk": "^1.3.0", + "@toolpack-sdk/knowledge": "^1.3.0" + } +} +``` + +### After (running `node scripts/update-version.js 1.4.0`) +```json +// packages/toolpack-sdk/package.json +{ + "name": "toolpack-sdk", + "version": "1.4.0" +} + +// packages/toolpack-knowledge/package.json +{ + "name": "@toolpack-sdk/knowledge", + "version": "1.4.0" +} + +// packages/toolpack-agents/package.json +{ + "name": "@toolpack-sdk/agents", + "version": "1.4.0", + "peerDependencies": { + "toolpack-sdk": "^1.4.0", + "@toolpack-sdk/knowledge": "^1.4.0" + } +} +``` + +## Error Handling + +### Missing Version Argument +```bash +$ node scripts/update-version.js + +❌ Error: Version argument required + +Usage: + node scripts/update-version.js +``` + +### Invalid Version Format +```bash +$ node scripts/update-version.js 1.4 + +❌ Error: Invalid version format: 1.4 + Expected format: X.Y.Z or X.Y.Z-suffix +``` + +### File Not Found +```bash +❌ Failed to update packages/toolpack-sdk/package.json: ENOENT: no such file or directory +``` + +## Integration with npm + +Add to `package.json`: + +```json +{ + "scripts": { + "version": "node scripts/update-version.js" + } +} +``` + +Then use: + +```bash +npm run version 1.4.0 +``` + +## Best Practices + +### 1. Always Review Changes +```bash +git diff +``` + +### 2. Build and Test Before Committing +```bash +npm run build +npm test +``` + +### 3. Use Consistent Version Numbers +- All packages use the same version +- Peer dependencies use `^` (caret) for compatibility + +### 4. Follow Semantic Versioning +- **Patch** (1.3.1): Bug fixes +- **Minor** (1.4.0): New features, backward compatible +- **Major** (2.0.0): Breaking changes + +### 5. Tag Releases +```bash +git tag v1.4.0 +git push --tags +``` + +## Troubleshooting + +### Script Fails to Update File + +**Problem:** Permission denied or file not found + +**Solution:** +```bash +# Check file exists +ls -la packages/toolpack-sdk/package.json + +# Check permissions +chmod +x scripts/update-version.js +``` + +### Peer Dependencies Not Updated + +**Problem:** Agents package doesn't have peer dependencies + +**Solution:** +- Check `packages/toolpack-agents/package.json` has `peerDependencies` field +- Script only updates existing peer dependencies + +### Version Mismatch After Update + +**Problem:** Some files show old version + +**Solution:** +```bash +# Re-run the script +node scripts/update-version.js 1.4.0 + +# Check all files +git diff +``` + +## Future Enhancements + +Potential improvements: + +1. **Dry Run Mode** + ```bash + node scripts/update-version.js 1.4.0 --dry-run + ``` + +2. **Selective Updates** + ```bash + node scripts/update-version.js 1.4.0 --only=sdk,agents + ``` + +3. **Automatic Git Operations** + ```bash + node scripts/update-version.js 1.4.0 --commit --tag + ``` + +4. **Changelog Generation** + - Auto-generate CHANGELOG.md entries + - Pull from git commits since last tag + +5. **Pre-release Versions** + - Auto-increment pre-release numbers + - `1.4.0-beta.1` → `1.4.0-beta.2` + +## Related Scripts + +- `npm run build` — Build all packages +- `npm test` — Run all tests +- `npm run lint` — Lint all packages +- `npm run publish` — Publish to npm (future) diff --git a/scripts/update-version.js b/scripts/update-version.js index 52220bb..3d43ffb 100755 --- a/scripts/update-version.js +++ b/scripts/update-version.js @@ -1,11 +1,16 @@ #!/usr/bin/env node /** - * Update version across all Toolpack packages + * Update version across all Toolpack packages and their peer dependencies * * Usage: - * node scripts/update-version.js - * node scripts/update-version.js + * node scripts/update-version.js * npm run version 1.3.0 + * + * This script: + * - Updates version in SDK, Knowledge, and Agents packages + * - Updates peer dependency versions in Agents package + * - Updates CLI sample version + * - Updates AppInfo.tsx version display */ import fs from 'fs'; @@ -15,25 +20,41 @@ import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootDir = path.join(__dirname, '..'); +// Package paths +const PACKAGES = { + sdk: 'packages/toolpack-sdk/package.json', + knowledge: 'packages/toolpack-knowledge/package.json', + agents: 'packages/toolpack-agents/package.json', +}; + // Files to update const FILES_TO_UPDATE = [ { - path: 'packages/toolpack-sdk/package.json', + path: PACKAGES.sdk, + field: 'version', + name: 'toolpack-sdk', + }, + { + path: PACKAGES.knowledge, field: 'version', + name: '@toolpack-sdk/knowledge', }, { - path: 'packages/toolpack-knowledge/package.json', + path: PACKAGES.agents, field: 'version', + name: '@toolpack-sdk/agents', }, { path: 'samples/toolpack-cli/package.json', field: 'version', + name: 'toolpack-cli', }, { path: 'samples/toolpack-cli/source/components/AppInfo.tsx', type: 'typescript', pattern: /const version = 'v[\d\.\-A-Z]+';/, replacement: (version) => `const version = 'v${version}';`, + name: 'AppInfo.tsx', }, ]; @@ -46,6 +67,37 @@ function updatePackageJson(filePath, version) { return oldVersion; } +function updatePeerDependencies(filePath, versions) { + const fullPath = path.join(rootDir, filePath); + const content = JSON.parse(fs.readFileSync(fullPath, 'utf8')); + + if (!content.peerDependencies) { + return null; + } + + const updates = []; + + // Update toolpack-sdk peer dependency + if (content.peerDependencies['toolpack-sdk'] && versions.sdk) { + const oldVersion = content.peerDependencies['toolpack-sdk']; + content.peerDependencies['toolpack-sdk'] = `^${versions.sdk}`; + updates.push({ package: 'toolpack-sdk', old: oldVersion, new: `^${versions.sdk}` }); + } + + // Update @toolpack-sdk/knowledge peer dependency + if (content.peerDependencies['@toolpack-sdk/knowledge'] && versions.knowledge) { + const oldVersion = content.peerDependencies['@toolpack-sdk/knowledge']; + content.peerDependencies['@toolpack-sdk/knowledge'] = `^${versions.knowledge}`; + updates.push({ package: '@toolpack-sdk/knowledge', old: oldVersion, new: `^${versions.knowledge}` }); + } + + if (updates.length > 0) { + fs.writeFileSync(fullPath, JSON.stringify(content, null, 2) + '\n'); + } + + return updates; +} + function updateTypeScriptFile(filePath, pattern, replacement, version) { const fullPath = path.join(rootDir, filePath); let content = fs.readFileSync(fullPath, 'utf8'); @@ -79,9 +131,16 @@ function main() { } console.log(`🔄 Updating version to ${newVersion}...\n`); + console.log('📦 Step 1: Updating package versions\n'); let updatedCount = 0; + const versions = { + sdk: newVersion, + knowledge: newVersion, + agents: newVersion, + }; + // Update all package versions for (const file of FILES_TO_UPDATE) { try { let oldVersion; @@ -97,7 +156,7 @@ function main() { oldVersion = updatePackageJson(file.path, newVersion); } - console.log(`✅ ${file.path}`); + console.log(`✅ ${file.name || file.path}`); console.log(` ${oldVersion} → ${newVersion}`); updatedCount++; } catch (error) { @@ -105,11 +164,34 @@ function main() { } } - console.log(`\n✨ Updated ${updatedCount}/${FILES_TO_UPDATE.length} files`); + console.log(`\n✨ Updated ${updatedCount}/${FILES_TO_UPDATE.length} package versions`); + + // Update peer dependencies in agents package + console.log(`\n📦 Step 2: Updating peer dependencies\n`); + + try { + const peerUpdates = updatePeerDependencies(PACKAGES.agents, versions); + + if (peerUpdates && peerUpdates.length > 0) { + console.log(`✅ @toolpack-sdk/agents peer dependencies:`); + for (const update of peerUpdates) { + console.log(` ${update.package}: ${update.old} → ${update.new}`); + } + } else { + console.log(`ℹ️ No peer dependencies to update`); + } + } catch (error) { + console.error(`❌ Failed to update peer dependencies:`, error.message); + } + + console.log(`\n✨ Version update complete!`); console.log(`\n💡 Next steps:`); console.log(` 1. Review changes: git diff`); console.log(` 2. Build packages: npm run build`); - console.log(` 3. Commit: git commit -am "chore: bump version to ${newVersion}"`); + console.log(` 3. Run tests: npm test`); + console.log(` 4. Commit: git commit -am "chore: bump version to ${newVersion}"`); + console.log(` 5. Tag: git tag v${newVersion}`); + console.log(` 6. Push: git push && git push --tags`); } main();