commit a6244cfe64ecee649457a482e9fa26ffc7426f39 Author: JackChen Date: Wed Apr 1 04:33:15 2026 +0800 Initial release: open-multi-agent v0.1.0 Production-grade multi-agent orchestration framework. Model-agnostic, supports team collaboration, task scheduling with dependency resolution, and inter-agent communication. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..64830c9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +*.tgz +.DS_Store +reddit-promotion.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..407838d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 open-multi-agent contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b837915 --- /dev/null +++ b/README.md @@ -0,0 +1,276 @@ +# Open Multi-Agent + +Open Multi-Agent is an open-source multi-agent orchestration framework. Build autonomous AI agent teams that can collaborate, communicate, schedule tasks with dependencies, and execute complex multi-step workflows — all model-agnostic. + +Unlike single-agent SDKs like `@anthropic-ai/claude-agent-sdk` which run one agent per process, Open Multi-Agent orchestrates **multiple specialized agents** working together in-process — deploy anywhere: cloud servers, serverless functions, Docker containers, CI/CD pipelines. + +[![npm version](https://img.shields.io/npm/v/open-multi-agent)](https://www.npmjs.com/package/open-multi-agent) +[![license](https://img.shields.io/npm/l/open-multi-agent)](./LICENSE) +[![TypeScript](https://img.shields.io/badge/TypeScript-5.6-blue)](https://www.typescriptlang.org/) + +## Features + +- **Multi-Agent Teams** — Create teams of specialized agents that collaborate toward a shared goal +- **Automatic Orchestration** — Describe a goal in plain English; the framework decomposes it into tasks and assigns them +- **Task Dependencies** — Define tasks with `dependsOn` chains; the `TaskQueue` resolves them topologically +- **Inter-Agent Communication** — Agents message each other via `MessageBus` and share knowledge through `SharedMemory` +- **Model Agnostic** — Works with Anthropic Claude, OpenAI GPT, or any custom `LLMAdapter` +- **Tool Framework** — Define custom tools with Zod schemas, or use 5 built-in tools (bash, file_read, file_write, file_edit, grep) +- **Parallel Execution** — Independent tasks run concurrently with configurable `maxConcurrency` +- **4 Scheduling Strategies** — Round-robin, least-busy, capability-match, dependency-first +- **Streaming** — Stream incremental text deltas from any agent via `AsyncGenerator` +- **Full Type Safety** — Strict TypeScript with Zod validation throughout + +## Quick Start + +```bash +npm install open-multi-agent +``` + +```typescript +import { OpenMultiAgent } from 'open-multi-agent' + +const orchestrator = new OpenMultiAgent({ defaultModel: 'claude-sonnet-4-6' }) + +// One agent, one task +const result = await orchestrator.runAgent( + { + name: 'coder', + model: 'claude-sonnet-4-6', + tools: ['bash', 'file_write'], + }, + 'Write a TypeScript function that reverses a string, save it to /tmp/reverse.ts, and run it.', +) + +console.log(result.output) +``` + +Set `ANTHROPIC_API_KEY` (and optionally `OPENAI_API_KEY`) in your environment before running. + +## Usage + +### Multi-Agent Team + +```typescript +import { OpenMultiAgent } from 'open-multi-agent' +import type { AgentConfig } from 'open-multi-agent' + +const architect: AgentConfig = { + name: 'architect', + model: 'claude-sonnet-4-6', + systemPrompt: 'You design clean API contracts and file structures.', + tools: ['file_write'], +} + +const developer: AgentConfig = { + name: 'developer', + model: 'claude-sonnet-4-6', + systemPrompt: 'You implement what the architect designs.', + tools: ['bash', 'file_read', 'file_write', 'file_edit'], +} + +const reviewer: AgentConfig = { + name: 'reviewer', + model: 'claude-sonnet-4-6', + systemPrompt: 'You review code for correctness and clarity.', + tools: ['file_read', 'grep'], +} + +const orchestrator = new OpenMultiAgent({ + defaultModel: 'claude-sonnet-4-6', + onProgress: (event) => console.log(event.type, event.agent ?? event.task ?? ''), +}) + +const team = orchestrator.createTeam('api-team', { + name: 'api-team', + agents: [architect, developer, reviewer], + sharedMemory: true, +}) + +// Describe a goal — the framework breaks it into tasks and orchestrates execution +const result = await orchestrator.runTeam(team, 'Create a REST API for a todo list in /tmp/todo-api/') + +console.log(`Success: ${result.success}`) +console.log(`Tokens: ${result.totalTokenUsage.output_tokens} output tokens`) +``` + +### Task Pipeline + +Use `runTasks()` when you want explicit control over the task graph and assignments: + +```typescript +const result = await orchestrator.runTasks(team, [ + { + title: 'Design the data model', + description: 'Write a TypeScript interface spec to /tmp/spec.md', + assignee: 'architect', + }, + { + title: 'Implement the module', + description: 'Read /tmp/spec.md and implement the module in /tmp/src/', + assignee: 'developer', + dependsOn: ['Design the data model'], // blocked until design completes + }, + { + title: 'Write tests', + description: 'Read the implementation and write Vitest tests.', + assignee: 'developer', + dependsOn: ['Implement the module'], + }, + { + title: 'Review code', + description: 'Review /tmp/src/ and produce a structured code review.', + assignee: 'reviewer', + dependsOn: ['Implement the module'], // can run in parallel with tests + }, +]) +``` + +### Custom Tools + +```typescript +import { z } from 'zod' +import { defineTool, Agent, ToolRegistry, ToolExecutor, registerBuiltInTools } from 'open-multi-agent' + +const searchTool = defineTool({ + name: 'web_search', + description: 'Search the web and return the top results.', + inputSchema: z.object({ + query: z.string().describe('The search query.'), + maxResults: z.number().optional().describe('Number of results (default 5).'), + }), + execute: async ({ query, maxResults = 5 }) => { + const results = await mySearchProvider(query, maxResults) + return { data: JSON.stringify(results), isError: false } + }, +}) + +const registry = new ToolRegistry() +registerBuiltInTools(registry) +registry.register(searchTool) + +const executor = new ToolExecutor(registry) +const agent = new Agent( + { name: 'researcher', model: 'claude-sonnet-4-6', tools: ['web_search'] }, + registry, + executor, +) + +const result = await agent.run('Find the three most recent TypeScript releases.') +``` + +### Multi-Model Teams + +```typescript +const claudeAgent: AgentConfig = { + name: 'strategist', + model: 'claude-opus-4-6', + provider: 'anthropic', + systemPrompt: 'You plan high-level approaches.', + tools: ['file_write'], +} + +const gptAgent: AgentConfig = { + name: 'implementer', + model: 'gpt-5.4', + provider: 'openai', + systemPrompt: 'You implement plans as working code.', + tools: ['bash', 'file_read', 'file_write'], +} + +const team = orchestrator.createTeam('mixed-team', { + name: 'mixed-team', + agents: [claudeAgent, gptAgent], + sharedMemory: true, +}) + +const result = await orchestrator.runTeam(team, 'Build a CLI tool that converts JSON to CSV.') +``` + +### Streaming Output + +```typescript +import { Agent, ToolRegistry, ToolExecutor, registerBuiltInTools } from 'open-multi-agent' + +const registry = new ToolRegistry() +registerBuiltInTools(registry) +const executor = new ToolExecutor(registry) + +const agent = new Agent( + { name: 'writer', model: 'claude-sonnet-4-6', maxTurns: 3 }, + registry, + executor, +) + +for await (const event of agent.stream('Explain monads in two sentences.')) { + if (event.type === 'text' && typeof event.data === 'string') { + process.stdout.write(event.data) + } +} +``` + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ OpenMultiAgent (Orchestrator) │ +│ │ +│ createTeam() runTeam() runTasks() runAgent() getStatus() │ +└──────────────────────┬──────────────────────────────────────────┘ + │ + ┌──────────▼──────────┐ + │ Team │ + │ - AgentConfig[] │ + │ - MessageBus │ + │ - TaskQueue │ + │ - SharedMemory │ + └──────────┬──────────┘ + │ + ┌─────────────┴─────────────┐ + │ │ +┌────────▼──────────┐ ┌───────────▼───────────┐ +│ AgentPool │ │ TaskQueue │ +│ - Semaphore │ │ - dependency graph │ +│ - runParallel() │ │ - auto unblock │ +└────────┬──────────┘ │ - cascade failure │ + │ └───────────────────────┘ +┌────────▼──────────┐ +│ Agent │ +│ - run() │ ┌──────────────────────┐ +│ - prompt() │───►│ LLMAdapter │ +│ - stream() │ │ - AnthropicAdapter │ +└────────┬──────────┘ │ - OpenAIAdapter │ + │ └──────────────────────┘ +┌────────▼──────────┐ +│ AgentRunner │ ┌──────────────────────┐ +│ - conversation │───►│ ToolRegistry │ +│ loop │ │ - defineTool() │ +│ - tool dispatch │ │ - 5 built-in tools │ +└───────────────────┘ └──────────────────────┘ +``` + +## Built-in Tools + +| Tool | Description | +|------|-------------| +| `bash` | Execute shell commands. Returns stdout + stderr. Supports timeout and cwd. | +| `file_read` | Read file contents at an absolute path. Supports offset/limit for large files. | +| `file_write` | Write or create a file. Auto-creates parent directories. | +| `file_edit` | Edit a file by replacing an exact string match. | +| `grep` | Search file contents with regex. Uses ripgrep when available, falls back to Node.js. | + +## Design Inspiration + +The architecture draws from common multi-agent orchestration patterns seen in modern AI coding tools. + +| Pattern | open-multi-agent | What it does | +|---------|-----------------|--------------| +| Conversation loop | `AgentRunner` | Drives the model → tool → model turn loop | +| Tool definition | `defineTool()` | Typed tool definition with Zod validation | +| Coordinator | `OpenMultiAgent` | Decomposes goals, assigns tasks, manages concurrency | +| Team / sub-agent | `Team` + `MessageBus` | Inter-agent communication and shared state | +| Task scheduling | `TaskQueue` | Topological task scheduling with dependency resolution | + +## License + +MIT diff --git a/examples/01-single-agent.ts b/examples/01-single-agent.ts new file mode 100644 index 0000000..b7ac765 --- /dev/null +++ b/examples/01-single-agent.ts @@ -0,0 +1,131 @@ +/** + * Example 01 — Single Agent + * + * The simplest possible usage: one agent with bash and file tools, running + * a coding task. Then shows streaming output using the Agent class directly. + * + * Run: + * npx tsx examples/01-single-agent.ts + * + * Prerequisites: + * ANTHROPIC_API_KEY env var must be set. + */ + +import { OpenMultiAgent, Agent, ToolRegistry, ToolExecutor, registerBuiltInTools } from '../src/index.js' +import type { OrchestratorEvent } from '../src/types.js' + +// --------------------------------------------------------------------------- +// Part 1: Single agent via OpenMultiAgent (simplest path) +// --------------------------------------------------------------------------- + +const orchestrator = new OpenMultiAgent({ + defaultModel: 'claude-sonnet-4-6', + onProgress: (event: OrchestratorEvent) => { + if (event.type === 'agent_start') { + console.log(`[start] agent=${event.agent}`) + } else if (event.type === 'agent_complete') { + console.log(`[complete] agent=${event.agent}`) + } + }, +}) + +console.log('Part 1: runAgent() — single one-shot task\n') + +const result = await orchestrator.runAgent( + { + name: 'coder', + model: 'claude-sonnet-4-6', + systemPrompt: `You are a focused TypeScript developer. +When asked to implement something, write clean, minimal code with no extra commentary. +Use the bash tool to run commands and the file tools to read/write files.`, + tools: ['bash', 'file_read', 'file_write'], + maxTurns: 8, + }, + `Create a small TypeScript utility function in /tmp/greet.ts that: + 1. Exports a function named greet(name: string): string + 2. Returns "Hello, !" + 3. Adds a brief usage comment at the top of the file. + Then add a default call greet("World") at the bottom and run the file with: npx tsx /tmp/greet.ts`, +) + +if (result.success) { + console.log('\nAgent output:') + console.log('─'.repeat(60)) + console.log(result.output) + console.log('─'.repeat(60)) +} else { + console.error('Agent failed:', result.output) + process.exit(1) +} + +console.log('\nToken usage:') +console.log(` input: ${result.tokenUsage.input_tokens}`) +console.log(` output: ${result.tokenUsage.output_tokens}`) +console.log(` tool calls made: ${result.toolCalls.length}`) + +// --------------------------------------------------------------------------- +// Part 2: Streaming via Agent directly +// +// OpenMultiAgent.runAgent() is a convenient wrapper. When you need streaming, use +// the Agent class directly with an injected ToolRegistry + ToolExecutor. +// --------------------------------------------------------------------------- + +console.log('\n\nPart 2: Agent.stream() — incremental text output\n') + +// Build a registry with all built-in tools registered +const registry = new ToolRegistry() +registerBuiltInTools(registry) +const executor = new ToolExecutor(registry) + +const streamingAgent = new Agent( + { + name: 'explainer', + model: 'claude-sonnet-4-6', + systemPrompt: 'You are a concise technical writer. Keep explanations brief.', + maxTurns: 3, + }, + registry, + executor, +) + +process.stdout.write('Streaming: ') + +for await (const event of streamingAgent.stream( + 'In two sentences, explain what a TypeScript generic constraint is.', +)) { + if (event.type === 'text' && typeof event.data === 'string') { + process.stdout.write(event.data) + } else if (event.type === 'done') { + process.stdout.write('\n') + } else if (event.type === 'error') { + console.error('\nStream error:', event.data) + } +} + +// --------------------------------------------------------------------------- +// Part 3: Multi-turn conversation via Agent.prompt() +// --------------------------------------------------------------------------- + +console.log('\nPart 3: Agent.prompt() — multi-turn conversation\n') + +const conversationAgent = new Agent( + { + name: 'tutor', + model: 'claude-sonnet-4-6', + systemPrompt: 'You are a TypeScript tutor. Give short, direct answers.', + maxTurns: 2, + }, + new ToolRegistry(), // no tools needed for this conversation + new ToolExecutor(new ToolRegistry()), +) + +const turn1 = await conversationAgent.prompt('What is a type guard in TypeScript?') +console.log('Turn 1:', turn1.output.slice(0, 200)) + +const turn2 = await conversationAgent.prompt('Give me one concrete code example of what you just described.') +console.log('\nTurn 2:', turn2.output.slice(0, 300)) + +// History is retained between prompt() calls +console.log(`\nConversation history length: ${conversationAgent.getHistory().length} messages`) + +console.log('\nDone.') diff --git a/examples/02-team-collaboration.ts b/examples/02-team-collaboration.ts new file mode 100644 index 0000000..7c2ab1b --- /dev/null +++ b/examples/02-team-collaboration.ts @@ -0,0 +1,167 @@ +/** + * Example 02 — Multi-Agent Team Collaboration + * + * Three specialised agents (architect, developer, reviewer) collaborate on a + * shared goal. The OpenMultiAgent orchestrator breaks the goal into tasks, assigns + * them to the right agents, and collects the results. + * + * Run: + * npx tsx examples/02-team-collaboration.ts + * + * Prerequisites: + * ANTHROPIC_API_KEY env var must be set. + */ + +import { OpenMultiAgent } from '../src/index.js' +import type { AgentConfig, OrchestratorEvent } from '../src/types.js' + +// --------------------------------------------------------------------------- +// Agent definitions +// --------------------------------------------------------------------------- + +const architect: AgentConfig = { + name: 'architect', + model: 'claude-sonnet-4-6', + provider: 'anthropic', + systemPrompt: `You are a software architect with deep experience in Node.js and REST API design. +Your job is to design clear, production-quality API contracts and file/directory structures. +Output concise plans in markdown — no unnecessary prose.`, + tools: ['bash', 'file_write'], + maxTurns: 5, + temperature: 0.2, +} + +const developer: AgentConfig = { + name: 'developer', + model: 'claude-sonnet-4-6', + provider: 'anthropic', + systemPrompt: `You are a TypeScript/Node.js developer. You implement what the architect specifies. +Write clean, runnable code with proper error handling. Use the tools to write files and run tests.`, + tools: ['bash', 'file_read', 'file_write', 'file_edit'], + maxTurns: 12, + temperature: 0.1, +} + +const reviewer: AgentConfig = { + name: 'reviewer', + model: 'claude-sonnet-4-6', + provider: 'anthropic', + systemPrompt: `You are a senior code reviewer. Review code for correctness, security, and clarity. +Provide a structured review with: LGTM items, suggestions, and any blocking issues. +Read files using the tools before reviewing.`, + tools: ['bash', 'file_read', 'grep'], + maxTurns: 5, + temperature: 0.3, +} + +// --------------------------------------------------------------------------- +// Progress tracking +// --------------------------------------------------------------------------- + +const startTimes = new Map() + +function handleProgress(event: OrchestratorEvent): void { + const ts = new Date().toISOString().slice(11, 23) // HH:MM:SS.mmm + + switch (event.type) { + case 'agent_start': + startTimes.set(event.agent ?? '', Date.now()) + console.log(`[${ts}] AGENT START → ${event.agent}`) + break + + case 'agent_complete': { + const elapsed = Date.now() - (startTimes.get(event.agent ?? '') ?? Date.now()) + console.log(`[${ts}] AGENT DONE ← ${event.agent} (${elapsed}ms)`) + break + } + + case 'task_start': + console.log(`[${ts}] TASK START ↓ ${event.task}`) + break + + case 'task_complete': + console.log(`[${ts}] TASK DONE ↑ ${event.task}`) + break + + case 'message': + console.log(`[${ts}] MESSAGE • ${event.agent} → (team)`) + break + + case 'error': + console.error(`[${ts}] ERROR ✗ agent=${event.agent} task=${event.task}`) + if (event.data instanceof Error) { + console.error(` ${event.data.message}`) + } + break + } +} + +// --------------------------------------------------------------------------- +// Orchestrate +// --------------------------------------------------------------------------- + +const orchestrator = new OpenMultiAgent({ + defaultModel: 'claude-sonnet-4-6', + maxConcurrency: 1, // run agents sequentially so output is readable + onProgress: handleProgress, +}) + +const team = orchestrator.createTeam('api-team', { + name: 'api-team', + agents: [architect, developer, reviewer], + sharedMemory: true, + maxConcurrency: 1, +}) + +console.log(`Team "${team.name}" created with agents: ${team.getAgents().map(a => a.name).join(', ')}`) +console.log('\nStarting team run...\n') +console.log('='.repeat(60)) + +const goal = `Create a minimal Express.js REST API in /tmp/express-api/ with: +- GET /health → { status: "ok" } +- GET /users → returns a hardcoded array of 2 user objects +- POST /users → accepts { name, email } body, logs it, returns 201 +- Proper error handling middleware +- The server should listen on port 3001 +- Include a package.json with the required dependencies` + +const result = await orchestrator.runTeam(team, goal) + +console.log('\n' + '='.repeat(60)) + +// --------------------------------------------------------------------------- +// Results +// --------------------------------------------------------------------------- + +console.log('\nTeam run complete.') +console.log(`Success: ${result.success}`) +console.log(`Total tokens — input: ${result.totalTokenUsage.input_tokens}, output: ${result.totalTokenUsage.output_tokens}`) + +console.log('\nPer-agent results:') +for (const [agentName, agentResult] of result.agentResults) { + const status = agentResult.success ? 'OK' : 'FAILED' + const tools = agentResult.toolCalls.length + console.log(` ${agentName.padEnd(12)} [${status}] tool_calls=${tools}`) + if (!agentResult.success) { + console.log(` Error: ${agentResult.output.slice(0, 120)}`) + } +} + +// Print the developer's final output (the actual code) as a sample +const developerResult = result.agentResults.get('developer') +if (developerResult?.success) { + console.log('\nDeveloper output (last 600 chars):') + console.log('─'.repeat(60)) + const out = developerResult.output + console.log(out.length > 600 ? '...' + out.slice(-600) : out) + console.log('─'.repeat(60)) +} + +// Print the reviewer's findings +const reviewerResult = result.agentResults.get('reviewer') +if (reviewerResult?.success) { + console.log('\nReviewer output:') + console.log('─'.repeat(60)) + console.log(reviewerResult.output) + console.log('─'.repeat(60)) +} diff --git a/examples/03-task-pipeline.ts b/examples/03-task-pipeline.ts new file mode 100644 index 0000000..840848d --- /dev/null +++ b/examples/03-task-pipeline.ts @@ -0,0 +1,201 @@ +/** + * Example 03 — Explicit Task Pipeline with Dependencies + * + * Demonstrates how to define tasks with explicit dependency chains + * (design → implement → test → review) using runTasks(). The TaskQueue + * automatically blocks downstream tasks until their dependencies complete. + * + * Run: + * npx tsx examples/03-task-pipeline.ts + * + * Prerequisites: + * ANTHROPIC_API_KEY env var must be set. + */ + +import { OpenMultiAgent } from '../src/index.js' +import type { AgentConfig, OrchestratorEvent, Task } from '../src/types.js' + +// --------------------------------------------------------------------------- +// Agents +// --------------------------------------------------------------------------- + +const designer: AgentConfig = { + name: 'designer', + model: 'claude-sonnet-4-6', + systemPrompt: `You are a software designer. Your output is always a concise technical spec +in markdown. Focus on interfaces, data shapes, and file structure. Be brief.`, + tools: ['file_write'], + maxTurns: 4, +} + +const implementer: AgentConfig = { + name: 'implementer', + model: 'claude-sonnet-4-6', + systemPrompt: `You are a TypeScript developer. Read the design spec written by the designer, +then implement it. Write all files to /tmp/pipeline-output/. Use the tools.`, + tools: ['bash', 'file_read', 'file_write'], + maxTurns: 10, +} + +const tester: AgentConfig = { + name: 'tester', + model: 'claude-sonnet-4-6', + systemPrompt: `You are a QA engineer. Read the implemented files and run them to verify correctness. +Report: what passed, what failed, and any bugs found.`, + tools: ['bash', 'file_read', 'grep'], + maxTurns: 6, +} + +const reviewer: AgentConfig = { + name: 'reviewer', + model: 'claude-sonnet-4-6', + systemPrompt: `You are a code reviewer. Read all files and produce a brief structured review. +Sections: Summary, Strengths, Issues (if any), Verdict (SHIP / NEEDS WORK).`, + tools: ['file_read', 'grep'], + maxTurns: 4, +} + +// --------------------------------------------------------------------------- +// Progress handler — shows dependency blocking/unblocking +// --------------------------------------------------------------------------- + +const taskTimes = new Map() + +function handleProgress(event: OrchestratorEvent): void { + const ts = new Date().toISOString().slice(11, 23) + + switch (event.type) { + case 'task_start': { + taskTimes.set(event.task ?? '', Date.now()) + const task = event.data as Task | undefined + console.log(`[${ts}] TASK READY "${task?.title ?? event.task}" (assignee: ${task?.assignee ?? 'any'})`) + break + } + case 'task_complete': { + const elapsed = Date.now() - (taskTimes.get(event.task ?? '') ?? Date.now()) + const task = event.data as Task | undefined + console.log(`[${ts}] TASK DONE "${task?.title ?? event.task}" in ${elapsed}ms`) + break + } + case 'agent_start': + console.log(`[${ts}] AGENT START ${event.agent}`) + break + case 'agent_complete': + console.log(`[${ts}] AGENT DONE ${event.agent}`) + break + case 'error': { + const task = event.data as Task | undefined + console.error(`[${ts}] ERROR ${event.agent ?? ''} task="${task?.title ?? event.task}"`) + break + } + } +} + +// --------------------------------------------------------------------------- +// Build the pipeline +// --------------------------------------------------------------------------- + +const orchestrator = new OpenMultiAgent({ + defaultModel: 'claude-sonnet-4-6', + maxConcurrency: 2, // allow test + review to potentially run in parallel later + onProgress: handleProgress, +}) + +const team = orchestrator.createTeam('pipeline-team', { + name: 'pipeline-team', + agents: [designer, implementer, tester, reviewer], + sharedMemory: true, +}) + +// Task IDs — use stable strings so dependsOn can reference them +// (IDs will be generated by the framework; we capture the returned Task objects) +const SPEC_FILE = '/tmp/pipeline-output/design-spec.md' + +const tasks: Array<{ + title: string + description: string + assignee?: string + dependsOn?: string[] +}> = [ + { + title: 'Design: URL shortener data model', + description: `Design a minimal in-memory URL shortener service. +Write a markdown spec to ${SPEC_FILE} covering: +- TypeScript interfaces for Url and ShortenRequest +- The shortening algorithm (hash approach is fine) +- API contract: POST /shorten, GET /:code +Keep the spec under 30 lines.`, + assignee: 'designer', + // no dependencies — this is the root task + }, + { + title: 'Implement: URL shortener', + description: `Read the design spec at ${SPEC_FILE}. +Implement the URL shortener in /tmp/pipeline-output/src/: +- shortener.ts: core logic (shorten, resolve functions) +- server.ts: tiny HTTP server using Node's built-in http module (no Express) + - POST /shorten body: { url: string } → { code: string, short: string } + - GET /:code → redirect (301) or 404 +- index.ts: entry point that starts the server on port 3002 +No external dependencies beyond Node built-ins.`, + assignee: 'implementer', + dependsOn: ['Design: URL shortener data model'], + }, + { + title: 'Test: URL shortener', + description: `Run the URL shortener implementation: +1. Start the server: node /tmp/pipeline-output/src/index.ts (or tsx) +2. POST a URL to shorten it using curl +3. Verify the GET redirect works +4. Report what passed and what (if anything) failed. +Kill the server after testing.`, + assignee: 'tester', + dependsOn: ['Implement: URL shortener'], + }, + { + title: 'Review: URL shortener', + description: `Read all .ts files in /tmp/pipeline-output/src/ and the design spec. +Produce a structured code review with sections: +- Summary (2 sentences) +- Strengths (bullet list) +- Issues (bullet list, or "None" if clean) +- Verdict: SHIP or NEEDS WORK`, + assignee: 'reviewer', + dependsOn: ['Implement: URL shortener'], // runs in parallel with Test after Implement completes + }, +] + +// --------------------------------------------------------------------------- +// Run +// --------------------------------------------------------------------------- + +console.log('Starting 4-stage task pipeline...\n') +console.log('Pipeline: design → implement → test + review (parallel)') +console.log('='.repeat(60)) + +const result = await orchestrator.runTasks(team, tasks) + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +console.log('\n' + '='.repeat(60)) +console.log('Pipeline complete.\n') +console.log(`Overall success: ${result.success}`) +console.log(`Tokens — input: ${result.totalTokenUsage.input_tokens}, output: ${result.totalTokenUsage.output_tokens}`) + +console.log('\nPer-agent summary:') +for (const [name, r] of result.agentResults) { + const icon = r.success ? 'OK ' : 'FAIL' + const toolCount = r.toolCalls.map(c => c.toolName).join(', ') + console.log(` [${icon}] ${name.padEnd(14)} tools used: ${toolCount || '(none)'}`) +} + +// Print the reviewer's verdict +const review = result.agentResults.get('reviewer') +if (review?.success) { + console.log('\nCode review:') + console.log('─'.repeat(60)) + console.log(review.output) + console.log('─'.repeat(60)) +} diff --git a/examples/04-multi-model-team.ts b/examples/04-multi-model-team.ts new file mode 100644 index 0000000..f3b8492 --- /dev/null +++ b/examples/04-multi-model-team.ts @@ -0,0 +1,261 @@ +/** + * Example 04 — Multi-Model Team with Custom Tools + * + * Demonstrates: + * - Mixing Anthropic and OpenAI models in the same team + * - Defining custom tools with defineTool() and Zod schemas + * - Building agents with a custom ToolRegistry so they can use custom tools + * - Running a team goal that uses the custom tools + * + * Run: + * npx tsx examples/04-multi-model-team.ts + * + * Prerequisites: + * ANTHROPIC_API_KEY and OPENAI_API_KEY env vars must be set. + * (If you only have one key, set useOpenAI = false below.) + */ + +import { z } from 'zod' +import { OpenMultiAgent, defineTool } from '../src/index.js' +import type { AgentConfig, OrchestratorEvent } from '../src/types.js' + +// --------------------------------------------------------------------------- +// Custom tools — defined with defineTool() + Zod schemas +// --------------------------------------------------------------------------- + +/** + * A custom tool that fetches live exchange rates from a public API. + */ +const exchangeRateTool = defineTool({ + name: 'get_exchange_rate', + description: + 'Get the current exchange rate between two currencies. ' + + 'Returns the rate as a decimal: 1 unit of `from` = N units of `to`.', + inputSchema: z.object({ + from: z.string().describe('ISO 4217 currency code, e.g. "USD"'), + to: z.string().describe('ISO 4217 currency code, e.g. "EUR"'), + }), + execute: async ({ from, to }) => { + try { + const url = `https://api.exchangerate.host/convert?from=${from}&to=${to}&amount=1` + const resp = await fetch(url, { signal: AbortSignal.timeout(5000) }) + + if (!resp.ok) throw new Error(`HTTP ${resp.status}`) + + interface ExchangeRateResponse { + result?: number + info?: { rate?: number } + } + const json = (await resp.json()) as ExchangeRateResponse + const rate: number | undefined = json?.result ?? json?.info?.rate + + if (typeof rate !== 'number') throw new Error('Unexpected API response shape') + + return { + data: JSON.stringify({ from, to, rate, timestamp: new Date().toISOString() }), + isError: false, + } + } catch (err) { + // Graceful degradation — return a stubbed rate so the team can still proceed + const stub = parseFloat((Math.random() * 0.5 + 0.8).toFixed(4)) + return { + data: JSON.stringify({ + from, + to, + rate: stub, + note: `Live fetch failed (${err instanceof Error ? err.message : String(err)}). Using stub rate.`, + }), + isError: false, + } + } + }, +}) + +/** + * A custom tool that formats a number as a localised currency string. + */ +const formatCurrencyTool = defineTool({ + name: 'format_currency', + description: 'Format a number as a localised currency string.', + inputSchema: z.object({ + amount: z.number().describe('The numeric amount to format.'), + currency: z.string().describe('ISO 4217 currency code, e.g. "USD".'), + locale: z + .string() + .optional() + .describe('BCP 47 locale string, e.g. "en-US". Defaults to "en-US".'), + }), + execute: async ({ amount, currency, locale = 'en-US' }) => { + try { + const formatted = new Intl.NumberFormat(locale, { + style: 'currency', + currency, + }).format(amount) + return { data: formatted, isError: false } + } catch { + return { data: `${amount} ${currency}`, isError: true } + } + }, +}) + +// --------------------------------------------------------------------------- +// Helper: build an AgentConfig whose tools list includes custom tool names. +// +// Agents reference tools by name in their AgentConfig.tools array. +// The ToolRegistry is injected via the Agent constructor. When using OpenMultiAgent +// convenience methods (runTeam, runTasks, runAgent), the orchestrator builds +// agents internally using buildAgent(), which registers only the five built-in +// tools. For custom tools, use AgentPool + Agent directly (see the note in the +// README) or provide the custom tool names in the tools array and rely on a +// registry you inject yourself. +// +// In this example we demonstrate the custom-tool pattern by running the agents +// directly through AgentPool rather than through the OpenMultiAgent high-level API. +// --------------------------------------------------------------------------- + +import { Agent, AgentPool, ToolRegistry, ToolExecutor, registerBuiltInTools } from '../src/index.js' + +/** + * Build an Agent with both built-in and custom tools registered. + */ +function buildCustomAgent( + config: AgentConfig, + extraTools: ReturnType[], +): Agent { + const registry = new ToolRegistry() + registerBuiltInTools(registry) + for (const tool of extraTools) { + registry.register(tool) + } + const executor = new ToolExecutor(registry) + return new Agent(config, registry, executor) +} + +// --------------------------------------------------------------------------- +// Agent definitions — mixed providers +// --------------------------------------------------------------------------- + +const useOpenAI = Boolean(process.env.OPENAI_API_KEY) + +const researcherConfig: AgentConfig = { + name: 'researcher', + model: 'claude-sonnet-4-6', + provider: 'anthropic', + systemPrompt: `You are a financial data researcher. +Use the get_exchange_rate tool to fetch current rates between the currency pairs you are given. +Return the raw rates as a JSON object keyed by pair, e.g. { "USD/EUR": 0.91, "USD/GBP": 0.79 }.`, + tools: ['get_exchange_rate'], + maxTurns: 6, + temperature: 0, +} + +const analystConfig: AgentConfig = { + name: 'analyst', + model: useOpenAI ? 'gpt-5.4' : 'claude-sonnet-4-6', + provider: useOpenAI ? 'openai' : 'anthropic', + systemPrompt: `You are a foreign exchange analyst. +You receive exchange rate data and produce a short briefing. +Use format_currency to show example conversions. +Keep the briefing under 200 words.`, + tools: ['format_currency'], + maxTurns: 4, + temperature: 0.3, +} + +// --------------------------------------------------------------------------- +// Build agents with custom tools +// --------------------------------------------------------------------------- + +const researcher = buildCustomAgent(researcherConfig, [exchangeRateTool]) +const analyst = buildCustomAgent(analystConfig, [formatCurrencyTool]) + +// --------------------------------------------------------------------------- +// Run with AgentPool for concurrency control +// --------------------------------------------------------------------------- + +console.log('Multi-model team with custom tools') +console.log(`Providers: researcher=anthropic, analyst=${useOpenAI ? 'openai (gpt-5.4)' : 'anthropic (fallback)'}`) +console.log('Custom tools:', [exchangeRateTool.name, formatCurrencyTool.name].join(', ')) +console.log() + +const pool = new AgentPool(1) // sequential for readability +pool.add(researcher) +pool.add(analyst) + +// Step 1: researcher fetches the rates +console.log('[1/2] Researcher fetching FX rates...') +const researchResult = await pool.run( + 'researcher', + `Fetch exchange rates for these pairs using the get_exchange_rate tool: +- USD to EUR +- USD to GBP +- USD to JPY +- EUR to GBP + +Return the results as a JSON object: { "USD/EUR": , "USD/GBP": , ... }`, +) + +if (!researchResult.success) { + console.error('Researcher failed:', researchResult.output) + process.exit(1) +} + +console.log('Researcher done. Tool calls made:', researchResult.toolCalls.map(c => c.toolName).join(', ')) + +// Step 2: analyst writes the briefing, receiving the researcher output as context +console.log('\n[2/2] Analyst writing FX briefing...') +const analystResult = await pool.run( + 'analyst', + `Here are the current FX rates gathered by the research team: + +${researchResult.output} + +Using format_currency, show what $1,000 USD and €1,000 EUR convert to in each of the other currencies. +Then write a short FX market briefing (under 200 words) covering: +- Each rate with a brief observation +- The strongest and weakest currency in the set +- One-sentence market comment`, +) + +if (!analystResult.success) { + console.error('Analyst failed:', analystResult.output) + process.exit(1) +} + +console.log('Analyst done. Tool calls made:', analystResult.toolCalls.map(c => c.toolName).join(', ')) + +// --------------------------------------------------------------------------- +// Results +// --------------------------------------------------------------------------- + +console.log('\n' + '='.repeat(60)) + +console.log('\nResearcher output:') +console.log(researchResult.output.slice(0, 400)) + +console.log('\nAnalyst briefing:') +console.log('─'.repeat(60)) +console.log(analystResult.output) +console.log('─'.repeat(60)) + +const totalInput = researchResult.tokenUsage.input_tokens + analystResult.tokenUsage.input_tokens +const totalOutput = researchResult.tokenUsage.output_tokens + analystResult.tokenUsage.output_tokens +console.log(`\nTotal tokens — input: ${totalInput}, output: ${totalOutput}`) + +// --------------------------------------------------------------------------- +// Bonus: show how defineTool() works in isolation (no LLM needed) +// --------------------------------------------------------------------------- + +console.log('\n--- Bonus: testing custom tools in isolation ---\n') + +const fmtResult = await formatCurrencyTool.execute( + { amount: 1234.56, currency: 'EUR', locale: 'de-DE' }, + { agent: { name: 'test', role: 'test', model: 'test' } }, +) +console.log(`format_currency(1234.56, EUR, de-DE) = ${fmtResult.data}`) + +const rateResult = await exchangeRateTool.execute( + { from: 'USD', to: 'EUR' }, + { agent: { name: 'test', role: 'test', model: 'test' } }, +) +console.log(`get_exchange_rate(USD→EUR) = ${rateResult.data}`) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..96f1dec --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1907 @@ +{ + "name": "maestro-agents", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "maestro-agents", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "^0.52.0", + "openai": "^4.73.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0", + "vitest": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.52.0", + "resolved": "https://registry.npmmirror.com/@anthropic-ai/sdk/-/sdk-0.52.0.tgz", + "integrity": "sha512-d4c+fg+xy9e46c8+YnrrgIQR45CZlAi7PwdzIfDXDM6ACxEZli1/fxhURsq30ZpMZy6LvSkr41jGq5aF5TD7rQ==", + "license": "MIT", + "bin": { + "anthropic-ai-sdk": "bin/cli" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmmirror.com/@types/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.4" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/agentkeepalive": { + "version": "4.6.0", + "resolved": "https://registry.npmmirror.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz", + "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", + "license": "MIT", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "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/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "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.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "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/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmmirror.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", + "license": "MIT" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmmirror.com/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "license": "MIT", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "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.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "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.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.0.0" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/openai": { + "version": "4.104.0", + "resolved": "https://registry.npmmirror.com/openai/-/openai-4.104.0.tgz", + "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7" + }, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/openai/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8e86ec4 --- /dev/null +++ b/package.json @@ -0,0 +1,49 @@ +{ + "name": "open-multi-agent", + "version": "0.1.0", + "description": "Production-grade multi-agent orchestration framework. Model-agnostic, supports team collaboration, task scheduling, and inter-agent communication.", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "test": "vitest run", + "test:watch": "vitest", + "lint": "tsc --noEmit", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "ai", + "agent", + "multi-agent", + "orchestration", + "llm", + "claude", + "openai", + "mcp", + "tool-use", + "agent-framework" + ], + "author": "", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.52.0", + "openai": "^4.73.0", + "zod": "^3.23.0" + }, + "devDependencies": { + "typescript": "^5.6.0", + "vitest": "^2.1.0", + "@types/node": "^22.0.0" + } +} diff --git a/src/agent/agent.ts b/src/agent/agent.ts new file mode 100644 index 0000000..1dc530d --- /dev/null +++ b/src/agent/agent.ts @@ -0,0 +1,364 @@ +/** + * @fileoverview High-level Agent class for open-multi-agent. + * + * {@link Agent} is the primary interface most consumers interact with. + * It wraps {@link AgentRunner} with: + * - Persistent conversation history (`prompt()`) + * - Fresh-conversation semantics (`run()`) + * - Streaming support (`stream()`) + * - Dynamic tool registration at runtime + * - Full lifecycle state tracking (`idle → running → completed | error`) + * + * @example + * ```ts + * const agent = new Agent({ + * name: 'researcher', + * model: 'claude-opus-4-6', + * systemPrompt: 'You are a rigorous research assistant.', + * tools: ['web_search', 'read_file'], + * }) + * + * const result = await agent.run('Summarise the last 3 IPCC reports.') + * console.log(result.output) + * ``` + */ + +import type { + AgentConfig, + AgentState, + AgentRunResult, + LLMMessage, + StreamEvent, + TokenUsage, + ToolUseContext, +} from '../types.js' +import type { ToolDefinition as FrameworkToolDefinition, ToolRegistry } from '../tool/framework.js' +import type { ToolExecutor } from '../tool/executor.js' +import { createAdapter } from '../llm/adapter.js' +import { AgentRunner, type RunnerOptions, type RunOptions } from './runner.js' + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +const ZERO_USAGE: TokenUsage = { input_tokens: 0, output_tokens: 0 } + +function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage { + return { + input_tokens: a.input_tokens + b.input_tokens, + output_tokens: a.output_tokens + b.output_tokens, + } +} + +// --------------------------------------------------------------------------- +// Agent +// --------------------------------------------------------------------------- + +/** + * High-level wrapper around {@link AgentRunner} that manages conversation + * history, state transitions, and tool lifecycle. + */ +export class Agent { + readonly name: string + readonly config: AgentConfig + + private runner: AgentRunner | null = null + private state: AgentState + private readonly _toolRegistry: ToolRegistry + private readonly _toolExecutor: ToolExecutor + private messageHistory: LLMMessage[] = [] + + /** + * @param config - Static configuration for this agent. + * @param toolRegistry - Registry used to resolve and manage tools. + * @param toolExecutor - Executor that dispatches tool calls. + * + * `toolRegistry` and `toolExecutor` are injected rather than instantiated + * internally so that teams of agents can share a single registry. + */ + constructor( + config: AgentConfig, + toolRegistry: ToolRegistry, + toolExecutor: ToolExecutor, + ) { + this.name = config.name + this.config = config + this._toolRegistry = toolRegistry + this._toolExecutor = toolExecutor + + this.state = { + status: 'idle', + messages: [], + tokenUsage: ZERO_USAGE, + } + } + + // ------------------------------------------------------------------------- + // Initialisation (async, called lazily) + // ------------------------------------------------------------------------- + + /** + * Lazily create the {@link AgentRunner}. + * + * The adapter is created asynchronously (it may lazy-import provider SDKs), + * so we defer construction until the first `run` / `prompt` / `stream` call. + */ + private async getRunner(): Promise { + if (this.runner !== null) { + return this.runner + } + + const provider = this.config.provider ?? 'anthropic' + const adapter = await createAdapter(provider) + + const runnerOptions: RunnerOptions = { + model: this.config.model, + systemPrompt: this.config.systemPrompt, + maxTurns: this.config.maxTurns, + maxTokens: this.config.maxTokens, + temperature: this.config.temperature, + allowedTools: this.config.tools, + agentName: this.name, + agentRole: this.config.systemPrompt?.slice(0, 50) ?? 'assistant', + } + + this.runner = new AgentRunner( + adapter, + this._toolRegistry, + this._toolExecutor, + runnerOptions, + ) + + return this.runner + } + + // ------------------------------------------------------------------------- + // Primary execution methods + // ------------------------------------------------------------------------- + + /** + * Run `prompt` in a fresh conversation (history is NOT used). + * + * Equivalent to constructing a brand-new messages array `[{ role:'user', … }]` + * and calling the runner once. The agent's persistent history is not modified. + * + * Use this for one-shot queries where past context is irrelevant. + */ + async run(prompt: string): Promise { + const messages: LLMMessage[] = [ + { role: 'user', content: [{ type: 'text', text: prompt }] }, + ] + + return this.executeRun(messages) + } + + /** + * Run `prompt` as part of the ongoing conversation. + * + * Appends the user message to the persistent history, runs the agent, then + * appends the resulting messages to the history for the next call. + * + * Use this for multi-turn interactions. + */ + async prompt(message: string): Promise { + const userMessage: LLMMessage = { + role: 'user', + content: [{ type: 'text', text: message }], + } + + this.messageHistory.push(userMessage) + + const result = await this.executeRun([...this.messageHistory]) + + // Persist the new messages into history so the next `prompt` sees them. + for (const msg of result.messages) { + this.messageHistory.push(msg) + } + + return result + } + + /** + * Stream a fresh-conversation response, yielding {@link StreamEvent}s. + * + * Like {@link run}, this does not use or update the persistent history. + */ + async *stream(prompt: string): AsyncGenerator { + const messages: LLMMessage[] = [ + { role: 'user', content: [{ type: 'text', text: prompt }] }, + ] + + yield* this.executeStream(messages) + } + + // ------------------------------------------------------------------------- + // State management + // ------------------------------------------------------------------------- + + /** Return a snapshot of the current agent state (does not clone nested objects). */ + getState(): AgentState { + return { ...this.state, messages: [...this.state.messages] } + } + + /** Return a copy of the persistent message history. */ + getHistory(): LLMMessage[] { + return [...this.messageHistory] + } + + /** + * Clear the persistent conversation history and reset state to `idle`. + * Does NOT discard the runner instance — the adapter connection is reused. + */ + reset(): void { + this.messageHistory = [] + this.state = { + status: 'idle', + messages: [], + tokenUsage: ZERO_USAGE, + } + } + + // ------------------------------------------------------------------------- + // Dynamic tool management + // ------------------------------------------------------------------------- + + /** + * Register a new tool with this agent's tool registry at runtime. + * + * The tool becomes available to the next LLM call — no restart required. + */ + addTool(tool: FrameworkToolDefinition): void { + this._toolRegistry.register(tool) + } + + /** + * Deregister a tool by name. + * If the tool is not registered this is a no-op (no error is thrown). + */ + removeTool(name: string): void { + this._toolRegistry.deregister(name) + } + + /** Return the names of all currently registered tools. */ + getTools(): string[] { + return this._toolRegistry.list().map((t) => t.name) + } + + // ------------------------------------------------------------------------- + // Private execution core + // ------------------------------------------------------------------------- + + /** + * Shared execution path used by both `run` and `prompt`. + * Handles state transitions and error wrapping. + */ + private async executeRun(messages: LLMMessage[]): Promise { + this.transitionTo('running') + + try { + const runner = await this.getRunner() + const runOptions: RunOptions = { + onMessage: msg => { + this.state.messages.push(msg) + }, + } + + const result = await runner.run(messages, runOptions) + + this.state.tokenUsage = addUsage(this.state.tokenUsage, result.tokenUsage) + this.transitionTo('completed') + + return this.toAgentRunResult(result, true) + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + this.transitionToError(error) + + return { + success: false, + output: error.message, + messages: [], + tokenUsage: ZERO_USAGE, + toolCalls: [], + } + } + } + + /** + * Shared streaming path used by `stream`. + * Handles state transitions and error wrapping. + */ + private async *executeStream(messages: LLMMessage[]): AsyncGenerator { + this.transitionTo('running') + + try { + const runner = await this.getRunner() + + for await (const event of runner.stream(messages)) { + if (event.type === 'done') { + const result = event.data as import('./runner.js').RunResult + this.state.tokenUsage = addUsage(this.state.tokenUsage, result.tokenUsage) + this.transitionTo('completed') + } else if (event.type === 'error') { + const error = event.data instanceof Error + ? event.data + : new Error(String(event.data)) + this.transitionToError(error) + } + + yield event + } + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + this.transitionToError(error) + yield { type: 'error', data: error } satisfies StreamEvent + } + } + + // ------------------------------------------------------------------------- + // State transition helpers + // ------------------------------------------------------------------------- + + private transitionTo(status: 'idle' | 'running' | 'completed' | 'error'): void { + this.state = { ...this.state, status } + } + + private transitionToError(error: Error): void { + this.state = { ...this.state, status: 'error', error } + } + + // ------------------------------------------------------------------------- + // Result mapping + // ------------------------------------------------------------------------- + + private toAgentRunResult( + result: import('./runner.js').RunResult, + success: boolean, + ): AgentRunResult { + return { + success, + output: result.output, + messages: result.messages, + tokenUsage: result.tokenUsage, + toolCalls: result.toolCalls, + } + } + + // ------------------------------------------------------------------------- + // ToolUseContext builder (for direct use by subclasses or advanced callers) + // ------------------------------------------------------------------------- + + /** + * Build a {@link ToolUseContext} that identifies this agent. + * Exposed so team orchestrators can inject richer context (e.g. `TeamInfo`). + */ + buildToolContext(abortSignal?: AbortSignal): ToolUseContext { + return { + agent: { + name: this.name, + role: this.config.systemPrompt?.slice(0, 60) ?? 'assistant', + model: this.config.model, + }, + abortSignal, + } + } +} diff --git a/src/agent/pool.ts b/src/agent/pool.ts new file mode 100644 index 0000000..915f361 --- /dev/null +++ b/src/agent/pool.ts @@ -0,0 +1,278 @@ +/** + * @fileoverview Agent pool for managing and scheduling multiple agents. + * + * {@link AgentPool} is a registry + scheduler that: + * - Holds any number of named {@link Agent} instances + * - Enforces a concurrency cap across parallel runs via {@link Semaphore} + * - Provides `runParallel` for fan-out and `runAny` for round-robin dispatch + * - Reports aggregate pool health via `getStatus()` + * + * @example + * ```ts + * const pool = new AgentPool(3) + * pool.add(researchAgent) + * pool.add(writerAgent) + * + * const results = await pool.runParallel([ + * { agent: 'researcher', prompt: 'Find recent AI papers.' }, + * { agent: 'writer', prompt: 'Draft an intro section.' }, + * ]) + * ``` + */ + +import type { AgentRunResult } from '../types.js' +import type { Agent } from './agent.js' +import { Semaphore } from '../utils/semaphore.js' + +export { Semaphore } from '../utils/semaphore.js' + +// --------------------------------------------------------------------------- +// Pool status snapshot +// --------------------------------------------------------------------------- + +export interface PoolStatus { + /** Total number of agents registered in the pool. */ + readonly total: number + /** Agents currently in `idle` state. */ + readonly idle: number + /** Agents currently in `running` state. */ + readonly running: number + /** Agents currently in `completed` state. */ + readonly completed: number + /** Agents currently in `error` state. */ + readonly error: number +} + +// --------------------------------------------------------------------------- +// AgentPool +// --------------------------------------------------------------------------- + +/** + * Registry and scheduler for a collection of {@link Agent} instances. + * + * Thread-safety note: Node.js is single-threaded, so the semaphore approach + * is safe — no atomics or mutex primitives are needed. The semaphore gates + * concurrent async operations, not CPU threads. + */ +export class AgentPool { + private readonly agents: Map = new Map() + private readonly semaphore: Semaphore + /** Cursor used by `runAny` for round-robin dispatch. */ + private roundRobinIndex = 0 + + /** + * @param maxConcurrency - Maximum number of agent runs allowed at the same + * time across the whole pool. Defaults to `5`. + */ + constructor(private readonly maxConcurrency: number = 5) { + this.semaphore = new Semaphore(maxConcurrency) + } + + // ------------------------------------------------------------------------- + // Registry operations + // ------------------------------------------------------------------------- + + /** + * Register an agent with the pool. + * + * @throws {Error} If an agent with the same name is already registered. + */ + add(agent: Agent): void { + if (this.agents.has(agent.name)) { + throw new Error( + `AgentPool: agent '${agent.name}' is already registered. ` + + `Call remove('${agent.name}') before re-adding.`, + ) + } + this.agents.set(agent.name, agent) + } + + /** + * Unregister an agent by name. + * + * @throws {Error} If the agent is not found. + */ + remove(name: string): void { + if (!this.agents.has(name)) { + throw new Error(`AgentPool: agent '${name}' is not registered.`) + } + this.agents.delete(name) + } + + /** + * Retrieve a registered agent by name, or `undefined` if not found. + */ + get(name: string): Agent | undefined { + return this.agents.get(name) + } + + /** + * Return all registered agents in insertion order. + */ + list(): Agent[] { + return Array.from(this.agents.values()) + } + + // ------------------------------------------------------------------------- + // Execution API + // ------------------------------------------------------------------------- + + /** + * Run a single prompt on the named agent, respecting the pool concurrency + * limit. + * + * @throws {Error} If the agent name is not found. + */ + async run(agentName: string, prompt: string): Promise { + const agent = this.requireAgent(agentName) + + await this.semaphore.acquire() + try { + return await agent.run(prompt) + } finally { + this.semaphore.release() + } + } + + /** + * Run prompts on multiple agents in parallel, subject to the concurrency + * cap set at construction time. + * + * Results are returned as a `Map`. If two tasks + * target the same agent name, the map will only contain the last result. + * Use unique agent names or run tasks sequentially in that case. + * + * @param tasks - Array of `{ agent, prompt }` descriptors. + */ + async runParallel( + tasks: ReadonlyArray<{ readonly agent: string; readonly prompt: string }>, + ): Promise> { + const resultMap = new Map() + + const settledResults = await Promise.allSettled( + tasks.map(async task => { + const result = await this.run(task.agent, task.prompt) + return { name: task.agent, result } + }), + ) + + for (const settled of settledResults) { + if (settled.status === 'fulfilled') { + resultMap.set(settled.value.name, settled.value.result) + } else { + // A rejected run is surfaced as an error AgentRunResult so the caller + // sees it in the map rather than needing to catch Promise.allSettled. + // We cannot know the agent name from the rejection alone — find it via + // the original task list index. + const idx = settledResults.indexOf(settled) + const agentName = tasks[idx]?.agent ?? 'unknown' + resultMap.set(agentName, this.errorResult(settled.reason)) + } + } + + return resultMap + } + + /** + * Run a prompt on the "best available" agent using round-robin selection. + * + * Agents are selected in insertion order, cycling back to the start. The + * concurrency limit is still enforced — if the selected agent is busy the + * call will queue via the semaphore. + * + * @throws {Error} If the pool is empty. + */ + async runAny(prompt: string): Promise { + const allAgents = this.list() + if (allAgents.length === 0) { + throw new Error('AgentPool: cannot call runAny on an empty pool.') + } + + // Wrap the index to keep it in bounds even if agents were removed. + this.roundRobinIndex = this.roundRobinIndex % allAgents.length + const agent = allAgents[this.roundRobinIndex]! + this.roundRobinIndex = (this.roundRobinIndex + 1) % allAgents.length + + await this.semaphore.acquire() + try { + return await agent.run(prompt) + } finally { + this.semaphore.release() + } + } + + // ------------------------------------------------------------------------- + // Observability + // ------------------------------------------------------------------------- + + /** + * Snapshot of how many agents are in each lifecycle state. + */ + getStatus(): PoolStatus { + let idle = 0 + let running = 0 + let completed = 0 + let error = 0 + + for (const agent of this.agents.values()) { + switch (agent.getState().status) { + case 'idle': idle++; break + case 'running': running++; break + case 'completed': completed++; break + case 'error': error++; break + } + } + + return { total: this.agents.size, idle, running, completed, error } + } + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + /** + * Reset all agents in the pool. + * + * Clears their conversation histories and returns them to `idle` state. + * Does not remove agents from the pool. + * + * Async for forward compatibility — shutdown may need to perform async + * cleanup (e.g. draining in-flight requests) in future versions. + */ + async shutdown(): Promise { + for (const agent of this.agents.values()) { + agent.reset() + } + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private requireAgent(name: string): Agent { + const agent = this.agents.get(name) + if (agent === undefined) { + throw new Error( + `AgentPool: agent '${name}' is not registered. ` + + `Registered agents: [${Array.from(this.agents.keys()).join(', ')}]`, + ) + } + return agent + } + + /** + * Build a failure {@link AgentRunResult} from a caught rejection reason. + * This keeps `runParallel` returning a complete map even when individual + * agents fail. + */ + private errorResult(reason: unknown): AgentRunResult { + const message = reason instanceof Error ? reason.message : String(reason) + return { + success: false, + output: message, + messages: [], + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + toolCalls: [], + } + } +} diff --git a/src/agent/runner.ts b/src/agent/runner.ts new file mode 100644 index 0000000..13667db --- /dev/null +++ b/src/agent/runner.ts @@ -0,0 +1,413 @@ +/** + * @fileoverview Core conversation loop engine for open-multi-agent. + * + * {@link AgentRunner} is the heart of the framework. It handles: + * - Sending messages to the LLM adapter + * - Extracting tool-use blocks from the response + * - Executing tool calls in parallel via {@link ToolExecutor} + * - Appending tool results and looping back until `end_turn` + * - Accumulating token usage and timing data across all turns + * + * The loop follows a standard agentic conversation pattern: + * one outer `while (true)` that breaks on `end_turn` or maxTurns exhaustion. + */ + +import type { + LLMMessage, + ContentBlock, + TextBlock, + ToolUseBlock, + ToolResultBlock, + ToolCallRecord, + TokenUsage, + StreamEvent, + ToolResult, + ToolUseContext, + LLMAdapter, + LLMChatOptions, +} from '../types.js' +import type { ToolRegistry } from '../tool/framework.js' +import type { ToolExecutor } from '../tool/executor.js' + +// --------------------------------------------------------------------------- +// Public interfaces +// --------------------------------------------------------------------------- + +/** + * Static configuration for an {@link AgentRunner} instance. + * These values are constant across every `run` / `stream` call. + */ +export interface RunnerOptions { + /** LLM model identifier, e.g. `'claude-opus-4-6'`. */ + readonly model: string + /** Optional system prompt prepended to every conversation. */ + readonly systemPrompt?: string + /** + * Maximum number of tool-call round-trips before the runner stops. + * Prevents unbounded loops. Defaults to `10`. + */ + readonly maxTurns?: number + /** Maximum output tokens per LLM response. */ + readonly maxTokens?: number + /** Sampling temperature passed to the adapter. */ + readonly temperature?: number + /** AbortSignal that cancels any in-flight adapter call and stops the loop. */ + readonly abortSignal?: AbortSignal + /** + * Whitelist of tool names this runner is allowed to use. + * When provided, only tools whose name appears in this list are sent to the + * LLM. When omitted, all registered tools are available. + */ + readonly allowedTools?: readonly string[] + /** Display name of the agent driving this runner (used in tool context). */ + readonly agentName?: string + /** Short role description of the agent (used in tool context). */ + readonly agentRole?: string +} + +/** + * Per-call callbacks for observing tool execution in real time. + * All callbacks are optional; unused ones are simply skipped. + */ +export interface RunOptions { + /** Fired just before each tool is dispatched. */ + readonly onToolCall?: (name: string, input: Record) => void + /** Fired after each tool result is received. */ + readonly onToolResult?: (name: string, result: ToolResult) => void + /** Fired after each complete {@link LLMMessage} is appended. */ + readonly onMessage?: (message: LLMMessage) => void +} + +/** The aggregated result returned when a full run completes. */ +export interface RunResult { + /** All messages accumulated during this run (assistant + tool results). */ + readonly messages: LLMMessage[] + /** The final text output from the last assistant turn. */ + readonly output: string + /** All tool calls made during this run, in execution order. */ + readonly toolCalls: ToolCallRecord[] + /** Aggregated token counts across every LLM call in this run. */ + readonly tokenUsage: TokenUsage + /** Total number of LLM turns (including tool-call follow-ups). */ + readonly turns: number +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Extract every TextBlock from a content array and join them. */ +function extractText(content: readonly ContentBlock[]): string { + return content + .filter((b): b is TextBlock => b.type === 'text') + .map(b => b.text) + .join('') +} + +/** Extract every ToolUseBlock from a content array. */ +function extractToolUseBlocks(content: readonly ContentBlock[]): ToolUseBlock[] { + return content.filter((b): b is ToolUseBlock => b.type === 'tool_use') +} + +/** Add two {@link TokenUsage} values together, returning a new object. */ +function addTokenUsage(a: TokenUsage, b: TokenUsage): TokenUsage { + return { + input_tokens: a.input_tokens + b.input_tokens, + output_tokens: a.output_tokens + b.output_tokens, + } +} + +const ZERO_USAGE: TokenUsage = { input_tokens: 0, output_tokens: 0 } + +// --------------------------------------------------------------------------- +// AgentRunner +// --------------------------------------------------------------------------- + +/** + * Drives a full agentic conversation: LLM calls, tool execution, and looping. + * + * @example + * ```ts + * const runner = new AgentRunner(adapter, registry, executor, { + * model: 'claude-opus-4-6', + * maxTurns: 10, + * }) + * const result = await runner.run(messages) + * console.log(result.output) + * ``` + */ +export class AgentRunner { + private readonly maxTurns: number + + constructor( + private readonly adapter: LLMAdapter, + private readonly toolRegistry: ToolRegistry, + private readonly toolExecutor: ToolExecutor, + private readonly options: RunnerOptions, + ) { + this.maxTurns = options.maxTurns ?? 10 + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** + * Run a complete conversation starting from `messages`. + * + * The call may internally make multiple LLM requests (one per tool-call + * round-trip). It returns only when: + * - The LLM emits `end_turn` with no tool-use blocks, or + * - `maxTurns` is exceeded, or + * - The abort signal is triggered. + */ + async run( + messages: LLMMessage[], + options: RunOptions = {}, + ): Promise { + // Collect everything yielded by the internal streaming loop. + const accumulated: { + messages: LLMMessage[] + output: string + toolCalls: ToolCallRecord[] + tokenUsage: TokenUsage + turns: number + } = { + messages: [], + output: '', + toolCalls: [], + tokenUsage: ZERO_USAGE, + turns: 0, + } + + for await (const event of this.stream(messages, options)) { + if (event.type === 'done') { + const result = event.data as RunResult + accumulated.messages = result.messages + accumulated.output = result.output + accumulated.toolCalls = result.toolCalls + accumulated.tokenUsage = result.tokenUsage + accumulated.turns = result.turns + } + } + + return accumulated + } + + /** + * Run the conversation and yield {@link StreamEvent}s incrementally. + * + * Callers receive: + * - `{ type: 'text', data: string }` for each text delta + * - `{ type: 'tool_use', data: ToolUseBlock }` when the model requests a tool + * - `{ type: 'tool_result', data: ToolResultBlock }` after each execution + * - `{ type: 'done', data: RunResult }` at the very end + * - `{ type: 'error', data: Error }` on unrecoverable failure + */ + async *stream( + initialMessages: LLMMessage[], + options: RunOptions = {}, + ): AsyncGenerator { + // Working copy of the conversation — mutated as turns progress. + const conversationMessages: LLMMessage[] = [...initialMessages] + + // Accumulated state across all turns. + let totalUsage: TokenUsage = ZERO_USAGE + const allToolCalls: ToolCallRecord[] = [] + let finalOutput = '' + let turns = 0 + + // Build the stable LLM options once; model / tokens / temp don't change. + // toToolDefs() returns LLMToolDef[] (inputSchema, camelCase) — matches + // LLMChatOptions.tools from types.ts directly. + const allDefs = this.toolRegistry.toToolDefs() + const toolDefs = this.options.allowedTools + ? allDefs.filter(d => this.options.allowedTools!.includes(d.name)) + : allDefs + + const baseChatOptions: LLMChatOptions = { + model: this.options.model, + tools: toolDefs.length > 0 ? toolDefs : undefined, + maxTokens: this.options.maxTokens, + temperature: this.options.temperature, + systemPrompt: this.options.systemPrompt, + abortSignal: this.options.abortSignal, + } + + try { + // ----------------------------------------------------------------- + // Main agentic loop — `while (true)` until end_turn or maxTurns + // ----------------------------------------------------------------- + while (true) { + // Respect abort before each LLM call. + if (this.options.abortSignal?.aborted) { + break + } + + // Guard against unbounded loops. + if (turns >= this.maxTurns) { + break + } + + turns++ + + // ------------------------------------------------------------------ + // Step 1: Call the LLM and collect the full response for this turn. + // ------------------------------------------------------------------ + const response = await this.adapter.chat(conversationMessages, baseChatOptions) + + totalUsage = addTokenUsage(totalUsage, response.usage) + + // ------------------------------------------------------------------ + // Step 2: Build the assistant message from the response content. + // ------------------------------------------------------------------ + const assistantMessage: LLMMessage = { + role: 'assistant', + content: response.content, + } + + conversationMessages.push(assistantMessage) + options.onMessage?.(assistantMessage) + + // Yield text deltas so streaming callers can display them promptly. + const turnText = extractText(response.content) + if (turnText.length > 0) { + yield { type: 'text', data: turnText } satisfies StreamEvent + } + + // Announce each tool-use block the model requested. + const toolUseBlocks = extractToolUseBlocks(response.content) + for (const block of toolUseBlocks) { + yield { type: 'tool_use', data: block } satisfies StreamEvent + } + + // ------------------------------------------------------------------ + // Step 3: Decide whether to continue looping. + // ------------------------------------------------------------------ + if (toolUseBlocks.length === 0) { + // No tools requested — this is the terminal assistant turn. + finalOutput = turnText + break + } + + // ------------------------------------------------------------------ + // Step 4: Execute all tool calls in PARALLEL. + // + // Parallel execution is critical for multi-tool responses where the + // tools are independent (e.g. reading several files at once). + // ------------------------------------------------------------------ + const toolContext: ToolUseContext = this.buildToolContext() + + const executionPromises = toolUseBlocks.map(async (block): Promise<{ + resultBlock: ToolResultBlock + record: ToolCallRecord + }> => { + options.onToolCall?.(block.name, block.input) + + const startTime = Date.now() + let result: ToolResult + + try { + result = await this.toolExecutor.execute( + block.name, + block.input, + toolContext, + ) + } catch (err) { + // Tool executor errors become error results — the loop continues. + const message = err instanceof Error ? err.message : String(err) + result = { data: message, isError: true } + } + + const duration = Date.now() - startTime + + options.onToolResult?.(block.name, result) + + const record: ToolCallRecord = { + toolName: block.name, + input: block.input, + output: result.data, + duration, + } + + const resultBlock: ToolResultBlock = { + type: 'tool_result', + tool_use_id: block.id, + content: result.data, + is_error: result.isError, + } + + return { resultBlock, record } + }) + + // Wait for every tool in this turn to finish. + const executions = await Promise.all(executionPromises) + + // ------------------------------------------------------------------ + // Step 5: Accumulate results and build the user message that carries + // them back to the LLM in the next turn. + // ------------------------------------------------------------------ + const toolResultBlocks: ContentBlock[] = executions.map(e => e.resultBlock) + + for (const { record, resultBlock } of executions) { + allToolCalls.push(record) + yield { type: 'tool_result', data: resultBlock } satisfies StreamEvent + } + + const toolResultMessage: LLMMessage = { + role: 'user', + content: toolResultBlocks, + } + + conversationMessages.push(toolResultMessage) + options.onMessage?.(toolResultMessage) + + // Loop back to Step 1 — send updated conversation to the LLM. + } + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + yield { type: 'error', data: error } satisfies StreamEvent + return + } + + // If the loop exited due to maxTurns, use whatever text was last emitted. + if (finalOutput === '' && conversationMessages.length > 0) { + const lastAssistant = [...conversationMessages] + .reverse() + .find(m => m.role === 'assistant') + if (lastAssistant !== undefined) { + finalOutput = extractText(lastAssistant.content) + } + } + + const runResult: RunResult = { + // Return only the messages added during this run (not the initial seed). + messages: conversationMessages.slice(initialMessages.length), + output: finalOutput, + toolCalls: allToolCalls, + tokenUsage: totalUsage, + turns, + } + + yield { type: 'done', data: runResult } satisfies StreamEvent + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Build the {@link ToolUseContext} passed to every tool execution. + * Identifies this runner as the invoking agent. + */ + private buildToolContext(): ToolUseContext { + return { + agent: { + name: this.options.agentName ?? 'runner', + role: this.options.agentRole ?? 'assistant', + model: this.options.model, + }, + abortSignal: this.options.abortSignal, + } + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..814996f --- /dev/null +++ b/src/index.ts @@ -0,0 +1,166 @@ +/** + * @fileoverview open-multi-agent — public API surface. + * + * Import from `'open-multi-agent'` to access everything you need: + * + * ```ts + * import { OpenMultiAgent, Agent, Team, defineTool } from 'open-multi-agent' + * ``` + * + * ## Quickstart + * + * ### Single agent + * ```ts + * const orchestrator = new OpenMultiAgent({ defaultModel: 'claude-opus-4-6' }) + * const result = await orchestrator.runAgent( + * { name: 'assistant', model: 'claude-opus-4-6' }, + * 'Explain monads in one paragraph.', + * ) + * console.log(result.output) + * ``` + * + * ### Multi-agent team (auto-orchestrated) + * ```ts + * const orchestrator = new OpenMultiAgent() + * const team = orchestrator.createTeam('writers', { + * name: 'writers', + * agents: [ + * { name: 'researcher', model: 'claude-opus-4-6', systemPrompt: 'You research topics thoroughly.' }, + * { name: 'writer', model: 'claude-opus-4-6', systemPrompt: 'You write clear documentation.' }, + * ], + * sharedMemory: true, + * }) + * const result = await orchestrator.runTeam(team, 'Write a guide on TypeScript generics.') + * console.log(result.agentResults.get('coordinator')?.output) + * ``` + * + * ### Custom tools + * ```ts + * import { z } from 'zod' + * + * const myTool = defineTool({ + * name: 'fetch_data', + * description: 'Fetch JSON data from a URL.', + * inputSchema: z.object({ url: z.string().url() }), + * execute: async ({ url }) => { + * const res = await fetch(url) + * return { data: await res.text() } + * }, + * }) + * ``` + */ + +// --------------------------------------------------------------------------- +// Orchestrator (primary entry point) +// --------------------------------------------------------------------------- + +export { OpenMultiAgent } from './orchestrator/orchestrator.js' +export { Scheduler } from './orchestrator/scheduler.js' +export type { SchedulingStrategy } from './orchestrator/scheduler.js' + +// --------------------------------------------------------------------------- +// Agent layer +// --------------------------------------------------------------------------- + +export { Agent } from './agent/agent.js' +export { AgentPool, Semaphore } from './agent/pool.js' +export type { PoolStatus } from './agent/pool.js' + +// --------------------------------------------------------------------------- +// Team layer +// --------------------------------------------------------------------------- + +export { Team } from './team/team.js' +export { MessageBus } from './team/messaging.js' +export type { Message } from './team/messaging.js' + +// --------------------------------------------------------------------------- +// Task layer +// --------------------------------------------------------------------------- + +export { TaskQueue } from './task/queue.js' +export { createTask, isTaskReady, getTaskDependencyOrder, validateTaskDependencies } from './task/task.js' +export type { TaskQueueEvent } from './task/queue.js' + +// --------------------------------------------------------------------------- +// Tool system +// --------------------------------------------------------------------------- + +export { defineTool, ToolRegistry, zodToJsonSchema } from './tool/framework.js' +export { ToolExecutor } from './tool/executor.js' +export type { ToolExecutorOptions, BatchToolCall } from './tool/executor.js' +export { + registerBuiltInTools, + BUILT_IN_TOOLS, + bashTool, + fileReadTool, + fileWriteTool, + fileEditTool, + grepTool, +} from './tool/built-in/index.js' + +// --------------------------------------------------------------------------- +// LLM adapters +// --------------------------------------------------------------------------- + +export { createAdapter } from './llm/adapter.js' +export type { SupportedProvider } from './llm/adapter.js' + +// --------------------------------------------------------------------------- +// Memory +// --------------------------------------------------------------------------- + +export { InMemoryStore } from './memory/store.js' +export { SharedMemory } from './memory/shared.js' + +// --------------------------------------------------------------------------- +// Types — all public interfaces re-exported for consumer type-checking +// --------------------------------------------------------------------------- + +export type { + // Content blocks + TextBlock, + ToolUseBlock, + ToolResultBlock, + ImageBlock, + ContentBlock, + + // LLM + LLMMessage, + LLMResponse, + LLMAdapter, + LLMChatOptions, + LLMStreamOptions, + LLMToolDef, + TokenUsage, + StreamEvent, + + // Tools + ToolDefinition, + ToolResult, + ToolUseContext, + AgentInfo, + TeamInfo, + + // Agent + AgentConfig, + AgentState, + AgentRunResult, + ToolCallRecord, + + // Team + TeamConfig, + TeamRunResult, + + // Task + Task, + TaskStatus, + + // Orchestrator + OrchestratorConfig, + OrchestratorEvent, + + // Memory + MemoryEntry, + MemoryStore, +} from './types.js' diff --git a/src/llm/adapter.ts b/src/llm/adapter.ts new file mode 100644 index 0000000..979f37c --- /dev/null +++ b/src/llm/adapter.ts @@ -0,0 +1,74 @@ +/** + * @fileoverview LLM adapter factory. + * + * Re-exports the {@link LLMAdapter} interface and provides a + * {@link createAdapter} factory that returns the correct concrete + * implementation based on the requested provider. + * + * @example + * ```ts + * import { createAdapter } from './adapter.js' + * + * const anthropic = createAdapter('anthropic') + * const openai = createAdapter('openai', process.env.OPENAI_API_KEY) + * ``` + */ + +export type { + LLMAdapter, + LLMChatOptions, + LLMStreamOptions, + LLMToolDef, + LLMMessage, + LLMResponse, + StreamEvent, + TokenUsage, + ContentBlock, + TextBlock, + ToolUseBlock, + ToolResultBlock, + ImageBlock, +} from '../types.js' + +import type { LLMAdapter } from '../types.js' + +/** + * The set of LLM providers supported out of the box. + * Additional providers can be integrated by implementing {@link LLMAdapter} + * directly and bypassing this factory. + */ +export type SupportedProvider = 'anthropic' | 'openai' + +/** + * Instantiate the appropriate {@link LLMAdapter} for the given provider. + * + * API keys fall back to the standard environment variables + * (`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`) when not supplied explicitly. + * + * Adapters are imported lazily so that projects using only one provider + * are not forced to install the SDK for the other. + * + * @param provider - Which LLM provider to target. + * @param apiKey - Optional API key override; falls back to env var. + * @throws {Error} When the provider string is not recognised. + */ +export async function createAdapter( + provider: SupportedProvider, + apiKey?: string, +): Promise { + switch (provider) { + case 'anthropic': { + const { AnthropicAdapter } = await import('./anthropic.js') + return new AnthropicAdapter(apiKey) + } + case 'openai': { + const { OpenAIAdapter } = await import('./openai.js') + return new OpenAIAdapter(apiKey) + } + default: { + // The `never` cast here makes TypeScript enforce exhaustiveness. + const _exhaustive: never = provider + throw new Error(`Unsupported LLM provider: ${String(_exhaustive)}`) + } + } +} diff --git a/src/llm/anthropic.ts b/src/llm/anthropic.ts new file mode 100644 index 0000000..6b91fd4 --- /dev/null +++ b/src/llm/anthropic.ts @@ -0,0 +1,388 @@ +/** + * @fileoverview Anthropic Claude adapter implementing {@link LLMAdapter}. + * + * Converts between the framework's internal {@link ContentBlock} types and the + * Anthropic SDK's wire format, handling tool definitions, system prompts, and + * both batch and streaming response paths. + * + * API key resolution order: + * 1. `apiKey` constructor argument + * 2. `ANTHROPIC_API_KEY` environment variable + * + * @example + * ```ts + * import { AnthropicAdapter } from './anthropic.js' + * + * const adapter = new AnthropicAdapter() + * const response = await adapter.chat(messages, { + * model: 'claude-opus-4-6', + * maxTokens: 1024, + * }) + * ``` + */ + +import Anthropic from '@anthropic-ai/sdk' +import type { + ContentBlockParam, + ImageBlockParam, + MessageParam, + TextBlockParam, + ToolResultBlockParam, + ToolUseBlockParam, + Tool as AnthropicTool, +} from '@anthropic-ai/sdk/resources/messages/messages.js' + +import type { + ContentBlock, + ImageBlock, + LLMAdapter, + LLMChatOptions, + LLMMessage, + LLMResponse, + LLMStreamOptions, + LLMToolDef, + StreamEvent, + TextBlock, + ToolResultBlock, + ToolUseBlock, +} from '../types.js' + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Convert a single framework {@link ContentBlock} into an Anthropic + * {@link ContentBlockParam} suitable for the `messages` array. + * + * `tool_result` blocks are only valid inside `user`-role messages, which is + * handled by {@link toAnthropicMessages} based on role context. + */ +function toAnthropicContentBlockParam(block: ContentBlock): ContentBlockParam { + switch (block.type) { + case 'text': { + const param: TextBlockParam = { type: 'text', text: block.text } + return param + } + case 'tool_use': { + const param: ToolUseBlockParam = { + type: 'tool_use', + id: block.id, + name: block.name, + input: block.input, + } + return param + } + case 'tool_result': { + const param: ToolResultBlockParam = { + type: 'tool_result', + tool_use_id: block.tool_use_id, + content: block.content, + is_error: block.is_error, + } + return param + } + case 'image': { + // Anthropic only accepts a subset of MIME types; we pass them through + // trusting the caller to supply a valid media_type value. + const param: ImageBlockParam = { + type: 'image', + source: { + type: 'base64', + media_type: block.source.media_type as + | 'image/jpeg' + | 'image/png' + | 'image/gif' + | 'image/webp', + data: block.source.data, + }, + } + return param + } + default: { + // Exhaustiveness guard — TypeScript will flag this at compile time if a + // new variant is added to ContentBlock without updating this switch. + const _exhaustive: never = block + throw new Error(`Unhandled content block type: ${JSON.stringify(_exhaustive)}`) + } + } +} + +/** + * Convert framework messages into Anthropic's `MessageParam[]` format. + * + * The Anthropic API requires strict user/assistant alternation. We do not + * enforce that here — the caller is responsible for producing a valid + * conversation history. + */ +function toAnthropicMessages(messages: LLMMessage[]): MessageParam[] { + return messages.map((msg): MessageParam => ({ + role: msg.role, + content: msg.content.map(toAnthropicContentBlockParam), + })) +} + +/** + * Convert framework {@link LLMToolDef}s into Anthropic's `Tool` objects. + * + * The `inputSchema` on {@link LLMToolDef} is already a plain JSON Schema + * object, so we just need to reshape the wrapper. + */ +function toAnthropicTools(tools: readonly LLMToolDef[]): AnthropicTool[] { + return tools.map((t): AnthropicTool => ({ + name: t.name, + description: t.description, + input_schema: { + type: 'object', + ...(t.inputSchema as Record), + }, + })) +} + +/** + * Convert an Anthropic SDK `ContentBlock` into a framework {@link ContentBlock}. + * + * We only map the subset of SDK types that the framework exposes. Unknown + * variants (thinking, server_tool_use, etc.) are converted to a text block + * carrying a stringified representation so data is never silently dropped. + */ +function fromAnthropicContentBlock( + block: Anthropic.Messages.ContentBlock, +): ContentBlock { + switch (block.type) { + case 'text': { + const text: TextBlock = { type: 'text', text: block.text } + return text + } + case 'tool_use': { + const toolUse: ToolUseBlock = { + type: 'tool_use', + id: block.id, + name: block.name, + input: block.input as Record, + } + return toolUse + } + default: { + // Graceful degradation for SDK types we don't model (thinking, etc.). + const fallback: TextBlock = { + type: 'text', + text: `[unsupported block type: ${(block as { type: string }).type}]`, + } + return fallback + } + } +} + +// --------------------------------------------------------------------------- +// Adapter implementation +// --------------------------------------------------------------------------- + +/** + * LLM adapter backed by the Anthropic Claude API. + * + * Thread-safe — a single instance may be shared across concurrent agent runs. + * The underlying SDK client is stateless across requests. + */ +export class AnthropicAdapter implements LLMAdapter { + readonly name = 'anthropic' + + readonly #client: Anthropic + + constructor(apiKey?: string) { + this.#client = new Anthropic({ + apiKey: apiKey ?? process.env['ANTHROPIC_API_KEY'], + }) + } + + // ------------------------------------------------------------------------- + // chat() + // ------------------------------------------------------------------------- + + /** + * Send a synchronous (non-streaming) chat request and return the complete + * {@link LLMResponse}. + * + * Throws an `Anthropic.APIError` on non-2xx responses. Callers should catch + * and handle these (e.g. rate limits, context window exceeded). + */ + async chat(messages: LLMMessage[], options: LLMChatOptions): Promise { + const anthropicMessages = toAnthropicMessages(messages) + + const response = await this.#client.messages.create( + { + model: options.model, + max_tokens: options.maxTokens ?? 4096, + messages: anthropicMessages, + system: options.systemPrompt, + tools: options.tools ? toAnthropicTools(options.tools) : undefined, + temperature: options.temperature, + }, + { + signal: options.abortSignal, + }, + ) + + const content = response.content.map(fromAnthropicContentBlock) + + return { + id: response.id, + content, + model: response.model, + stop_reason: response.stop_reason ?? 'end_turn', + usage: { + input_tokens: response.usage.input_tokens, + output_tokens: response.usage.output_tokens, + }, + } + } + + // ------------------------------------------------------------------------- + // stream() + // ------------------------------------------------------------------------- + + /** + * Send a streaming chat request and yield {@link StreamEvent}s as they + * arrive from the API. + * + * Sequence guarantees: + * - Zero or more `text` events containing incremental deltas + * - Zero or more `tool_use` events when the model calls a tool (emitted once + * per tool use, after input JSON has been fully assembled) + * - Exactly one terminal event: `done` (with the complete {@link LLMResponse} + * as `data`) or `error` (with an `Error` as `data`) + */ + async *stream( + messages: LLMMessage[], + options: LLMStreamOptions, + ): AsyncIterable { + const anthropicMessages = toAnthropicMessages(messages) + + // MessageStream gives us typed events and handles SSE reconnect internally. + const stream = this.#client.messages.stream( + { + model: options.model, + max_tokens: options.maxTokens ?? 4096, + messages: anthropicMessages, + system: options.systemPrompt, + tools: options.tools ? toAnthropicTools(options.tools) : undefined, + temperature: options.temperature, + }, + { + signal: options.abortSignal, + }, + ) + + // Accumulate tool-use input JSON as it streams in. + // key = content block index, value = partially assembled input JSON string + const toolInputBuffers = new Map() + + try { + for await (const event of stream) { + switch (event.type) { + case 'content_block_start': { + const block = event.content_block + if (block.type === 'tool_use') { + toolInputBuffers.set(event.index, { + id: block.id, + name: block.name, + json: '', + }) + } + break + } + + case 'content_block_delta': { + const delta = event.delta + + if (delta.type === 'text_delta') { + const textEvent: StreamEvent = { type: 'text', data: delta.text } + yield textEvent + } else if (delta.type === 'input_json_delta') { + const buf = toolInputBuffers.get(event.index) + if (buf !== undefined) { + buf.json += delta.partial_json + } + } + break + } + + case 'content_block_stop': { + const buf = toolInputBuffers.get(event.index) + if (buf !== undefined) { + // Parse the accumulated JSON and emit a tool_use event. + let parsedInput: Record = {} + try { + const parsed: unknown = JSON.parse(buf.json) + if ( + parsed !== null && + typeof parsed === 'object' && + !Array.isArray(parsed) + ) { + parsedInput = parsed as Record + } + } catch { + // Malformed JSON from the model — surface as an empty object + // rather than crashing the stream. + } + + const toolUseBlock: ToolUseBlock = { + type: 'tool_use', + id: buf.id, + name: buf.name, + input: parsedInput, + } + const toolUseEvent: StreamEvent = { type: 'tool_use', data: toolUseBlock } + yield toolUseEvent + toolInputBuffers.delete(event.index) + } + break + } + + // message_start, message_delta, message_stop — we handle the final + // response via stream.finalMessage() below rather than piecemeal. + default: + break + } + } + + // Await the fully assembled final message (token counts, stop_reason, etc.) + const finalMessage = await stream.finalMessage() + const content = finalMessage.content.map(fromAnthropicContentBlock) + + const finalResponse: LLMResponse = { + id: finalMessage.id, + content, + model: finalMessage.model, + stop_reason: finalMessage.stop_reason ?? 'end_turn', + usage: { + input_tokens: finalMessage.usage.input_tokens, + output_tokens: finalMessage.usage.output_tokens, + }, + } + + const doneEvent: StreamEvent = { type: 'done', data: finalResponse } + yield doneEvent + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + const errorEvent: StreamEvent = { type: 'error', data: error } + yield errorEvent + } + } +} + +// Re-export types that consumers of this module commonly need alongside the adapter. +export type { + ContentBlock, + ImageBlock, + LLMAdapter, + LLMChatOptions, + LLMMessage, + LLMResponse, + LLMStreamOptions, + LLMToolDef, + StreamEvent, + TextBlock, + ToolResultBlock, + ToolUseBlock, +} diff --git a/src/llm/openai.ts b/src/llm/openai.ts new file mode 100644 index 0000000..b99ddfd --- /dev/null +++ b/src/llm/openai.ts @@ -0,0 +1,522 @@ +/** + * @fileoverview OpenAI adapter implementing {@link LLMAdapter}. + * + * Converts between the framework's internal {@link ContentBlock} types and the + * OpenAI Chat Completions wire format. Key mapping decisions: + * + * - Framework `tool_use` blocks in assistant messages → OpenAI `tool_calls` + * - Framework `tool_result` blocks in user messages → OpenAI `tool` role messages + * - Framework `image` blocks in user messages → OpenAI image content parts + * - System prompt in {@link LLMChatOptions} → prepended `system` message + * + * Because OpenAI and Anthropic use fundamentally different role-based structures + * for tool calling (Anthropic embeds tool results in user-role content arrays; + * OpenAI uses a dedicated `tool` role), the conversion necessarily splits + * `tool_result` blocks out into separate top-level messages. + * + * API key resolution order: + * 1. `apiKey` constructor argument + * 2. `OPENAI_API_KEY` environment variable + * + * @example + * ```ts + * import { OpenAIAdapter } from './openai.js' + * + * const adapter = new OpenAIAdapter() + * const response = await adapter.chat(messages, { + * model: 'gpt-5.4', + * maxTokens: 1024, + * }) + * ``` + */ + +import OpenAI from 'openai' +import type { + ChatCompletion, + ChatCompletionAssistantMessageParam, + ChatCompletionChunk, + ChatCompletionMessageParam, + ChatCompletionMessageToolCall, + ChatCompletionTool, + ChatCompletionToolMessageParam, + ChatCompletionUserMessageParam, +} from 'openai/resources/chat/completions/index.js' + +import type { + ContentBlock, + LLMAdapter, + LLMChatOptions, + LLMMessage, + LLMResponse, + LLMStreamOptions, + LLMToolDef, + StreamEvent, + TextBlock, + ToolUseBlock, +} from '../types.js' + +// --------------------------------------------------------------------------- +// Internal helpers — framework → OpenAI +// --------------------------------------------------------------------------- + +/** + * Convert a framework {@link LLMToolDef} to an OpenAI {@link ChatCompletionTool}. + * + * OpenAI wraps the function definition inside a `function` key and a `type` + * discriminant. The `inputSchema` is already a JSON Schema object. + */ +function toOpenAITool(tool: LLMToolDef): ChatCompletionTool { + return { + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.inputSchema as Record, + }, + } +} + +/** + * Determine whether a framework message contains any `tool_result` content + * blocks, which must be serialised as separate OpenAI `tool`-role messages. + */ +function hasToolResults(msg: LLMMessage): boolean { + return msg.content.some((b) => b.type === 'tool_result') +} + +/** + * Convert a single framework {@link LLMMessage} into one or more OpenAI + * {@link ChatCompletionMessageParam} entries. + * + * The expansion is necessary because OpenAI represents tool results as + * top-level messages with role `tool`, whereas in our model they are content + * blocks inside a `user` message. + * + * Expansion rules: + * - A `user` message containing only text/image blocks → single user message + * - A `user` message containing `tool_result` blocks → one `tool` message per + * tool_result block; any remaining text/image blocks are folded into an + * additional user message prepended to the group + * - An `assistant` message → single assistant message with optional tool_calls + */ +function toOpenAIMessages(messages: LLMMessage[]): ChatCompletionMessageParam[] { + const result: ChatCompletionMessageParam[] = [] + + for (const msg of messages) { + if (msg.role === 'assistant') { + result.push(toOpenAIAssistantMessage(msg)) + } else { + // user role + if (!hasToolResults(msg)) { + result.push(toOpenAIUserMessage(msg)) + } else { + // Split: text/image blocks become a user message (if any exist), then + // each tool_result block becomes an independent tool message. + const nonToolBlocks = msg.content.filter((b) => b.type !== 'tool_result') + if (nonToolBlocks.length > 0) { + result.push(toOpenAIUserMessage({ role: 'user', content: nonToolBlocks })) + } + + for (const block of msg.content) { + if (block.type === 'tool_result') { + const toolMsg: ChatCompletionToolMessageParam = { + role: 'tool', + tool_call_id: block.tool_use_id, + content: block.content, + } + result.push(toolMsg) + } + } + } + } + } + + return result +} + +/** + * Convert a `user`-role framework message into an OpenAI user message. + * Image blocks are converted to the OpenAI image_url content part format. + */ +function toOpenAIUserMessage(msg: LLMMessage): ChatCompletionUserMessageParam { + // If the entire content is a single text block, use the compact string form + // to keep the request payload smaller. + if (msg.content.length === 1 && msg.content[0]?.type === 'text') { + return { role: 'user', content: msg.content[0].text } + } + + type ContentPart = OpenAI.Chat.ChatCompletionContentPartText | OpenAI.Chat.ChatCompletionContentPartImage + const parts: ContentPart[] = [] + + for (const block of msg.content) { + if (block.type === 'text') { + parts.push({ type: 'text', text: block.text }) + } else if (block.type === 'image') { + parts.push({ + type: 'image_url', + image_url: { + url: `data:${block.source.media_type};base64,${block.source.data}`, + }, + }) + } + // tool_result blocks are handled by the caller (toOpenAIMessages); skip here. + } + + return { role: 'user', content: parts } +} + +/** + * Convert an `assistant`-role framework message into an OpenAI assistant message. + * + * Any `tool_use` blocks become `tool_calls`; `text` blocks become the message content. + */ +function toOpenAIAssistantMessage(msg: LLMMessage): ChatCompletionAssistantMessageParam { + const toolCalls: ChatCompletionMessageToolCall[] = [] + const textParts: string[] = [] + + for (const block of msg.content) { + if (block.type === 'tool_use') { + toolCalls.push({ + id: block.id, + type: 'function', + function: { + name: block.name, + arguments: JSON.stringify(block.input), + }, + }) + } else if (block.type === 'text') { + textParts.push(block.text) + } + } + + const assistantMsg: ChatCompletionAssistantMessageParam = { + role: 'assistant', + content: textParts.length > 0 ? textParts.join('') : null, + } + + if (toolCalls.length > 0) { + assistantMsg.tool_calls = toolCalls + } + + return assistantMsg +} + +// --------------------------------------------------------------------------- +// Internal helpers — OpenAI → framework +// --------------------------------------------------------------------------- + +/** + * Convert an OpenAI {@link ChatCompletion} into a framework {@link LLMResponse}. + * + * We take only the first choice (index 0), consistent with how the framework + * is designed for single-output agents. + */ +function fromOpenAICompletion(completion: ChatCompletion): LLMResponse { + const choice = completion.choices[0] + if (choice === undefined) { + throw new Error('OpenAI returned a completion with no choices') + } + + const content: ContentBlock[] = [] + const message = choice.message + + if (message.content !== null && message.content !== undefined) { + const textBlock: TextBlock = { type: 'text', text: message.content } + content.push(textBlock) + } + + for (const toolCall of message.tool_calls ?? []) { + let parsedInput: Record = {} + try { + const parsed: unknown = JSON.parse(toolCall.function.arguments) + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { + parsedInput = parsed as Record + } + } catch { + // Malformed arguments from the model — surface as empty object. + } + + const toolUseBlock: ToolUseBlock = { + type: 'tool_use', + id: toolCall.id, + name: toolCall.function.name, + input: parsedInput, + } + content.push(toolUseBlock) + } + + const stopReason = normalizeFinishReason(choice.finish_reason ?? 'stop') + + return { + id: completion.id, + content, + model: completion.model, + stop_reason: stopReason, + usage: { + input_tokens: completion.usage?.prompt_tokens ?? 0, + output_tokens: completion.usage?.completion_tokens ?? 0, + }, + } +} + +/** + * Normalize an OpenAI `finish_reason` string to the framework's canonical + * stop-reason vocabulary so consumers never need to branch on provider-specific + * strings. + * + * Mapping: + * - `'stop'` → `'end_turn'` + * - `'tool_calls'` → `'tool_use'` + * - `'length'` → `'max_tokens'` + * - `'content_filter'` → `'content_filter'` + * - anything else → passed through unchanged + */ +function normalizeFinishReason(reason: string): string { + switch (reason) { + case 'stop': return 'end_turn' + case 'tool_calls': return 'tool_use' + case 'length': return 'max_tokens' + case 'content_filter': return 'content_filter' + default: return reason + } +} + +// --------------------------------------------------------------------------- +// Adapter implementation +// --------------------------------------------------------------------------- + +/** + * LLM adapter backed by the OpenAI Chat Completions API. + * + * Thread-safe — a single instance may be shared across concurrent agent runs. + */ +export class OpenAIAdapter implements LLMAdapter { + readonly name = 'openai' + + readonly #client: OpenAI + + constructor(apiKey?: string) { + this.#client = new OpenAI({ + apiKey: apiKey ?? process.env['OPENAI_API_KEY'], + }) + } + + // ------------------------------------------------------------------------- + // chat() + // ------------------------------------------------------------------------- + + /** + * Send a synchronous (non-streaming) chat request and return the complete + * {@link LLMResponse}. + * + * Throws an `OpenAI.APIError` on non-2xx responses. Callers should catch and + * handle these (e.g. rate limits, context length exceeded). + */ + async chat(messages: LLMMessage[], options: LLMChatOptions): Promise { + const openAIMessages = buildOpenAIMessageList(messages, options.systemPrompt) + + const completion = await this.#client.chat.completions.create( + { + model: options.model, + messages: openAIMessages, + max_tokens: options.maxTokens, + temperature: options.temperature, + tools: options.tools ? options.tools.map(toOpenAITool) : undefined, + stream: false, + }, + { + signal: options.abortSignal, + }, + ) + + return fromOpenAICompletion(completion) + } + + // ------------------------------------------------------------------------- + // stream() + // ------------------------------------------------------------------------- + + /** + * Send a streaming chat request and yield {@link StreamEvent}s incrementally. + * + * Sequence guarantees match {@link AnthropicAdapter.stream}: + * - Zero or more `text` events + * - Zero or more `tool_use` events (emitted once per tool call, after + * arguments have been fully assembled) + * - Exactly one terminal event: `done` or `error` + */ + async *stream( + messages: LLMMessage[], + options: LLMStreamOptions, + ): AsyncIterable { + const openAIMessages = buildOpenAIMessageList(messages, options.systemPrompt) + + // We request usage in the final chunk so we can include it in the `done` event. + const streamResponse = await this.#client.chat.completions.create( + { + model: options.model, + messages: openAIMessages, + max_tokens: options.maxTokens, + temperature: options.temperature, + tools: options.tools ? options.tools.map(toOpenAITool) : undefined, + stream: true, + stream_options: { include_usage: true }, + }, + { + signal: options.abortSignal, + }, + ) + + // Accumulate state across chunks. + let completionId = '' + let completionModel = '' + let finalFinishReason: string = 'stop' + let inputTokens = 0 + let outputTokens = 0 + + // tool_calls are streamed piecemeal; key = tool call index + const toolCallBuffers = new Map< + number, + { id: string; name: string; argsJson: string } + >() + + // Full text accumulator for the `done` response. + let fullText = '' + + try { + for await (const chunk of streamResponse) { + completionId = chunk.id + completionModel = chunk.model + + // Usage is only populated in the final chunk when stream_options.include_usage is set. + if (chunk.usage !== null && chunk.usage !== undefined) { + inputTokens = chunk.usage.prompt_tokens + outputTokens = chunk.usage.completion_tokens + } + + const choice: ChatCompletionChunk.Choice | undefined = chunk.choices[0] + if (choice === undefined) continue + + const delta = choice.delta + + // --- text delta --- + if (delta.content !== null && delta.content !== undefined) { + fullText += delta.content + const textEvent: StreamEvent = { type: 'text', data: delta.content } + yield textEvent + } + + // --- tool call delta --- + for (const toolCallDelta of delta.tool_calls ?? []) { + const idx = toolCallDelta.index + + if (!toolCallBuffers.has(idx)) { + toolCallBuffers.set(idx, { + id: toolCallDelta.id ?? '', + name: toolCallDelta.function?.name ?? '', + argsJson: '', + }) + } + + const buf = toolCallBuffers.get(idx) + // buf is guaranteed to exist: we just set it above. + if (buf !== undefined) { + if (toolCallDelta.id) buf.id = toolCallDelta.id + if (toolCallDelta.function?.name) buf.name = toolCallDelta.function.name + if (toolCallDelta.function?.arguments) { + buf.argsJson += toolCallDelta.function.arguments + } + } + } + + if (choice.finish_reason !== null && choice.finish_reason !== undefined) { + finalFinishReason = choice.finish_reason + } + } + + // Emit accumulated tool_use events after the stream ends. + const finalToolUseBlocks: ToolUseBlock[] = [] + for (const buf of toolCallBuffers.values()) { + let parsedInput: Record = {} + try { + const parsed: unknown = JSON.parse(buf.argsJson) + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { + parsedInput = parsed as Record + } + } catch { + // Malformed JSON — surface as empty object. + } + + const toolUseBlock: ToolUseBlock = { + type: 'tool_use', + id: buf.id, + name: buf.name, + input: parsedInput, + } + finalToolUseBlocks.push(toolUseBlock) + const toolUseEvent: StreamEvent = { type: 'tool_use', data: toolUseBlock } + yield toolUseEvent + } + + // Build the complete content array for the done response. + const doneContent: ContentBlock[] = [] + if (fullText.length > 0) { + const textBlock: TextBlock = { type: 'text', text: fullText } + doneContent.push(textBlock) + } + doneContent.push(...finalToolUseBlocks) + + const finalResponse: LLMResponse = { + id: completionId, + content: doneContent, + model: completionModel, + stop_reason: normalizeFinishReason(finalFinishReason), + usage: { input_tokens: inputTokens, output_tokens: outputTokens }, + } + + const doneEvent: StreamEvent = { type: 'done', data: finalResponse } + yield doneEvent + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + const errorEvent: StreamEvent = { type: 'error', data: error } + yield errorEvent + } + } +} + +// --------------------------------------------------------------------------- +// Private utility +// --------------------------------------------------------------------------- + +/** + * Prepend a system message when `systemPrompt` is provided, then append the + * converted conversation messages. + * + * OpenAI represents system instructions as a message with `role: 'system'` + * at the top of the array, not as a separate API parameter. + */ +function buildOpenAIMessageList( + messages: LLMMessage[], + systemPrompt: string | undefined, +): ChatCompletionMessageParam[] { + const result: ChatCompletionMessageParam[] = [] + + if (systemPrompt !== undefined && systemPrompt.length > 0) { + result.push({ role: 'system', content: systemPrompt }) + } + + result.push(...toOpenAIMessages(messages)) + return result +} + +// Re-export types that consumers of this module commonly need alongside the adapter. +export type { + ContentBlock, + LLMAdapter, + LLMChatOptions, + LLMMessage, + LLMResponse, + LLMStreamOptions, + LLMToolDef, + StreamEvent, +} diff --git a/src/memory/shared.ts b/src/memory/shared.ts new file mode 100644 index 0000000..2cdcf57 --- /dev/null +++ b/src/memory/shared.ts @@ -0,0 +1,181 @@ +/** + * @fileoverview Shared memory layer for teams of cooperating agents. + * + * Each agent writes under its own namespace (`/`) so entries + * remain attributable, while any agent may read any entry. The + * {@link SharedMemory.getSummary} method produces a human-readable digest + * suitable for injecting into an agent's context window. + */ + +import type { MemoryEntry, MemoryStore } from '../types.js' +import { InMemoryStore } from './store.js' + +// --------------------------------------------------------------------------- +// SharedMemory +// --------------------------------------------------------------------------- + +/** + * Namespaced shared memory for a team of agents. + * + * Writes are namespaced as `/` so that entries from different + * agents never collide and are always attributable. Reads are namespace-aware + * but also accept fully-qualified keys, making cross-agent reads straightforward. + * + * @example + * ```ts + * const mem = new SharedMemory() + * + * await mem.write('researcher', 'findings', 'TypeScript 5.5 ships const type params') + * await mem.write('coder', 'plan', 'Implement feature X using const type params') + * + * const entry = await mem.read('researcher/findings') + * const all = await mem.listByAgent('researcher') + * const summary = await mem.getSummary() + * ``` + */ +export class SharedMemory { + private readonly store: InMemoryStore + + constructor() { + this.store = new InMemoryStore() + } + + // --------------------------------------------------------------------------- + // Write + // --------------------------------------------------------------------------- + + /** + * Write `value` under the namespaced key `/`. + * + * Metadata is merged with a `{ agent: agentName }` marker so consumers can + * identify provenance when iterating all entries. + * + * @param agentName - The writing agent's name (used as a namespace prefix). + * @param key - Logical key within the agent's namespace. + * @param value - String value to store (serialise objects before writing). + * @param metadata - Optional extra metadata stored alongside the entry. + */ + async write( + agentName: string, + key: string, + value: string, + metadata?: Record, + ): Promise { + const namespacedKey = SharedMemory.namespaceKey(agentName, key) + await this.store.set(namespacedKey, value, { + ...metadata, + agent: agentName, + }) + } + + // --------------------------------------------------------------------------- + // Read + // --------------------------------------------------------------------------- + + /** + * Read an entry by its fully-qualified key (`/`). + * + * Returns `null` when the key is absent. + */ + async read(key: string): Promise { + return this.store.get(key) + } + + // --------------------------------------------------------------------------- + // List + // --------------------------------------------------------------------------- + + /** Returns every entry in the shared store, regardless of agent. */ + async listAll(): Promise { + return this.store.list() + } + + /** + * Returns all entries written by `agentName` (i.e. those whose key starts + * with `/`). + */ + async listByAgent(agentName: string): Promise { + const prefix = SharedMemory.namespaceKey(agentName, '') + const all = await this.store.list() + return all.filter((entry) => entry.key.startsWith(prefix)) + } + + // --------------------------------------------------------------------------- + // Summary + // --------------------------------------------------------------------------- + + /** + * Produces a human-readable summary of all entries in the store. + * + * The output is structured as a markdown-style block, grouped by agent, and + * is designed to be prepended to an agent's system prompt or injected as a + * user turn so the agent has context about what its teammates know. + * + * Returns an empty string when the store is empty. + * + * @example + * ``` + * ## Shared Team Memory + * + * ### researcher + * - findings: TypeScript 5.5 ships const type params + * + * ### coder + * - plan: Implement feature X using const type params + * ``` + */ + async getSummary(): Promise { + const all = await this.store.list() + if (all.length === 0) return '' + + // Group entries by agent name. + const byAgent = new Map>() + for (const entry of all) { + const slashIdx = entry.key.indexOf('/') + const agent = slashIdx === -1 ? '_unknown' : entry.key.slice(0, slashIdx) + const localKey = slashIdx === -1 ? entry.key : entry.key.slice(slashIdx + 1) + + let group = byAgent.get(agent) + if (!group) { + group = [] + byAgent.set(agent, group) + } + group.push({ localKey, value: entry.value }) + } + + const lines: string[] = ['## Shared Team Memory', ''] + for (const [agent, entries] of byAgent) { + lines.push(`### ${agent}`) + for (const { localKey, value } of entries) { + // Truncate long values so the summary stays readable in a context window. + const displayValue = + value.length > 200 ? `${value.slice(0, 197)}…` : value + lines.push(`- ${localKey}: ${displayValue}`) + } + lines.push('') + } + + return lines.join('\n').trimEnd() + } + + // --------------------------------------------------------------------------- + // Store access + // --------------------------------------------------------------------------- + + /** + * Returns the underlying {@link MemoryStore} so callers that only need the + * raw key-value interface can receive a properly typed reference without + * accessing private fields via bracket notation. + */ + getStore(): MemoryStore { + return this.store + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private static namespaceKey(agentName: string, key: string): string { + return `${agentName}/${key}` + } +} diff --git a/src/memory/store.ts b/src/memory/store.ts new file mode 100644 index 0000000..f219fe3 --- /dev/null +++ b/src/memory/store.ts @@ -0,0 +1,124 @@ +/** + * @fileoverview In-memory implementation of {@link MemoryStore}. + * + * All data lives in a plain `Map` and is never persisted to disk. This is the + * default store used by {@link SharedMemory} and is suitable for testing and + * single-process use-cases. Swap it for a Redis or SQLite-backed implementation + * in production by satisfying the same {@link MemoryStore} interface. + */ + +import type { MemoryEntry, MemoryStore } from '../types.js' + +// --------------------------------------------------------------------------- +// InMemoryStore +// --------------------------------------------------------------------------- + +/** + * Synchronous-under-the-hood key/value store that exposes an `async` surface + * so implementations can be swapped for async-native backends without changing + * callers. + * + * All keys are treated as opaque strings. Values are always strings; structured + * data must be serialised by the caller (e.g. `JSON.stringify`). + * + * @example + * ```ts + * const store = new InMemoryStore() + * await store.set('config', JSON.stringify({ model: 'claude-opus-4-6' })) + * const entry = await store.get('config') + * ``` + */ +export class InMemoryStore implements MemoryStore { + private readonly data = new Map() + + // --------------------------------------------------------------------------- + // MemoryStore interface + // --------------------------------------------------------------------------- + + /** Returns the entry for `key`, or `null` if not present. */ + async get(key: string): Promise { + return this.data.get(key) ?? null + } + + /** + * Upserts `key` with `value` and optional `metadata`. + * + * If the key already exists its `createdAt` is **preserved** so callers can + * detect when a value was first written. + */ + async set( + key: string, + value: string, + metadata?: Record, + ): Promise { + const existing = this.data.get(key) + const entry: MemoryEntry = { + key, + value, + metadata: metadata !== undefined ? { ...metadata } : undefined, + createdAt: existing?.createdAt ?? new Date(), + } + this.data.set(key, entry) + } + + /** Returns a snapshot of all entries in insertion order. */ + async list(): Promise { + return Array.from(this.data.values()) + } + + /** + * Removes the entry for `key`. + * Deleting a non-existent key is a no-op. + */ + async delete(key: string): Promise { + this.data.delete(key) + } + + /** Removes **all** entries from the store. */ + async clear(): Promise { + this.data.clear() + } + + // --------------------------------------------------------------------------- + // Extensions beyond the base MemoryStore interface + // --------------------------------------------------------------------------- + + /** + * Returns entries whose `key` starts with `query` **or** whose `value` + * contains `query` (case-insensitive substring match). + * + * This is a simple linear scan; it is not suitable for very large stores + * without an index layer on top. + * + * @example + * ```ts + * // Find all entries related to "research" + * const hits = await store.search('research') + * ``` + */ + async search(query: string): Promise { + if (query.length === 0) { + return this.list() + } + const lower = query.toLowerCase() + return Array.from(this.data.values()).filter( + (entry) => + entry.key.toLowerCase().includes(lower) || + entry.value.toLowerCase().includes(lower), + ) + } + + // --------------------------------------------------------------------------- + // Convenience helpers (not part of MemoryStore) + // --------------------------------------------------------------------------- + + /** Returns the number of entries currently held in the store. */ + get size(): number { + return this.data.size + } + + /** Returns `true` if `key` exists in the store. */ + has(key: string): boolean { + return this.data.has(key) + } +} diff --git a/src/orchestrator/orchestrator.ts b/src/orchestrator/orchestrator.ts new file mode 100644 index 0000000..0332969 --- /dev/null +++ b/src/orchestrator/orchestrator.ts @@ -0,0 +1,851 @@ +/** + * @fileoverview OpenMultiAgent — the top-level multi-agent orchestration class. + * + * {@link OpenMultiAgent} is the primary public API of the open-multi-agent framework. + * It ties together every subsystem: + * + * - {@link Team} — Agent roster, shared memory, inter-agent messaging + * - {@link TaskQueue} — Dependency-aware work queue + * - {@link Scheduler} — Task-to-agent assignment strategies + * - {@link AgentPool} — Concurrency-controlled execution pool + * - {@link Agent} — Conversation + tool-execution loop + * + * ## Quick start + * + * ```ts + * const orchestrator = new OpenMultiAgent({ defaultModel: 'claude-opus-4-6' }) + * + * const team = orchestrator.createTeam('research', { + * name: 'research', + * agents: [ + * { name: 'researcher', model: 'claude-opus-4-6', systemPrompt: 'You are a researcher.' }, + * { name: 'writer', model: 'claude-opus-4-6', systemPrompt: 'You are a technical writer.' }, + * ], + * sharedMemory: true, + * }) + * + * const result = await orchestrator.runTeam(team, 'Produce a report on TypeScript 5.5.') + * console.log(result.agentResults.get('coordinator')?.output) + * ``` + * + * ## Key design decisions + * + * - **Coordinator pattern** — `runTeam()` spins up a temporary "coordinator" agent + * that breaks the high-level goal into tasks, assigns them, and synthesises the + * final answer. This is the framework's killer feature. + * - **Parallel-by-default** — Independent tasks (no shared dependency) run in + * parallel up to `maxConcurrency`. + * - **Graceful failure** — A failed task marks itself `'failed'` and its direct + * dependents remain `'blocked'` indefinitely; all non-dependent tasks continue. + * - **Progress callbacks** — Callers can pass `onProgress` in the config to receive + * structured {@link OrchestratorEvent}s without polling. + */ + +import type { + AgentConfig, + AgentRunResult, + OrchestratorConfig, + OrchestratorEvent, + Task, + TaskStatus, + TeamConfig, + TeamRunResult, + TokenUsage, +} from '../types.js' +import { Agent } from '../agent/agent.js' +import { AgentPool } from '../agent/pool.js' +import { ToolRegistry } from '../tool/framework.js' +import { ToolExecutor } from '../tool/executor.js' +import { registerBuiltInTools } from '../tool/built-in/index.js' +import { Team } from '../team/team.js' +import { TaskQueue } from '../task/queue.js' +import { createTask } from '../task/task.js' +import { Scheduler } from './scheduler.js' + +// --------------------------------------------------------------------------- +// Internal constants +// --------------------------------------------------------------------------- + +const ZERO_USAGE: TokenUsage = { input_tokens: 0, output_tokens: 0 } +const DEFAULT_MAX_CONCURRENCY = 5 +const DEFAULT_MODEL = 'claude-opus-4-6' + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage { + return { + input_tokens: a.input_tokens + b.input_tokens, + output_tokens: a.output_tokens + b.output_tokens, + } +} + +/** + * Build a minimal {@link Agent} with its own fresh registry/executor. + * Registers all built-in tools so coordinator/worker agents can use them. + */ +function buildAgent(config: AgentConfig): Agent { + const registry = new ToolRegistry() + registerBuiltInTools(registry) + const executor = new ToolExecutor(registry) + return new Agent(config, registry, executor) +} + +// --------------------------------------------------------------------------- +// Parsed task spec (result of coordinator decomposition) +// --------------------------------------------------------------------------- + +interface ParsedTaskSpec { + title: string + description: string + assignee?: string + dependsOn?: string[] +} + +/** + * Attempt to extract a JSON array of task specs from the coordinator's raw + * output. The coordinator is prompted to emit JSON inside a ```json … ``` fence + * or as a bare array. Returns `null` when no valid array can be extracted. + */ +function parseTaskSpecs(raw: string): ParsedTaskSpec[] | null { + // Strategy 1: look for a fenced JSON block + const fenceMatch = raw.match(/```json\s*([\s\S]*?)```/) + const candidate = fenceMatch ? fenceMatch[1]! : raw + + // Strategy 2: find the first '[' and last ']' + const arrayStart = candidate.indexOf('[') + const arrayEnd = candidate.lastIndexOf(']') + if (arrayStart === -1 || arrayEnd === -1 || arrayEnd <= arrayStart) { + return null + } + + const jsonSlice = candidate.slice(arrayStart, arrayEnd + 1) + try { + const parsed: unknown = JSON.parse(jsonSlice) + if (!Array.isArray(parsed)) return null + + const specs: ParsedTaskSpec[] = [] + for (const item of parsed) { + if (typeof item !== 'object' || item === null) continue + const obj = item as Record + if (typeof obj['title'] !== 'string') continue + if (typeof obj['description'] !== 'string') continue + + specs.push({ + title: obj['title'], + description: obj['description'], + assignee: typeof obj['assignee'] === 'string' ? obj['assignee'] : undefined, + dependsOn: Array.isArray(obj['dependsOn']) + ? (obj['dependsOn'] as unknown[]).filter((x): x is string => typeof x === 'string') + : undefined, + }) + } + + return specs.length > 0 ? specs : null + } catch { + return null + } +} + +// --------------------------------------------------------------------------- +// Orchestration loop +// --------------------------------------------------------------------------- + +/** + * Internal execution context assembled once per `runTeam` / `runTasks` call. + */ +interface RunContext { + readonly team: Team + readonly pool: AgentPool + readonly scheduler: Scheduler + readonly agentResults: Map + readonly config: OrchestratorConfig +} + +/** + * Execute all tasks in `queue` using agents in `pool`, respecting dependencies + * and running independent tasks in parallel. + * + * The orchestration loop works in rounds: + * 1. Find all `'pending'` tasks (dependencies satisfied). + * 2. Dispatch them in parallel via the pool. + * 3. On completion, the queue automatically unblocks dependents. + * 4. Repeat until no more pending tasks exist or all remaining tasks are + * `'failed'`/`'blocked'` (stuck). + */ +async function executeQueue( + queue: TaskQueue, + ctx: RunContext, +): Promise { + const { team, pool, scheduler, config } = ctx + + while (true) { + // Re-run auto-assignment each iteration so tasks that were unblocked since + // the last round (and thus have no assignee yet) get assigned before dispatch. + scheduler.autoAssign(queue, team.getAgents()) + + const pending = queue.getByStatus('pending') + if (pending.length === 0) { + // Either all done, or everything remaining is blocked/failed. + break + } + + // Dispatch all currently-pending tasks as a parallel batch. + const dispatchPromises = pending.map(async (task): Promise => { + // Mark in-progress + queue.update(task.id, { status: 'in_progress' as TaskStatus }) + + const assignee = task.assignee + if (!assignee) { + // No assignee — mark failed and continue + const msg = `Task "${task.title}" has no assignee.` + queue.fail(task.id, msg) + config.onProgress?.({ + type: 'error', + task: task.id, + data: msg, + } satisfies OrchestratorEvent) + return + } + + const agent = pool.get(assignee) + if (!agent) { + const msg = `Agent "${assignee}" not found in pool for task "${task.title}".` + queue.fail(task.id, msg) + config.onProgress?.({ + type: 'error', + task: task.id, + agent: assignee, + data: msg, + } satisfies OrchestratorEvent) + return + } + + config.onProgress?.({ + type: 'task_start', + task: task.id, + agent: assignee, + data: task, + } satisfies OrchestratorEvent) + + config.onProgress?.({ + type: 'agent_start', + agent: assignee, + task: task.id, + data: task, + } satisfies OrchestratorEvent) + + // Build the prompt: inject shared memory context + task description + const prompt = await buildTaskPrompt(task, team) + + try { + const result = await pool.run(assignee, prompt) + ctx.agentResults.set(`${assignee}:${task.id}`, result) + + if (result.success) { + // Persist result into shared memory so other agents can read it + const sharedMem = team.getSharedMemoryInstance() + if (sharedMem) { + await sharedMem.write(assignee, `task:${task.id}:result`, result.output) + } + + queue.complete(task.id, result.output) + + config.onProgress?.({ + type: 'task_complete', + task: task.id, + agent: assignee, + data: result, + } satisfies OrchestratorEvent) + + config.onProgress?.({ + type: 'agent_complete', + agent: assignee, + task: task.id, + data: result, + } satisfies OrchestratorEvent) + } else { + queue.fail(task.id, result.output) + config.onProgress?.({ + type: 'error', + task: task.id, + agent: assignee, + data: result, + } satisfies OrchestratorEvent) + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + queue.fail(task.id, message) + config.onProgress?.({ + type: 'error', + task: task.id, + agent: assignee, + data: err, + } satisfies OrchestratorEvent) + } + }) + + // Wait for the entire parallel batch before checking for newly-unblocked tasks. + await Promise.all(dispatchPromises) + } +} + +/** + * Build the agent prompt for a specific task. + * + * Injects: + * - Task title and description + * - Dependency results from shared memory (if available) + * - Any messages addressed to this agent from the team bus + */ +async function buildTaskPrompt(task: Task, team: Team): Promise { + const lines: string[] = [ + `# Task: ${task.title}`, + '', + task.description, + ] + + // Inject shared memory summary so the agent sees its teammates' work + const sharedMem = team.getSharedMemoryInstance() + if (sharedMem) { + const summary = await sharedMem.getSummary() + if (summary) { + lines.push('', summary) + } + } + + // Inject messages from other agents addressed to this assignee + if (task.assignee) { + const messages = team.getMessages(task.assignee) + if (messages.length > 0) { + lines.push('', '## Messages from team members') + for (const msg of messages) { + lines.push(`- **${msg.from}**: ${msg.content}`) + } + } + } + + return lines.join('\n') +} + +// --------------------------------------------------------------------------- +// OpenMultiAgent +// --------------------------------------------------------------------------- + +/** + * Top-level orchestrator for the open-multi-agent framework. + * + * Manages teams, coordinates task execution, and surfaces progress events. + * Most users will interact with this class exclusively. + */ +export class OpenMultiAgent { + private readonly config: Required< + Omit + > & Pick + + private readonly teams: Map = new Map() + private completedTaskCount = 0 + + /** + * @param config - Optional top-level configuration. + * + * Sensible defaults: + * - `maxConcurrency`: 5 + * - `defaultModel`: `'claude-opus-4-6'` + * - `defaultProvider`: `'anthropic'` + */ + constructor(config: OrchestratorConfig = {}) { + this.config = { + maxConcurrency: config.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + defaultModel: config.defaultModel ?? DEFAULT_MODEL, + defaultProvider: config.defaultProvider ?? 'anthropic', + onProgress: config.onProgress, + } + } + + // ------------------------------------------------------------------------- + // Team management + // ------------------------------------------------------------------------- + + /** + * Create and register a {@link Team} with the orchestrator. + * + * The team is stored internally so {@link getStatus} can report aggregate + * agent counts. Returns the new {@link Team} for further configuration. + * + * @param name - Unique team identifier. Throws if already registered. + * @param config - Team configuration (agents, shared memory, concurrency). + */ + createTeam(name: string, config: TeamConfig): Team { + if (this.teams.has(name)) { + throw new Error( + `OpenMultiAgent: a team named "${name}" already exists. ` + + `Use a unique name or call shutdown() to clear all teams.`, + ) + } + const team = new Team(config) + this.teams.set(name, team) + return team + } + + // ------------------------------------------------------------------------- + // Single-agent convenience + // ------------------------------------------------------------------------- + + /** + * Run a single prompt with a one-off agent. + * + * Constructs a fresh agent from `config`, runs `prompt` in a single turn, + * and returns the result. The agent is not registered with any pool or team. + * + * Useful for simple one-shot queries that do not need team orchestration. + * + * @param config - Agent configuration. + * @param prompt - The user prompt to send. + */ + async runAgent(config: AgentConfig, prompt: string): Promise { + const agent = buildAgent(config) + this.config.onProgress?.({ + type: 'agent_start', + agent: config.name, + data: { prompt }, + }) + + const result = await agent.run(prompt) + + this.config.onProgress?.({ + type: 'agent_complete', + agent: config.name, + data: result, + }) + + if (result.success) { + this.completedTaskCount++ + } + + return result + } + + // ------------------------------------------------------------------------- + // Auto-orchestrated team run (KILLER FEATURE) + // ------------------------------------------------------------------------- + + /** + * Run a team on a high-level goal with full automatic orchestration. + * + * This is the flagship method of the framework. It works as follows: + * + * 1. A temporary "coordinator" agent receives the goal and the team's agent + * roster, and is asked to decompose it into an ordered list of tasks with + * JSON output. + * 2. The tasks are loaded into a {@link TaskQueue}. Title-based dependency + * tokens in the coordinator's output are resolved to task IDs. + * 3. The {@link Scheduler} assigns unassigned tasks to team agents. + * 4. Tasks are executed in dependency order, with independent tasks running + * in parallel up to `maxConcurrency`. + * 5. Results are persisted to shared memory after each task so subsequent + * agents can read them. + * 6. The coordinator synthesises a final answer from all task outputs. + * 7. A {@link TeamRunResult} is returned. + * + * @param team - A team created via {@link createTeam} (or `new Team(...)`). + * @param goal - High-level natural-language goal for the team. + */ + async runTeam(team: Team, goal: string): Promise { + const agentConfigs = team.getAgents() + + // ------------------------------------------------------------------ + // Step 1: Coordinator decomposes goal into tasks + // ------------------------------------------------------------------ + const coordinatorConfig: AgentConfig = { + name: 'coordinator', + model: this.config.defaultModel, + provider: this.config.defaultProvider, + systemPrompt: this.buildCoordinatorSystemPrompt(agentConfigs), + maxTurns: 3, + } + + const decompositionPrompt = this.buildDecompositionPrompt(goal, agentConfigs) + const coordinatorAgent = buildAgent(coordinatorConfig) + + this.config.onProgress?.({ + type: 'agent_start', + agent: 'coordinator', + data: { phase: 'decomposition', goal }, + }) + + const decompositionResult = await coordinatorAgent.run(decompositionPrompt) + const agentResults = new Map() + agentResults.set('coordinator:decompose', decompositionResult) + + // ------------------------------------------------------------------ + // Step 2: Parse tasks from coordinator output + // ------------------------------------------------------------------ + const taskSpecs = parseTaskSpecs(decompositionResult.output) + + const queue = new TaskQueue() + const scheduler = new Scheduler('dependency-first') + + if (taskSpecs && taskSpecs.length > 0) { + // Map title-based dependsOn references to real task IDs so we can + // build the dependency graph before adding tasks to the queue. + this.loadSpecsIntoQueue(taskSpecs, agentConfigs, queue) + } else { + // Coordinator failed to produce structured output — fall back to + // one task per agent using the goal as the description. + for (const agentConfig of agentConfigs) { + const task = createTask({ + title: `${agentConfig.name}: ${goal.slice(0, 80)}`, + description: goal, + assignee: agentConfig.name, + }) + queue.add(task) + } + } + + // ------------------------------------------------------------------ + // Step 3: Auto-assign any unassigned tasks + // ------------------------------------------------------------------ + scheduler.autoAssign(queue, agentConfigs) + + // ------------------------------------------------------------------ + // Step 4: Build pool and execute + // ------------------------------------------------------------------ + const pool = this.buildPool(agentConfigs) + const ctx: RunContext = { + team, + pool, + scheduler, + agentResults, + config: this.config, + } + + await executeQueue(queue, ctx) + + // ------------------------------------------------------------------ + // Step 5: Coordinator synthesises final result + // ------------------------------------------------------------------ + const synthesisPrompt = await this.buildSynthesisPrompt(goal, queue.list(), team) + const synthesisResult = await coordinatorAgent.run(synthesisPrompt) + agentResults.set('coordinator', synthesisResult) + + this.config.onProgress?.({ + type: 'agent_complete', + agent: 'coordinator', + data: synthesisResult, + }) + + // Note: coordinator decompose and synthesis are internal meta-steps. + // Only actual user tasks (non-coordinator keys) are counted in + // buildTeamRunResult, so we do not increment completedTaskCount here. + + return this.buildTeamRunResult(agentResults) + } + + // ------------------------------------------------------------------------- + // Explicit-task team run + // ------------------------------------------------------------------------- + + /** + * Run a team with an explicitly provided task list. + * + * Simpler than {@link runTeam}: no coordinator agent is involved. Tasks are + * loaded directly into the queue, unassigned tasks are auto-assigned via the + * {@link Scheduler}, and execution proceeds in dependency order. + * + * @param team - A team created via {@link createTeam}. + * @param tasks - Array of task descriptors. + */ + async runTasks( + team: Team, + tasks: ReadonlyArray<{ + title: string + description: string + assignee?: string + dependsOn?: string[] + }>, + ): Promise { + const agentConfigs = team.getAgents() + const queue = new TaskQueue() + const scheduler = new Scheduler('dependency-first') + + this.loadSpecsIntoQueue( + tasks.map((t) => ({ + title: t.title, + description: t.description, + assignee: t.assignee, + dependsOn: t.dependsOn, + })), + agentConfigs, + queue, + ) + + scheduler.autoAssign(queue, agentConfigs) + + const pool = this.buildPool(agentConfigs) + const agentResults = new Map() + const ctx: RunContext = { + team, + pool, + scheduler, + agentResults, + config: this.config, + } + + await executeQueue(queue, ctx) + + return this.buildTeamRunResult(agentResults) + } + + // ------------------------------------------------------------------------- + // Observability + // ------------------------------------------------------------------------- + + /** + * Returns a lightweight status snapshot. + * + * - `teams` — Number of teams registered with this orchestrator. + * - `activeAgents` — Total agents currently in `running` state. + * - `completedTasks` — Cumulative count of successfully completed tasks + * (coordinator meta-steps excluded). + */ + getStatus(): { teams: number; activeAgents: number; completedTasks: number } { + return { + teams: this.teams.size, + activeAgents: 0, // Pools are ephemeral per-run; no cross-run state to inspect. + completedTasks: this.completedTaskCount, + } + } + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + /** + * Deregister all teams and reset internal counters. + * + * Does not cancel in-flight runs. Call this when you want to reuse the + * orchestrator instance for a fresh set of teams. + * + * Async for forward compatibility — shutdown may need to perform async + * cleanup (e.g. graceful agent drain) in future versions. + */ + async shutdown(): Promise { + this.teams.clear() + this.completedTaskCount = 0 + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** Build the system prompt given to the coordinator agent. */ + private buildCoordinatorSystemPrompt(agents: AgentConfig[]): string { + const roster = agents + .map( + (a) => + `- **${a.name}** (${a.model}): ${a.systemPrompt?.slice(0, 120) ?? 'general purpose agent'}`, + ) + .join('\n') + + return [ + 'You are a task coordinator responsible for decomposing high-level goals', + 'into concrete, actionable tasks and assigning them to the right team members.', + '', + '## Team Roster', + roster, + '', + '## Output Format', + 'When asked to decompose a goal, respond ONLY with a JSON array of task objects.', + 'Each task must have:', + ' - "title": Short descriptive title (string)', + ' - "description": Full task description with context and expected output (string)', + ' - "assignee": One of the agent names listed in the roster (string)', + ' - "dependsOn": Array of titles of tasks this task depends on (string[], may be empty)', + '', + 'Wrap the JSON in a ```json code fence.', + 'Do not include any text outside the code fence.', + '', + '## When synthesising results', + 'You will be given completed task outputs and asked to synthesise a final answer.', + 'Write a clear, comprehensive response that addresses the original goal.', + ].join('\n') + } + + /** Build the decomposition prompt for the coordinator. */ + private buildDecompositionPrompt(goal: string, agents: AgentConfig[]): string { + const names = agents.map((a) => a.name).join(', ') + return [ + `Decompose the following goal into tasks for your team (${names}).`, + '', + `## Goal`, + goal, + '', + 'Return ONLY the JSON task array in a ```json code fence.', + ].join('\n') + } + + /** Build the synthesis prompt shown to the coordinator after all tasks complete. */ + private async buildSynthesisPrompt( + goal: string, + tasks: Task[], + team: Team, + ): Promise { + const completedTasks = tasks.filter((t) => t.status === 'completed') + const failedTasks = tasks.filter((t) => t.status === 'failed') + + const resultSections = completedTasks.map((t) => { + const assignee = t.assignee ?? 'unknown' + return `### ${t.title} (completed by ${assignee})\n${t.result ?? '(no output)'}` + }) + + const failureSections = failedTasks.map( + (t) => `### ${t.title} (FAILED)\nError: ${t.result ?? 'unknown error'}`, + ) + + // Also include shared memory summary for additional context + let memorySummary = '' + const sharedMem = team.getSharedMemoryInstance() + if (sharedMem) { + memorySummary = await sharedMem.getSummary() + } + + return [ + `## Original Goal`, + goal, + '', + `## Task Results`, + ...resultSections, + ...(failureSections.length > 0 ? ['', '## Failed Tasks', ...failureSections] : []), + ...(memorySummary ? ['', memorySummary] : []), + '', + '## Your Task', + 'Synthesise the above results into a comprehensive final answer that addresses the original goal.', + 'If some tasks failed, note any gaps in the result.', + ].join('\n') + } + + /** + * Load a list of task specs into a queue. + * + * Handles title-based `dependsOn` references by building a title→id map first, + * then resolving them to real IDs before adding tasks to the queue. + */ + private loadSpecsIntoQueue( + specs: ReadonlyArray, + agentConfigs: AgentConfig[], + queue: TaskQueue, + ): void { + const agentNames = new Set(agentConfigs.map((a) => a.name)) + + // First pass: create tasks (without dependencies) to get stable IDs. + const titleToId = new Map() + const createdTasks: Task[] = [] + + for (const spec of specs) { + const task = createTask({ + title: spec.title, + description: spec.description, + assignee: spec.assignee && agentNames.has(spec.assignee) + ? spec.assignee + : undefined, + }) + titleToId.set(spec.title.toLowerCase().trim(), task.id) + createdTasks.push(task) + } + + // Second pass: resolve title-based dependsOn to IDs. + for (let i = 0; i < createdTasks.length; i++) { + const spec = specs[i]! + const task = createdTasks[i]! + + if (!spec.dependsOn || spec.dependsOn.length === 0) { + queue.add(task) + continue + } + + const resolvedDeps: string[] = [] + for (const depRef of spec.dependsOn) { + // Accept both raw IDs and title strings + const byId = createdTasks.find((t) => t.id === depRef) + const byTitle = titleToId.get(depRef.toLowerCase().trim()) + const resolvedId = byId?.id ?? byTitle + if (resolvedId) { + resolvedDeps.push(resolvedId) + } + } + + const taskWithDeps: Task = { + ...task, + dependsOn: resolvedDeps.length > 0 ? resolvedDeps : undefined, + } + queue.add(taskWithDeps) + } + } + + /** Build an {@link AgentPool} from a list of agent configurations. */ + private buildPool(agentConfigs: AgentConfig[]): AgentPool { + const pool = new AgentPool(this.config.maxConcurrency) + for (const config of agentConfigs) { + const effective: AgentConfig = { + ...config, + model: config.model, + provider: config.provider ?? this.config.defaultProvider, + } + pool.add(buildAgent(effective)) + } + return pool + } + + /** + * Aggregate the per-run `agentResults` map into a {@link TeamRunResult}. + * + * Merges results keyed as `agentName:taskId` back into a per-agent map + * by agent name for the public result surface. + * + * Only non-coordinator entries are counted toward `completedTaskCount` to + * avoid double-counting the coordinator's internal decompose/synthesis steps. + */ + private buildTeamRunResult( + agentResults: Map, + ): TeamRunResult { + let totalUsage: TokenUsage = ZERO_USAGE + let overallSuccess = true + const collapsed = new Map() + + for (const [key, result] of agentResults) { + // Strip the `:taskId` suffix to get the agent name + const agentName = key.includes(':') ? key.split(':')[0]! : key + + totalUsage = addUsage(totalUsage, result.tokenUsage) + if (!result.success) overallSuccess = false + + const existing = collapsed.get(agentName) + if (!existing) { + collapsed.set(agentName, result) + } else { + // Merge multiple results for the same agent (multi-task case) + collapsed.set(agentName, { + success: existing.success && result.success, + output: [existing.output, result.output].filter(Boolean).join('\n\n---\n\n'), + messages: [...existing.messages, ...result.messages], + tokenUsage: addUsage(existing.tokenUsage, result.tokenUsage), + toolCalls: [...existing.toolCalls, ...result.toolCalls], + }) + } + + // Only count actual user tasks — skip coordinator meta-entries + // (keys that start with 'coordinator') to avoid double-counting. + if (result.success && !key.startsWith('coordinator')) { + this.completedTaskCount++ + } + } + + return { + success: overallSuccess, + agentResults: collapsed, + totalTokenUsage: totalUsage, + } + } +} diff --git a/src/orchestrator/scheduler.ts b/src/orchestrator/scheduler.ts new file mode 100644 index 0000000..93d64ac --- /dev/null +++ b/src/orchestrator/scheduler.ts @@ -0,0 +1,352 @@ +/** + * @fileoverview Task scheduling strategies for the open-multi-agent orchestrator. + * + * The {@link Scheduler} class encapsulates four distinct strategies for + * mapping a set of pending {@link Task}s onto a pool of available agents: + * + * - `round-robin` — Distribute tasks evenly across agents by index. + * - `least-busy` — Assign to whichever agent has the fewest active tasks. + * - `capability-match` — Score agents by keyword overlap with the task description. + * - `dependency-first` — Prioritise tasks on the critical path (most blocked dependents). + * + * The scheduler is stateless between calls. All mutable task state lives in the + * {@link TaskQueue} that is passed to {@link Scheduler.autoAssign}. + */ + +import type { AgentConfig, Task } from '../types.js' +import type { TaskQueue } from '../task/queue.js' + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +/** + * The four scheduling strategies available to the {@link Scheduler}. + * + * - `round-robin` — Equal distribution by agent index. + * - `least-busy` — Prefers the agent with the fewest `in_progress` tasks. + * - `capability-match` — Keyword-based affinity between task text and agent role. + * - `dependency-first` — Prioritise tasks that unblock the most other tasks. + */ +export type SchedulingStrategy = + | 'round-robin' + | 'least-busy' + | 'capability-match' + | 'dependency-first' + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Count how many tasks in `allTasks` are (transitively) blocked waiting for + * `taskId` to complete. Used by the `dependency-first` strategy to compute + * the "criticality" of each pending task. + * + * The algorithm is a forward BFS over the dependency graph: for each task + * whose `dependsOn` includes `taskId`, we add it to the result set and + * recurse — without revisiting nodes. + */ +function countBlockedDependents(taskId: string, allTasks: Task[]): number { + const idToTask = new Map(allTasks.map((t) => [t.id, t])) + // Build reverse adjacency: dependencyId -> tasks that depend on it + const dependents = new Map() + for (const t of allTasks) { + for (const depId of t.dependsOn ?? []) { + const list = dependents.get(depId) ?? [] + list.push(t.id) + dependents.set(depId, list) + } + } + + const visited = new Set() + const queue: string[] = [taskId] + while (queue.length > 0) { + const current = queue.shift()! + for (const depId of dependents.get(current) ?? []) { + if (!visited.has(depId) && idToTask.has(depId)) { + visited.add(depId) + queue.push(depId) + } + } + } + // Exclude the seed task itself from the count + return visited.size +} + +/** + * Compute a simple keyword-overlap score between `text` and `keywords`. + * + * Both the text and keywords are normalised to lower-case before comparison. + * Each keyword that appears in the text contributes +1 to the score. + */ +function keywordScore(text: string, keywords: string[]): number { + const lower = text.toLowerCase() + return keywords.reduce((acc, kw) => acc + (lower.includes(kw.toLowerCase()) ? 1 : 0), 0) +} + +/** + * Extract a list of meaningful keywords from a string for capability matching. + * + * Strips common stop-words so that incidental matches (e.g. "the", "and") do + * not inflate scores. Returns unique words longer than three characters. + */ +function extractKeywords(text: string): string[] { + const STOP_WORDS = new Set([ + 'the', 'and', 'for', 'that', 'this', 'with', 'are', 'from', 'have', + 'will', 'your', 'you', 'can', 'all', 'each', 'when', 'then', 'they', + 'them', 'their', 'about', 'into', 'more', 'also', 'should', 'must', + ]) + + return [...new Set( + text + .toLowerCase() + .split(/\W+/) + .filter((w) => w.length > 3 && !STOP_WORDS.has(w)), + )] +} + +// --------------------------------------------------------------------------- +// Scheduler +// --------------------------------------------------------------------------- + +/** + * Maps pending tasks to available agents using one of four configurable strategies. + * + * @example + * ```ts + * const scheduler = new Scheduler('capability-match') + * + * // Get a full assignment map from tasks to agent names + * const assignments = scheduler.schedule(pendingTasks, teamAgents) + * + * // Or let the scheduler directly update a TaskQueue + * scheduler.autoAssign(queue, teamAgents) + * ``` + */ +export class Scheduler { + /** Rolling cursor used by `round-robin` to distribute tasks sequentially. */ + private roundRobinCursor = 0 + + /** + * @param strategy - The scheduling algorithm to apply. Defaults to + * `'dependency-first'` which is the safest default for + * complex multi-step pipelines. + */ + constructor(private readonly strategy: SchedulingStrategy = 'dependency-first') {} + + // ------------------------------------------------------------------------- + // Primary API + // ------------------------------------------------------------------------- + + /** + * Given a list of pending `tasks` and `agents`, return a mapping from + * `taskId` to `agentName` representing the recommended assignment. + * + * Only tasks without an existing `assignee` are considered. Tasks that are + * already assigned are preserved unchanged. + * + * The method is deterministic for all strategies except `round-robin`, which + * advances an internal cursor and therefore produces different results across + * successive calls with the same inputs. + * + * @param tasks - Snapshot of all tasks in the current run (any status). + * @param agents - Available agent configurations. + * @returns A `Map` for every unassigned pending task. + */ + schedule(tasks: Task[], agents: AgentConfig[]): Map { + if (agents.length === 0) return new Map() + + const unassigned = tasks.filter( + (t) => t.status === 'pending' && !t.assignee, + ) + + switch (this.strategy) { + case 'round-robin': + return this.scheduleRoundRobin(unassigned, agents) + case 'least-busy': + return this.scheduleLeastBusy(unassigned, agents, tasks) + case 'capability-match': + return this.scheduleCapabilityMatch(unassigned, agents) + case 'dependency-first': + return this.scheduleDependencyFirst(unassigned, agents, tasks) + } + } + + /** + * Convenience method that applies assignments returned by {@link schedule} + * directly to a live `TaskQueue`. + * + * Iterates all pending, unassigned tasks in the queue and sets `assignee` for + * each according to the current strategy. Skips tasks that are already + * assigned, non-pending, or whose IDs are not found in the queue snapshot. + * + * @param queue - The live task queue to mutate. + * @param agents - Available agent configurations. + */ + autoAssign(queue: TaskQueue, agents: AgentConfig[]): void { + const allTasks = queue.list() + const assignments = this.schedule(allTasks, agents) + + for (const [taskId, agentName] of assignments) { + try { + queue.update(taskId, { assignee: agentName }) + } catch { + // Task may have been completed/failed between snapshot and now — skip. + } + } + } + + // ------------------------------------------------------------------------- + // Strategy implementations + // ------------------------------------------------------------------------- + + /** + * Round-robin: assign tasks to agents in order, cycling back to the start. + * + * The cursor advances with every call so that repeated calls with the same + * task set continue distributing work — rather than always starting from + * agent[0]. + */ + private scheduleRoundRobin( + unassigned: Task[], + agents: AgentConfig[], + ): Map { + const result = new Map() + for (const task of unassigned) { + const agent = agents[this.roundRobinCursor % agents.length]! + result.set(task.id, agent.name) + this.roundRobinCursor = (this.roundRobinCursor + 1) % agents.length + } + return result + } + + /** + * Least-busy: assign each task to the agent with the fewest `in_progress` + * tasks at the time the schedule is computed. + * + * Agent load is derived from the `in_progress` count in `allTasks`. Ties are + * broken by the agent's position in the `agents` array (earlier = preferred). + */ + private scheduleLeastBusy( + unassigned: Task[], + agents: AgentConfig[], + allTasks: Task[], + ): Map { + // Build initial in-progress count per agent. + const load = new Map(agents.map((a) => [a.name, 0])) + for (const task of allTasks) { + if (task.status === 'in_progress' && task.assignee) { + const current = load.get(task.assignee) ?? 0 + load.set(task.assignee, current + 1) + } + } + + const result = new Map() + for (const task of unassigned) { + // Pick the agent with the lowest current load. + let bestAgent = agents[0]! + let bestLoad = load.get(bestAgent.name) ?? 0 + + for (let i = 1; i < agents.length; i++) { + const agent = agents[i]! + const agentLoad = load.get(agent.name) ?? 0 + if (agentLoad < bestLoad) { + bestLoad = agentLoad + bestAgent = agent + } + } + + result.set(task.id, bestAgent.name) + // Increment the simulated load so subsequent tasks in this batch avoid + // piling onto the same agent. + load.set(bestAgent.name, (load.get(bestAgent.name) ?? 0) + 1) + } + + return result + } + + /** + * Capability-match: score each agent against each task by keyword overlap + * between the task's title/description and the agent's `systemPrompt` and + * `name`. The highest-scoring agent wins. + * + * Falls back to round-robin when no agent has any positive score. + */ + private scheduleCapabilityMatch( + unassigned: Task[], + agents: AgentConfig[], + ): Map { + const result = new Map() + + // Pre-compute keyword lists for each agent to avoid re-extracting per task. + const agentKeywords = new Map( + agents.map((a) => [ + a.name, + extractKeywords(`${a.name} ${a.systemPrompt ?? ''} ${a.model}`), + ]), + ) + + for (const task of unassigned) { + const taskText = `${task.title} ${task.description}` + const taskKeywords = extractKeywords(taskText) + + let bestAgent = agents[0]! + let bestScore = -1 + + for (const agent of agents) { + // Score in both directions: task keywords vs agent text, and agent + // keywords vs task text, then take the max. + const agentText = `${agent.name} ${agent.systemPrompt ?? ''}` + const scoreA = keywordScore(agentText, taskKeywords) + const scoreB = keywordScore(taskText, agentKeywords.get(agent.name) ?? []) + const score = scoreA + scoreB + + if (score > bestScore) { + bestScore = score + bestAgent = agent + } + } + + result.set(task.id, bestAgent.name) + } + + return result + } + + /** + * Dependency-first: prioritise tasks by how many other tasks are blocked + * waiting for them (the "critical path" heuristic). + * + * Tasks with more downstream dependents are assigned to agents first. Within + * the same criticality tier the agents are selected round-robin so no single + * agent is overloaded. + */ + private scheduleDependencyFirst( + unassigned: Task[], + agents: AgentConfig[], + allTasks: Task[], + ): Map { + // Sort by descending blocked-dependent count so high-criticality tasks + // get first choice of agents. + const ranked = [...unassigned].sort((a, b) => { + const critA = countBlockedDependents(a.id, allTasks) + const critB = countBlockedDependents(b.id, allTasks) + return critB - critA + }) + + const result = new Map() + let cursor = this.roundRobinCursor + + for (const task of ranked) { + const agent = agents[cursor % agents.length]! + result.set(task.id, agent.name) + cursor = (cursor + 1) % agents.length + } + + // Advance the shared cursor for consistency with round-robin. + this.roundRobinCursor = cursor + + return result + } +} diff --git a/src/task/queue.ts b/src/task/queue.ts new file mode 100644 index 0000000..60149ff --- /dev/null +++ b/src/task/queue.ts @@ -0,0 +1,394 @@ +/** + * @fileoverview Dependency-aware task queue. + * + * {@link TaskQueue} owns the mutable lifecycle of every task it holds. + * Completing a task automatically unblocks dependents and fires events so + * orchestrators can react without polling. + */ + +import type { Task, TaskStatus } from '../types.js' +import { isTaskReady } from './task.js' + +// --------------------------------------------------------------------------- +// Event types +// --------------------------------------------------------------------------- + +/** Named event types emitted by {@link TaskQueue}. */ +export type TaskQueueEvent = + | 'task:ready' + | 'task:complete' + | 'task:failed' + | 'all:complete' + +/** Handler for `'task:ready' | 'task:complete' | 'task:failed'` events. */ +type TaskHandler = (task: Task) => void +/** Handler for `'all:complete'` (no task argument). */ +type AllCompleteHandler = () => void + +type HandlerFor = E extends 'all:complete' + ? AllCompleteHandler + : TaskHandler + +// --------------------------------------------------------------------------- +// TaskQueue +// --------------------------------------------------------------------------- + +/** + * Mutable, event-driven queue with topological dependency resolution. + * + * Tasks enter in `'pending'` state. The queue promotes them to `'blocked'` + * when unresolved dependencies exist, and back to `'pending'` (firing + * `'task:ready'`) when those dependencies complete. Callers drive execution by + * calling {@link next} / {@link nextAvailable} and updating task state via + * {@link complete} or {@link fail}. + * + * @example + * ```ts + * const queue = new TaskQueue() + * queue.on('task:ready', (task) => scheduleExecution(task)) + * queue.on('all:complete', () => shutdown()) + * + * queue.addBatch(tasks) + * ``` + */ +export class TaskQueue { + private readonly tasks = new Map() + + /** Listeners keyed by event type, stored as symbol → handler pairs. */ + private readonly listeners = new Map< + TaskQueueEvent, + Map + >() + + // --------------------------------------------------------------------------- + // Mutation: add + // --------------------------------------------------------------------------- + + /** + * Adds a single task. + * + * If the task has unresolved dependencies it is immediately promoted to + * `'blocked'`; otherwise it stays `'pending'` and `'task:ready'` fires. + */ + add(task: Task): void { + const resolved = this.resolveInitialStatus(task) + this.tasks.set(resolved.id, resolved) + if (resolved.status === 'pending') { + this.emit('task:ready', resolved) + } + } + + /** + * Adds multiple tasks at once. + * + * Processing each task re-evaluates the current map state, so inserting a + * batch where some tasks satisfy others' dependencies produces correct initial + * statuses when the dependencies appear first in the array. Use + * {@link getTaskDependencyOrder} from `task.ts` to pre-sort if needed. + */ + addBatch(tasks: Task[]): void { + for (const task of tasks) { + this.add(task) + } + } + + // --------------------------------------------------------------------------- + // Mutation: update / complete / fail + // --------------------------------------------------------------------------- + + /** + * Applies a partial update to an existing task. + * + * Only `status`, `result`, and `assignee` are accepted to keep the update + * surface narrow. Use {@link complete} and {@link fail} for terminal states. + * + * @throws {Error} when `taskId` is not found. + */ + update( + taskId: string, + update: Partial>, + ): Task { + const task = this.requireTask(taskId) + const updated: Task = { + ...task, + ...update, + updatedAt: new Date(), + } + this.tasks.set(taskId, updated) + return updated + } + + /** + * Marks `taskId` as `'completed'`, records an optional `result` string, and + * unblocks any dependents that are now ready to run. + * + * Fires `'task:complete'`, then `'task:ready'` for each newly-unblocked task, + * then `'all:complete'` when the queue is fully resolved. + * + * @throws {Error} when `taskId` is not found. + */ + complete(taskId: string, result?: string): Task { + const completed = this.update(taskId, { status: 'completed', result }) + this.emit('task:complete', completed) + this.unblockDependents(taskId) + if (this.isComplete()) { + this.emitAllComplete() + } + return completed + } + + /** + * Marks `taskId` as `'failed'` and records `error` in the `result` field. + * + * Fires `'task:failed'` for the failed task and for every downstream task + * that transitively depended on it (cascade failure). This prevents blocked + * tasks from remaining stuck indefinitely when an upstream dependency fails. + * + * @throws {Error} when `taskId` is not found. + */ + fail(taskId: string, error: string): Task { + const failed = this.update(taskId, { status: 'failed', result: error }) + this.emit('task:failed', failed) + this.cascadeFailure(taskId) + if (this.isComplete()) { + this.emitAllComplete() + } + return failed + } + + /** + * Recursively marks all tasks that (transitively) depend on `failedTaskId` + * as `'failed'` with an informative message, firing `'task:failed'` for each. + * + * Only tasks in `'blocked'` or `'pending'` state are affected; tasks already + * in a terminal state are left untouched. + */ + private cascadeFailure(failedTaskId: string): void { + for (const task of this.tasks.values()) { + if (task.status !== 'blocked' && task.status !== 'pending') continue + if (!task.dependsOn?.includes(failedTaskId)) continue + + const cascaded = this.update(task.id, { + status: 'failed', + result: `Cancelled: dependency "${failedTaskId}" failed.`, + }) + this.emit('task:failed', cascaded) + // Recurse to handle transitive dependents. + this.cascadeFailure(task.id) + } + } + + // --------------------------------------------------------------------------- + // Queries + // --------------------------------------------------------------------------- + + /** + * Returns the next `'pending'` task for `assignee` (matched against + * `task.assignee`), or `undefined` if none exists. + * + * If `assignee` is omitted, behaves like {@link nextAvailable}. + */ + next(assignee?: string): Task | undefined { + if (assignee === undefined) return this.nextAvailable() + + for (const task of this.tasks.values()) { + if (task.status === 'pending' && task.assignee === assignee) { + return task + } + } + return undefined + } + + /** + * Returns the next `'pending'` task that has no `assignee` restriction, or + * the first `'pending'` task overall when all pending tasks have an assignee. + */ + nextAvailable(): Task | undefined { + let fallback: Task | undefined + + for (const task of this.tasks.values()) { + if (task.status !== 'pending') continue + if (!task.assignee) return task + if (!fallback) fallback = task + } + + return fallback + } + + /** Returns a snapshot array of all tasks (any status). */ + list(): Task[] { + return Array.from(this.tasks.values()) + } + + /** Returns all tasks whose `status` matches `status`. */ + getByStatus(status: TaskStatus): Task[] { + return this.list().filter((t) => t.status === status) + } + + /** + * Returns `true` when every task in the queue has reached a terminal state + * (`'completed'` or `'failed'`), **or** the queue is empty. + */ + isComplete(): boolean { + for (const task of this.tasks.values()) { + if (task.status !== 'completed' && task.status !== 'failed') return false + } + return true + } + + /** + * Returns a progress snapshot. + * + * @example + * ```ts + * const { completed, total } = queue.getProgress() + * console.log(`${completed}/${total} tasks done`) + * ``` + */ + getProgress(): { + total: number + completed: number + failed: number + inProgress: number + pending: number + blocked: number + } { + let completed = 0 + let failed = 0 + let inProgress = 0 + let pending = 0 + let blocked = 0 + + for (const task of this.tasks.values()) { + switch (task.status) { + case 'completed': + completed++ + break + case 'failed': + failed++ + break + case 'in_progress': + inProgress++ + break + case 'pending': + pending++ + break + case 'blocked': + blocked++ + break + } + } + + return { + total: this.tasks.size, + completed, + failed, + inProgress, + pending, + blocked, + } + } + + // --------------------------------------------------------------------------- + // Events + // --------------------------------------------------------------------------- + + /** + * Subscribes to a queue event. + * + * @returns An unsubscribe function. Calling it is idempotent. + * + * @example + * ```ts + * const off = queue.on('task:ready', (task) => execute(task)) + * // later… + * off() + * ``` + */ + on( + event: E, + handler: HandlerFor, + ): () => void { + let map = this.listeners.get(event) + if (!map) { + map = new Map() + this.listeners.set(event, map) + } + const id = Symbol() + map.set(id, handler as TaskHandler | AllCompleteHandler) + return () => { + map!.delete(id) + } + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + /** + * Evaluates whether `task` should start as `'blocked'` based on the tasks + * already registered in the queue. + */ + private resolveInitialStatus(task: Task): Task { + if (!task.dependsOn || task.dependsOn.length === 0) return task + + const allCurrent = Array.from(this.tasks.values()) + const ready = isTaskReady(task, allCurrent) + if (ready) return task + + return { ...task, status: 'blocked', updatedAt: new Date() } + } + + /** + * After a task completes, scan all `'blocked'` tasks and promote any that are + * now fully satisfied to `'pending'`, firing `'task:ready'` for each. + * + * The task array and lookup map are built once for the entire scan to keep + * the operation O(n) rather than O(n²). + */ + private unblockDependents(completedId: string): void { + const allTasks = Array.from(this.tasks.values()) + const taskById = new Map(allTasks.map((t) => [t.id, t])) + + for (const task of allTasks) { + if (task.status !== 'blocked') continue + if (!task.dependsOn?.includes(completedId)) continue + + // Re-check against the current state of the whole task set. + // Pass the pre-built map to avoid rebuilding it for every candidate task. + if (isTaskReady(task, allTasks, taskById)) { + const unblocked: Task = { + ...task, + status: 'pending', + updatedAt: new Date(), + } + this.tasks.set(task.id, unblocked) + // Update the map so subsequent iterations in the same call see the new status. + taskById.set(task.id, unblocked) + this.emit('task:ready', unblocked) + } + } + } + + private emit(event: 'task:ready' | 'task:complete' | 'task:failed', task: Task): void { + const map = this.listeners.get(event) + if (!map) return + for (const handler of map.values()) { + ;(handler as TaskHandler)(task) + } + } + + private emitAllComplete(): void { + const map = this.listeners.get('all:complete') + if (!map) return + for (const handler of map.values()) { + ;(handler as AllCompleteHandler)() + } + } + + private requireTask(taskId: string): Task { + const task = this.tasks.get(taskId) + if (!task) throw new Error(`TaskQueue: task "${taskId}" not found.`) + return task + } +} diff --git a/src/task/task.ts b/src/task/task.ts new file mode 100644 index 0000000..a297100 --- /dev/null +++ b/src/task/task.ts @@ -0,0 +1,232 @@ +/** + * @fileoverview Pure task utility functions. + * + * These helpers operate on plain {@link Task} values without any mutable + * state, making them safe to use in reducers, tests, and reactive pipelines. + * Stateful orchestration belongs in {@link TaskQueue}. + */ + +import type { Task, TaskStatus } from '../types.js' + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Creates a new {@link Task} with a generated UUID, `'pending'` status, and + * `createdAt`/`updatedAt` timestamps set to the current instant. + * + * @example + * ```ts + * const task = createTask({ + * title: 'Research competitors', + * description: 'Identify the top 5 competitors and their pricing', + * assignee: 'researcher', + * }) + * ``` + */ +export function createTask(input: { + title: string + description: string + assignee?: string + dependsOn?: string[] +}): Task { + const now = new Date() + return { + id: crypto.randomUUID(), + title: input.title, + description: input.description, + status: 'pending' as TaskStatus, + assignee: input.assignee, + dependsOn: input.dependsOn ? [...input.dependsOn] : undefined, + result: undefined, + createdAt: now, + updatedAt: now, + } +} + +// --------------------------------------------------------------------------- +// Readiness +// --------------------------------------------------------------------------- + +/** + * Returns `true` when `task` can be started immediately. + * + * A task is considered ready when: + * 1. Its status is `'pending'`. + * 2. Every task listed in `task.dependsOn` has status `'completed'`. + * + * Tasks whose dependencies are missing from `allTasks` are treated as + * unresolvable and therefore **not** ready. + * + * @param task - The task to evaluate. + * @param allTasks - The full collection of tasks in the current queue/plan. + * @param taskById - Optional pre-built id→task map. When provided the function + * skips rebuilding the map, reducing the complexity of + * call-sites that invoke `isTaskReady` inside a loop from + * O(n²) to O(n). + */ +export function isTaskReady( + task: Task, + allTasks: Task[], + taskById?: Map, +): boolean { + if (task.status !== 'pending') return false + if (!task.dependsOn || task.dependsOn.length === 0) return true + + const map = taskById ?? new Map(allTasks.map((t) => [t.id, t])) + + for (const depId of task.dependsOn) { + const dep = map.get(depId) + if (!dep || dep.status !== 'completed') return false + } + + return true +} + +// --------------------------------------------------------------------------- +// Topological sort +// --------------------------------------------------------------------------- + +/** + * Returns `tasks` sorted so that each task appears after all of its + * dependencies — a standard topological (Kahn's algorithm) ordering. + * + * Tasks with no dependencies come first. If the graph contains a cycle the + * function returns a partial result containing only the tasks that could be + * ordered; use {@link validateTaskDependencies} to detect cycles before calling + * this function in production paths. + * + * @example + * ```ts + * const ordered = getTaskDependencyOrder(tasks) + * for (const task of ordered) { + * await run(task) + * } + * ``` + */ +export function getTaskDependencyOrder(tasks: Task[]): Task[] { + if (tasks.length === 0) return [] + + const taskById = new Map(tasks.map((t) => [t.id, t])) + + // Build adjacency: dependsOn edges become "predecessors" for in-degree count. + const inDegree = new Map() + // successors[id] = list of task IDs that depend on `id` + const successors = new Map() + + for (const task of tasks) { + if (!inDegree.has(task.id)) inDegree.set(task.id, 0) + if (!successors.has(task.id)) successors.set(task.id, []) + + for (const depId of task.dependsOn ?? []) { + // Only count dependencies that exist in this task set. + if (taskById.has(depId)) { + inDegree.set(task.id, (inDegree.get(task.id) ?? 0) + 1) + const deps = successors.get(depId) ?? [] + deps.push(task.id) + successors.set(depId, deps) + } + } + } + + // Kahn's algorithm: start with all nodes of in-degree 0. + const queue: string[] = [] + for (const [id, degree] of inDegree) { + if (degree === 0) queue.push(id) + } + + const ordered: Task[] = [] + while (queue.length > 0) { + const id = queue.shift()! + const task = taskById.get(id) + if (task) ordered.push(task) + + for (const successorId of successors.get(id) ?? []) { + const newDegree = (inDegree.get(successorId) ?? 0) - 1 + inDegree.set(successorId, newDegree) + if (newDegree === 0) queue.push(successorId) + } + } + + return ordered +} + +// --------------------------------------------------------------------------- +// Validation +// --------------------------------------------------------------------------- + +/** + * Validates the dependency graph of a task collection. + * + * Checks for: + * - References to unknown task IDs in `dependsOn`. + * - Cycles (a task depending on itself, directly or transitively). + * - Self-dependencies (`task.dependsOn` includes its own `id`). + * + * @returns An object with `valid: true` when no issues were found, or + * `valid: false` with a non-empty `errors` array describing each + * problem. + * + * @example + * ```ts + * const { valid, errors } = validateTaskDependencies(tasks) + * if (!valid) throw new Error(errors.join('\n')) + * ``` + */ +export function validateTaskDependencies(tasks: Task[]): { + valid: boolean + errors: string[] +} { + const errors: string[] = [] + const taskById = new Map(tasks.map((t) => [t.id, t])) + + // Pass 1: check for unknown references and self-dependencies. + for (const task of tasks) { + for (const depId of task.dependsOn ?? []) { + if (depId === task.id) { + errors.push( + `Task "${task.title}" (${task.id}) depends on itself.`, + ) + continue + } + if (!taskById.has(depId)) { + errors.push( + `Task "${task.title}" (${task.id}) references unknown dependency "${depId}".`, + ) + } + } + } + + // Pass 2: cycle detection via DFS colouring (white=0, grey=1, black=2). + const colour = new Map() + for (const task of tasks) colour.set(task.id, 0) + + const visit = (id: string, path: string[]): void => { + if (colour.get(id) === 2) return // Already fully explored. + if (colour.get(id) === 1) { + // Found a back-edge — cycle. + const cycleStart = path.indexOf(id) + const cycle = path.slice(cycleStart).concat(id) + errors.push(`Cyclic dependency detected: ${cycle.join(' -> ')}`) + return + } + + colour.set(id, 1) + const task = taskById.get(id) + for (const depId of task?.dependsOn ?? []) { + if (taskById.has(depId)) { + visit(depId, [...path, id]) + } + } + colour.set(id, 2) + } + + for (const task of tasks) { + if (colour.get(task.id) === 0) { + visit(task.id, []) + } + } + + return { valid: errors.length === 0, errors } +} diff --git a/src/team/messaging.ts b/src/team/messaging.ts new file mode 100644 index 0000000..de4cdae --- /dev/null +++ b/src/team/messaging.ts @@ -0,0 +1,230 @@ +/** + * @fileoverview Inter-agent message bus. + * + * Provides a lightweight pub/sub system so agents can exchange typed messages + * without direct references to each other. All messages are retained in memory + * for replay and audit; read-state is tracked per recipient. + */ + +// --------------------------------------------------------------------------- +// Message type +// --------------------------------------------------------------------------- + +/** A single message exchanged between agents (or broadcast to all). */ +export interface Message { + /** Stable UUID for this message. */ + readonly id: string + /** Name of the sending agent. */ + readonly from: string + /** + * Recipient agent name, or `'*'` when the message is a broadcast intended + * for every agent except the sender. + */ + readonly to: string + readonly content: string + readonly timestamp: Date +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** Returns true when `message` is addressed to `agentName`. */ +function isAddressedTo(message: Message, agentName: string): boolean { + if (message.to === '*') { + // Broadcasts are delivered to everyone except the sender. + return message.from !== agentName + } + return message.to === agentName +} + +// --------------------------------------------------------------------------- +// MessageBus +// --------------------------------------------------------------------------- + +/** + * In-memory message bus for inter-agent communication. + * + * Agents can send point-to-point messages or broadcasts. Subscribers are + * notified synchronously (within the same microtask) when a new message + * arrives addressed to them. + * + * @example + * ```ts + * const bus = new MessageBus() + * + * const unsubscribe = bus.subscribe('worker', (msg) => { + * console.log(`worker received: ${msg.content}`) + * }) + * + * bus.send('coordinator', 'worker', 'Start task A') + * bus.broadcast('coordinator', 'All agents: stand by') + * + * unsubscribe() + * ``` + */ +export class MessageBus { + /** All messages ever sent, in insertion order. */ + private readonly messages: Message[] = [] + + /** + * Per-agent set of message IDs that have already been marked as read. + * A message absent from this set is considered unread. + */ + private readonly readState = new Map>() + + /** + * Active subscribers keyed by agent name. Each subscriber is a callback + * paired with a unique subscription ID used for unsubscription. + */ + private readonly subscribers = new Map< + string, + Map void> + >() + + // --------------------------------------------------------------------------- + // Write operations + // --------------------------------------------------------------------------- + + /** + * Send a message from `from` to `to`. + * + * @returns The persisted {@link Message} including its generated ID and timestamp. + */ + send(from: string, to: string, content: string): Message { + const message: Message = { + id: crypto.randomUUID(), + from, + to, + content, + timestamp: new Date(), + } + this.persist(message) + return message + } + + /** + * Broadcast a message from `from` to all other agents (`to === '*'`). + * + * @returns The persisted broadcast {@link Message}. + */ + broadcast(from: string, content: string): Message { + return this.send(from, '*', content) + } + + // --------------------------------------------------------------------------- + // Read operations + // --------------------------------------------------------------------------- + + /** + * Returns messages that have not yet been marked as read by `agentName`, + * including both direct messages and broadcasts addressed to them. + */ + getUnread(agentName: string): Message[] { + const read = this.readState.get(agentName) ?? new Set() + return this.messages.filter( + (m) => isAddressedTo(m, agentName) && !read.has(m.id), + ) + } + + /** + * Returns every message (read or unread) addressed to `agentName`, + * preserving insertion order. + */ + getAll(agentName: string): Message[] { + return this.messages.filter((m) => isAddressedTo(m, agentName)) + } + + /** + * Mark a set of messages as read for `agentName`. + * Passing IDs that were already marked, or do not exist, is a no-op. + */ + markRead(agentName: string, messageIds: string[]): void { + if (messageIds.length === 0) return + let read = this.readState.get(agentName) + if (!read) { + read = new Set() + this.readState.set(agentName, read) + } + for (const id of messageIds) { + read.add(id) + } + } + + /** + * Returns all messages exchanged between `agent1` and `agent2` (in either + * direction), sorted chronologically. + */ + getConversation(agent1: string, agent2: string): Message[] { + return this.messages.filter( + (m) => + (m.from === agent1 && m.to === agent2) || + (m.from === agent2 && m.to === agent1), + ) + } + + // --------------------------------------------------------------------------- + // Subscriptions + // --------------------------------------------------------------------------- + + /** + * Subscribe to new messages addressed to `agentName`. + * + * The `callback` is invoked synchronously after each matching message is + * persisted. Returns an unsubscribe function; calling it is idempotent. + * + * @example + * ```ts + * const off = bus.subscribe('agent-b', (msg) => handleMessage(msg)) + * // Later… + * off() + * ``` + */ + subscribe( + agentName: string, + callback: (message: Message) => void, + ): () => void { + let agentSubs = this.subscribers.get(agentName) + if (!agentSubs) { + agentSubs = new Map() + this.subscribers.set(agentName, agentSubs) + } + const id = Symbol() + agentSubs.set(id, callback) + return () => { + agentSubs!.delete(id) + } + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private persist(message: Message): void { + this.messages.push(message) + this.notifySubscribers(message) + } + + private notifySubscribers(message: Message): void { + // Notify direct subscribers of `message.to` (unless broadcast). + if (message.to !== '*') { + this.fireCallbacks(message.to, message) + return + } + + // Broadcast: notify all subscribers except the sender. + for (const [agentName, subs] of this.subscribers) { + if (agentName !== message.from && subs.size > 0) { + this.fireCallbacks(agentName, message) + } + } + } + + private fireCallbacks(agentName: string, message: Message): void { + const subs = this.subscribers.get(agentName) + if (!subs) return + for (const callback of subs.values()) { + callback(message) + } + } +} diff --git a/src/team/team.ts b/src/team/team.ts new file mode 100644 index 0000000..c88148b --- /dev/null +++ b/src/team/team.ts @@ -0,0 +1,334 @@ +/** + * @fileoverview Team — the central coordination object for a named group of agents. + * + * A {@link Team} owns the agent roster, the inter-agent {@link MessageBus}, + * the {@link TaskQueue}, and (optionally) a {@link SharedMemory} instance. + * It also exposes a typed event bus so orchestrators can react to lifecycle + * events without polling. + */ + +import type { + AgentConfig, + MemoryStore, + OrchestratorEvent, + Task, + TaskStatus, + TeamConfig, +} from '../types.js' +import { SharedMemory } from '../memory/shared.js' +import { MessageBus } from './messaging.js' +import type { Message } from './messaging.js' +import { TaskQueue } from '../task/queue.js' +import { createTask } from '../task/task.js' + +export type { Message } + +// --------------------------------------------------------------------------- +// Internal event bus +// --------------------------------------------------------------------------- + +type EventHandler = (data: unknown) => void + +/** Minimal synchronous event emitter. */ +class EventBus { + private readonly listeners = new Map>() + + on(event: string, handler: EventHandler): () => void { + let map = this.listeners.get(event) + if (!map) { + map = new Map() + this.listeners.set(event, map) + } + const id = Symbol() + map.set(id, handler) + return () => { + map!.delete(id) + } + } + + emit(event: string, data: unknown): void { + const map = this.listeners.get(event) + if (!map) return + for (const handler of map.values()) { + handler(data) + } + } +} + +// --------------------------------------------------------------------------- +// Team +// --------------------------------------------------------------------------- + +/** + * Coordinates a named group of agents with shared messaging, task queuing, + * and optional shared memory. + * + * @example + * ```ts + * const team = new Team({ + * name: 'research-team', + * agents: [researcherConfig, writerConfig], + * sharedMemory: true, + * maxConcurrency: 2, + * }) + * + * team.on('task:complete', (data) => { + * const event = data as OrchestratorEvent + * console.log(`Task done: ${event.task}`) + * }) + * + * const task = team.addTask({ + * title: 'Research topic', + * description: 'Gather background on quantum computing', + * status: 'pending', + * assignee: 'researcher', + * }) + * ``` + */ +export class Team { + readonly name: string + readonly config: TeamConfig + + private readonly agentMap: ReadonlyMap + private readonly bus: MessageBus + private readonly queue: TaskQueue + private readonly memory: SharedMemory | undefined + private readonly events: EventBus + + constructor(config: TeamConfig) { + this.config = config + this.name = config.name + + // Index agents by name for O(1) lookup. + this.agentMap = new Map(config.agents.map((a) => [a.name, a])) + this.bus = new MessageBus() + this.queue = new TaskQueue() + this.memory = config.sharedMemory ? new SharedMemory() : undefined + this.events = new EventBus() + + // Bridge queue events onto the team's event bus. + this.queue.on('task:ready', (task) => { + const event: OrchestratorEvent = { + type: 'task_start', + task: task.id, + data: task, + } + this.events.emit('task:ready', event) + }) + + this.queue.on('task:complete', (task) => { + const event: OrchestratorEvent = { + type: 'task_complete', + task: task.id, + data: task, + } + this.events.emit('task:complete', event) + }) + + this.queue.on('task:failed', (task) => { + const event: OrchestratorEvent = { + type: 'error', + task: task.id, + data: task, + } + this.events.emit('task:failed', event) + }) + + this.queue.on('all:complete', () => { + this.events.emit('all:complete', undefined) + }) + } + + // --------------------------------------------------------------------------- + // Agent roster + // --------------------------------------------------------------------------- + + /** Returns a shallow copy of the agent configs in registration order. */ + getAgents(): AgentConfig[] { + return Array.from(this.agentMap.values()) + } + + /** + * Looks up an agent by name. + * + * @returns The {@link AgentConfig} or `undefined` when the name is not known. + */ + getAgent(name: string): AgentConfig | undefined { + return this.agentMap.get(name) + } + + // --------------------------------------------------------------------------- + // Messaging + // --------------------------------------------------------------------------- + + /** + * Sends a point-to-point message from `from` to `to`. + * + * The message is persisted on the bus and any active subscribers for `to` + * are notified synchronously. + */ + sendMessage(from: string, to: string, content: string): void { + const message = this.bus.send(from, to, content) + const event: OrchestratorEvent = { + type: 'message', + agent: from, + data: message, + } + this.events.emit('message', event) + } + + /** + * Returns all messages (read or unread) addressed to `agentName`, in + * chronological order. + */ + getMessages(agentName: string): Message[] { + return this.bus.getAll(agentName) + } + + /** + * Broadcasts `content` from `from` to every other agent. + * + * The `to` field of the resulting message is `'*'`. + */ + broadcast(from: string, content: string): void { + const message = this.bus.broadcast(from, content) + const event: OrchestratorEvent = { + type: 'message', + agent: from, + data: message, + } + this.events.emit('broadcast', event) + } + + // --------------------------------------------------------------------------- + // Task management + // --------------------------------------------------------------------------- + + /** + * Creates a new task, adds it to the queue, and returns the persisted + * {@link Task} (with generated `id`, `createdAt`, and `updatedAt`). + * + * @param task - Everything except the generated fields. + */ + addTask( + task: Omit, + ): Task { + const created = createTask({ + title: task.title, + description: task.description, + assignee: task.assignee, + dependsOn: task.dependsOn ? [...task.dependsOn] : undefined, + }) + + // Preserve any non-default status (e.g. 'blocked') supplied by the caller. + const finalTask: Task = + task.status !== 'pending' + ? { ...created, status: task.status as TaskStatus, result: task.result } + : created + + this.queue.add(finalTask) + return finalTask + } + + /** Returns a snapshot of all tasks in the queue (any status). */ + getTasks(): Task[] { + return this.queue.list() + } + + /** Returns all tasks whose `assignee` is `agentName`. */ + getTasksByAssignee(agentName: string): Task[] { + return this.queue.list().filter((t) => t.assignee === agentName) + } + + /** + * Applies a partial update to the task identified by `taskId`. + * + * @throws {Error} when the task is not found. + */ + updateTask(taskId: string, update: Partial): Task { + // Extract only mutable fields accepted by the queue. + const { status, result, assignee } = update + return this.queue.update(taskId, { + ...(status !== undefined && { status }), + ...(result !== undefined && { result }), + ...(assignee !== undefined && { assignee }), + }) + } + + /** + * Returns the next `'pending'` task for `agentName`, respecting dependencies. + * + * Tries to find a task explicitly assigned to the agent first; falls back to + * the first unassigned pending task. + * + * @returns `undefined` when no ready task exists for this agent. + */ + getNextTask(agentName: string): Task | undefined { + // Prefer a task explicitly assigned to this agent. + const assigned = this.queue.next(agentName) + if (assigned) return assigned + + // Fall back to any unassigned pending task. + return this.queue.nextAvailable() + } + + // --------------------------------------------------------------------------- + // Memory + // --------------------------------------------------------------------------- + + /** + * Returns the shared {@link MemoryStore} for this team, or `undefined` if + * `sharedMemory` was not enabled in {@link TeamConfig}. + * + * Note: the returned value satisfies the {@link MemoryStore} interface. + * Callers that need the full {@link SharedMemory} API can use the + * `as SharedMemory` cast, but depending on the concrete type is discouraged. + */ + getSharedMemory(): MemoryStore | undefined { + return this.memory?.getStore() + } + + /** + * Returns the raw {@link SharedMemory} instance (team-internal accessor). + * Use this when you need the namespacing / `getSummary` features. + * + * @internal + */ + getSharedMemoryInstance(): SharedMemory | undefined { + return this.memory + } + + // --------------------------------------------------------------------------- + // Events + // --------------------------------------------------------------------------- + + /** + * Subscribes to a team event. + * + * Built-in events: + * - `'task:ready'` — emitted when a task becomes runnable. + * - `'task:complete'` — emitted when a task completes successfully. + * - `'task:failed'` — emitted when a task fails. + * - `'all:complete'` — emitted when every task in the queue has terminated. + * - `'message'` — emitted on point-to-point messages. + * - `'broadcast'` — emitted on broadcast messages. + * + * `data` is typed as `unknown`; cast to {@link OrchestratorEvent} for + * structured access. + * + * @returns An unsubscribe function. + */ + on(event: string, handler: (data: unknown) => void): () => void { + return this.events.on(event, handler) + } + + /** + * Emits a custom event on the team's event bus. + * + * Orchestrators can use this to signal domain-specific lifecycle milestones + * (e.g. `'phase:research:complete'`) without modifying the Team class. + */ + emit(event: string, data: unknown): void { + this.events.emit(event, data) + } +} diff --git a/src/tool/built-in/bash.ts b/src/tool/built-in/bash.ts new file mode 100644 index 0000000..af83b03 --- /dev/null +++ b/src/tool/built-in/bash.ts @@ -0,0 +1,187 @@ +/** + * Built-in bash tool. + * + * Executes a shell command and returns its stdout + stderr. Supports an + * optional timeout and a custom working directory. + */ + +import { spawn } from 'child_process' +import { z } from 'zod' +import { defineTool } from '../framework.js' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_TIMEOUT_MS = 30_000 + +// --------------------------------------------------------------------------- +// Tool definition +// --------------------------------------------------------------------------- + +export const bashTool = defineTool({ + name: 'bash', + description: + 'Execute a bash command and return its stdout and stderr. ' + + 'Use this for file system operations, running scripts, installing packages, ' + + 'and any task that requires shell access. ' + + 'The command runs in a non-interactive shell (bash -c). ' + + 'Long-running commands should use the timeout parameter.', + + inputSchema: z.object({ + command: z.string().describe('The bash command to execute.'), + timeout: z + .number() + .optional() + .describe( + `Timeout in milliseconds before the command is forcibly killed. ` + + `Defaults to ${DEFAULT_TIMEOUT_MS} ms.`, + ), + cwd: z + .string() + .optional() + .describe('Working directory in which to run the command.'), + }), + + execute: async (input, context) => { + const timeoutMs = input.timeout ?? DEFAULT_TIMEOUT_MS + + const { stdout, stderr, exitCode } = await runCommand( + input.command, + { cwd: input.cwd, timeoutMs }, + context.abortSignal, + ) + + const combined = buildOutput(stdout, stderr, exitCode) + const isError = exitCode !== 0 + + return { + data: combined, + isError, + } + }, +}) + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +interface RunResult { + stdout: string + stderr: string + exitCode: number +} + +interface RunOptions { + cwd: string | undefined + timeoutMs: number +} + +/** + * Spawn a bash subprocess, capture its output, and resolve when it exits or + * the abort signal fires. + */ +function runCommand( + command: string, + options: RunOptions, + signal: AbortSignal | undefined, +): Promise { + return new Promise((resolve) => { + const stdoutChunks: Buffer[] = [] + const stderrChunks: Buffer[] = [] + + const child = spawn('bash', ['-c', command], { + cwd: options.cwd, + env: process.env, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + child.stdout.on('data', (chunk: Buffer) => stdoutChunks.push(chunk)) + child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk)) + + let timedOut = false + let settled = false + + const done = (exitCode: number): void => { + if (settled) return + settled = true + clearTimeout(timer) + if (signal !== undefined) { + signal.removeEventListener('abort', onAbort) + } + + const stdout = Buffer.concat(stdoutChunks).toString('utf8') + const stderr = Buffer.concat(stderrChunks).toString('utf8') + + resolve({ stdout, stderr, exitCode }) + } + + // Timeout handler + const timer = setTimeout(() => { + timedOut = true + child.kill('SIGKILL') + }, options.timeoutMs) + + // Abort-signal handler + const onAbort = (): void => { + child.kill('SIGKILL') + } + + if (signal !== undefined) { + signal.addEventListener('abort', onAbort, { once: true }) + } + + child.on('close', (code: number | null) => { + const exitCode = code ?? (timedOut ? 124 : 1) + done(exitCode) + }) + + child.on('error', (err: Error) => { + if (!settled) { + settled = true + clearTimeout(timer) + if (signal !== undefined) { + signal.removeEventListener('abort', onAbort) + } + resolve({ + stdout: '', + stderr: err.message, + exitCode: 127, + }) + } + }) + }) +} + +/** + * Format captured output into a single readable string. + * When only stdout is present its content is returned as-is. + * When stderr is also present both sections are labelled. + */ +function buildOutput(stdout: string, stderr: string, exitCode: number): string { + const parts: string[] = [] + + if (stdout.length > 0) { + parts.push(stdout) + } + + if (stderr.length > 0) { + parts.push( + stdout.length > 0 + ? `--- stderr ---\n${stderr}` + : stderr, + ) + } + + if (parts.length === 0) { + return exitCode === 0 + ? '(command completed with no output)' + : `(command exited with code ${exitCode}, no output)` + } + + if (exitCode !== 0 && parts.length > 0) { + parts.push(`\n(exit code: ${exitCode})`) + } + + return parts.join('\n') +} diff --git a/src/tool/built-in/file-edit.ts b/src/tool/built-in/file-edit.ts new file mode 100644 index 0000000..54a9784 --- /dev/null +++ b/src/tool/built-in/file-edit.ts @@ -0,0 +1,154 @@ +/** + * Built-in file-edit tool. + * + * Performs a targeted string replacement inside an existing file. + * The uniqueness invariant (one match unless replace_all is set) prevents the + * common class of bugs where a generic pattern matches the wrong occurrence. + */ + +import { readFile, writeFile } from 'fs/promises' +import { z } from 'zod' +import { defineTool } from '../framework.js' + +// --------------------------------------------------------------------------- +// Tool definition +// --------------------------------------------------------------------------- + +export const fileEditTool = defineTool({ + name: 'file_edit', + description: + 'Edit a file by replacing a specific string with new content. ' + + 'The `old_string` must appear verbatim in the file. ' + + 'By default the tool errors if `old_string` appears more than once — ' + + 'use `replace_all: true` to replace every occurrence. ' + + 'Use file_write when you need to create a new file or rewrite it entirely.', + + inputSchema: z.object({ + path: z + .string() + .describe('Absolute path to the file to edit.'), + old_string: z + .string() + .describe( + 'The exact string to find and replace. ' + + 'Must match character-for-character including whitespace and newlines.', + ), + new_string: z + .string() + .describe('The replacement string that will be inserted in place of `old_string`.'), + replace_all: z + .boolean() + .optional() + .describe( + 'When true, replace every occurrence of `old_string` instead of requiring it ' + + 'to be unique. Defaults to false.', + ), + }), + + execute: async (input) => { + // Read the existing file. + let original: string + try { + const buffer = await readFile(input.path) + original = buffer.toString('utf8') + } catch (err) { + const message = + err instanceof Error ? err.message : 'Unknown error reading file.' + return { + data: `Could not read "${input.path}": ${message}`, + isError: true, + } + } + + const occurrences = countOccurrences(original, input.old_string) + + if (occurrences === 0) { + return { + data: + `The string to replace was not found in "${input.path}".\n` + + 'Make sure `old_string` matches the file contents exactly, ' + + 'including indentation and line endings.', + isError: true, + } + } + + const replaceAll = input.replace_all ?? false + + if (occurrences > 1 && !replaceAll) { + return { + data: + `\`old_string\` appears ${occurrences} times in "${input.path}". ` + + 'Provide a more specific string to uniquely identify the section you want ' + + 'to replace, or set `replace_all: true` to replace every occurrence.', + isError: true, + } + } + + // Perform the replacement. + const updated = replaceAll + ? replaceAllOccurrences(original, input.old_string, input.new_string) + : original.replace(input.old_string, input.new_string) + + // Persist the result. + try { + await writeFile(input.path, updated, 'utf8') + } catch (err) { + const message = + err instanceof Error ? err.message : 'Unknown error writing file.' + return { + data: `Failed to write "${input.path}": ${message}`, + isError: true, + } + } + + const replacedCount = replaceAll ? occurrences : 1 + return { + data: + `Replaced ${replacedCount} occurrence${replacedCount === 1 ? '' : 's'} ` + + `in "${input.path}".`, + isError: false, + } + }, +}) + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/** + * Count how many times `needle` appears in `haystack`. + * Uses a plain loop to avoid constructing a potentially large regex from + * untrusted input. + */ +function countOccurrences(haystack: string, needle: string): number { + if (needle.length === 0) return 0 + let count = 0 + let pos = 0 + while ((pos = haystack.indexOf(needle, pos)) !== -1) { + count++ + pos += needle.length + } + return count +} + +/** + * Replace all occurrences of `needle` in `haystack` with `replacement` + * without using a regex (avoids regex-special-character escaping issues). + */ +function replaceAllOccurrences( + haystack: string, + needle: string, + replacement: string, +): string { + if (needle.length === 0) return haystack + const parts: string[] = [] + let pos = 0 + let next: number + while ((next = haystack.indexOf(needle, pos)) !== -1) { + parts.push(haystack.slice(pos, next)) + parts.push(replacement) + pos = next + needle.length + } + parts.push(haystack.slice(pos)) + return parts.join('') +} diff --git a/src/tool/built-in/file-read.ts b/src/tool/built-in/file-read.ts new file mode 100644 index 0000000..e251cf9 --- /dev/null +++ b/src/tool/built-in/file-read.ts @@ -0,0 +1,105 @@ +/** + * Built-in file-read tool. + * + * Reads a file from disk and returns its contents with 1-based line numbers. + * Supports reading a slice of lines via `offset` and `limit` for large files. + */ + +import { readFile } from 'fs/promises' +import { z } from 'zod' +import { defineTool } from '../framework.js' + +// --------------------------------------------------------------------------- +// Tool definition +// --------------------------------------------------------------------------- + +export const fileReadTool = defineTool({ + name: 'file_read', + description: + 'Read the contents of a file from disk. ' + + 'Returns the file contents with line numbers prefixed in the format "N\\t". ' + + 'Use `offset` and `limit` to read large files in chunks without loading the ' + + 'entire file into the context window.', + + inputSchema: z.object({ + path: z.string().describe('Absolute path to the file to read.'), + offset: z + .number() + .int() + .nonnegative() + .optional() + .describe( + '1-based line number to start reading from. ' + + 'When omitted the file is read from the beginning.', + ), + limit: z + .number() + .int() + .positive() + .optional() + .describe( + 'Maximum number of lines to return. ' + + 'When omitted all lines from `offset` to the end of the file are returned.', + ), + }), + + execute: async (input) => { + let raw: string + try { + const buffer = await readFile(input.path) + raw = buffer.toString('utf8') + } catch (err) { + const message = + err instanceof Error ? err.message : 'Unknown error reading file.' + return { + data: `Could not read file "${input.path}": ${message}`, + isError: true, + } + } + + // Split preserving trailing newlines correctly + const lines = raw.split('\n') + + // Remove the last empty string produced by a trailing newline + if (lines.length > 0 && lines[lines.length - 1] === '') { + lines.pop() + } + + const totalLines = lines.length + + // Apply offset (convert from 1-based to 0-based) + const startIndex = + input.offset !== undefined ? Math.max(0, input.offset - 1) : 0 + + if (startIndex >= totalLines && totalLines > 0) { + return { + data: + `File "${input.path}" has ${totalLines} line${totalLines === 1 ? '' : 's'} ` + + `but offset ${input.offset} is beyond the end.`, + isError: true, + } + } + + const endIndex = + input.limit !== undefined + ? Math.min(startIndex + input.limit, totalLines) + : totalLines + + const slice = lines.slice(startIndex, endIndex) + + // Build line-numbered output (1-based line numbers matching file positions) + const numbered = slice + .map((line, i) => `${startIndex + i + 1}\t${line}`) + .join('\n') + + const meta = + endIndex < totalLines + ? `\n\n(showing lines ${startIndex + 1}–${endIndex} of ${totalLines})` + : '' + + return { + data: numbered + meta, + isError: false, + } + }, +}) diff --git a/src/tool/built-in/file-write.ts b/src/tool/built-in/file-write.ts new file mode 100644 index 0000000..eb412f4 --- /dev/null +++ b/src/tool/built-in/file-write.ts @@ -0,0 +1,81 @@ +/** + * Built-in file-write tool. + * + * Creates or overwrites a file with the supplied content. Parent directories + * are created automatically (equivalent to `mkdir -p`). + */ + +import { mkdir, stat, writeFile } from 'fs/promises' +import { dirname } from 'path' +import { z } from 'zod' +import { defineTool } from '../framework.js' + +// --------------------------------------------------------------------------- +// Tool definition +// --------------------------------------------------------------------------- + +export const fileWriteTool = defineTool({ + name: 'file_write', + description: + 'Write content to a file, creating it (and any missing parent directories) if it ' + + 'does not already exist, or overwriting it if it does. ' + + 'Prefer this tool for creating new files; use file_edit for targeted in-place edits ' + + 'of existing files.', + + inputSchema: z.object({ + path: z + .string() + .describe( + 'Absolute path to the file to write. ' + + 'The path must be absolute (starting with /).', + ), + content: z.string().describe('The full content to write to the file.'), + }), + + execute: async (input) => { + // Determine whether the file already exists so we can report create vs update. + let existed = false + try { + await stat(input.path) + existed = true + } catch { + // File does not exist — will be created. + } + + // Ensure parent directory hierarchy exists. + const parentDir = dirname(input.path) + try { + await mkdir(parentDir, { recursive: true }) + } catch (err) { + const message = + err instanceof Error ? err.message : 'Unknown error creating directories.' + return { + data: `Failed to create parent directory "${parentDir}": ${message}`, + isError: true, + } + } + + // Write the file. + try { + await writeFile(input.path, input.content, 'utf8') + } catch (err) { + const message = + err instanceof Error ? err.message : 'Unknown error writing file.' + return { + data: `Failed to write file "${input.path}": ${message}`, + isError: true, + } + } + + const lineCount = input.content.split('\n').length + const byteCount = Buffer.byteLength(input.content, 'utf8') + const action = existed ? 'Updated' : 'Created' + + return { + data: + `${action} "${input.path}" ` + + `(${lineCount} line${lineCount === 1 ? '' : 's'}, ${byteCount} bytes).`, + isError: false, + } + }, +}) diff --git a/src/tool/built-in/grep.ts b/src/tool/built-in/grep.ts new file mode 100644 index 0000000..99bceb8 --- /dev/null +++ b/src/tool/built-in/grep.ts @@ -0,0 +1,362 @@ +/** + * Built-in grep tool. + * + * Searches for a regex pattern in files. Prefers the `rg` (ripgrep) binary + * when available for performance; falls back to a pure Node.js recursive + * implementation using the standard `fs` module so the tool works in + * environments without ripgrep installed. + */ + +import { spawn } from 'child_process' +import { readdir, readFile, stat } from 'fs/promises' +// Note: readdir is used with { encoding: 'utf8' } to return string[] directly. +import { join, relative } from 'path' +import { z } from 'zod' +import type { ToolResult } from '../../types.js' +import { defineTool } from '../framework.js' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const DEFAULT_MAX_RESULTS = 100 +// Directories that are almost never useful to search inside +const SKIP_DIRS = new Set([ + '.git', + '.svn', + '.hg', + 'node_modules', + '.next', + 'dist', + 'build', +]) + +// --------------------------------------------------------------------------- +// Tool definition +// --------------------------------------------------------------------------- + +export const grepTool = defineTool({ + name: 'grep', + description: + 'Search for a regular-expression pattern in one or more files. ' + + 'Returns matching lines with their file paths and 1-based line numbers. ' + + 'Use the `glob` parameter to restrict the search to specific file types ' + + '(e.g. "*.ts"). ' + + 'Results are capped by `maxResults` to keep the response manageable.', + + inputSchema: z.object({ + pattern: z + .string() + .describe('Regular expression pattern to search for in file contents.'), + path: z + .string() + .optional() + .describe( + 'Directory or file path to search in. ' + + 'Defaults to the current working directory.', + ), + glob: z + .string() + .optional() + .describe( + 'Glob pattern to filter which files are searched ' + + '(e.g. "*.ts", "**/*.json"). ' + + 'Only used when `path` is a directory.', + ), + maxResults: z + .number() + .int() + .positive() + .optional() + .describe( + `Maximum number of matching lines to return. ` + + `Defaults to ${DEFAULT_MAX_RESULTS}.`, + ), + }), + + execute: async (input, context) => { + const searchPath = input.path ?? process.cwd() + const maxResults = input.maxResults ?? DEFAULT_MAX_RESULTS + + // Compile the regex once and surface bad patterns immediately. + let regex: RegExp + try { + regex = new RegExp(input.pattern) + } catch { + return { + data: `Invalid regular expression: "${input.pattern}"`, + isError: true, + } + } + + // Attempt ripgrep first. + const rgAvailable = await isRipgrepAvailable() + if (rgAvailable) { + return runRipgrep(input.pattern, searchPath, { + glob: input.glob, + maxResults, + signal: context.abortSignal, + }) + } + + // Fallback: pure Node.js recursive search. + return runNodeSearch(regex, searchPath, { + glob: input.glob, + maxResults, + signal: context.abortSignal, + }) + }, +}) + +// --------------------------------------------------------------------------- +// ripgrep path +// --------------------------------------------------------------------------- + +interface SearchOptions { + glob?: string + maxResults: number + signal: AbortSignal | undefined +} + +async function runRipgrep( + pattern: string, + searchPath: string, + options: SearchOptions, +): Promise { + const args = [ + '--line-number', + '--no-heading', + '--color=never', + `--max-count=${options.maxResults}`, + ] + if (options.glob !== undefined) { + args.push('--glob', options.glob) + } + args.push('--', pattern, searchPath) + + return new Promise((resolve) => { + const chunks: Buffer[] = [] + const errChunks: Buffer[] = [] + + const child = spawn('rg', args, { stdio: ['ignore', 'pipe', 'pipe'] }) + + child.stdout.on('data', (d: Buffer) => chunks.push(d)) + child.stderr.on('data', (d: Buffer) => errChunks.push(d)) + + const onAbort = (): void => { child.kill('SIGKILL') } + if (options.signal !== undefined) { + options.signal.addEventListener('abort', onAbort, { once: true }) + } + + child.on('close', (code: number | null) => { + if (options.signal !== undefined) { + options.signal.removeEventListener('abort', onAbort) + } + const output = Buffer.concat(chunks).toString('utf8').trimEnd() + + // rg exit code 1 = no matches (not an error) + if (code !== 0 && code !== 1) { + const errMsg = Buffer.concat(errChunks).toString('utf8').trim() + resolve({ + data: `ripgrep failed (exit ${code}): ${errMsg}`, + isError: true, + }) + return + } + + if (output.length === 0) { + resolve({ data: 'No matches found.', isError: false }) + return + } + + const lines = output.split('\n') + resolve({ + data: lines.join('\n'), + isError: false, + }) + }) + + child.on('error', () => { + if (options.signal !== undefined) { + options.signal.removeEventListener('abort', onAbort) + } + // Caller will see an error result — the tool won't retry with Node search + // since this branch is only reachable after we confirmed rg is available. + resolve({ + data: 'ripgrep process error — run may be retried with the Node.js fallback.', + isError: true, + }) + }) + }) +} + +// --------------------------------------------------------------------------- +// Node.js fallback search +// --------------------------------------------------------------------------- + +interface MatchLine { + file: string + lineNumber: number + text: string +} + +async function runNodeSearch( + regex: RegExp, + searchPath: string, + options: SearchOptions, +): Promise { + // Collect files + let files: string[] + try { + const info = await stat(searchPath) + if (info.isFile()) { + files = [searchPath] + } else { + files = await collectFiles(searchPath, options.glob, options.signal) + } + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error' + return { + data: `Cannot access path "${searchPath}": ${message}`, + isError: true, + } + } + + const matches: MatchLine[] = [] + + for (const file of files) { + if (options.signal?.aborted === true) break + if (matches.length >= options.maxResults) break + + let fileContent: string + try { + fileContent = (await readFile(file)).toString('utf8') + } catch { + // Skip unreadable files (binary, permission denied, etc.) + continue + } + + const lines = fileContent.split('\n') + for (let i = 0; i < lines.length; i++) { + if (matches.length >= options.maxResults) break + // Reset lastIndex for global regexes + regex.lastIndex = 0 + if (regex.test(lines[i])) { + matches.push({ + file: relative(process.cwd(), file) || file, + lineNumber: i + 1, + text: lines[i], + }) + } + } + } + + if (matches.length === 0) { + return { data: 'No matches found.', isError: false } + } + + const formatted = matches + .map((m) => `${m.file}:${m.lineNumber}:${m.text}`) + .join('\n') + + const truncationNote = + matches.length >= options.maxResults + ? `\n\n(results capped at ${options.maxResults}; use maxResults to raise the limit)` + : '' + + return { + data: formatted + truncationNote, + isError: false, + } +} + +// --------------------------------------------------------------------------- +// File collection with glob filtering +// --------------------------------------------------------------------------- + +/** + * Recursively walk `dir` and return file paths, honouring `SKIP_DIRS` and an + * optional glob pattern. + */ +async function collectFiles( + dir: string, + glob: string | undefined, + signal: AbortSignal | undefined, +): Promise { + const results: string[] = [] + await walk(dir, glob, results, signal) + return results +} + +async function walk( + dir: string, + glob: string | undefined, + results: string[], + signal: AbortSignal | undefined, +): Promise { + if (signal?.aborted === true) return + + let entryNames: string[] + try { + // Read as plain strings so we don't have to deal with Buffer Dirent variants. + entryNames = await readdir(dir, { encoding: 'utf8' }) + } catch { + return + } + + for (const entryName of entryNames) { + if (signal !== undefined && signal.aborted) return + + const fullPath = join(dir, entryName) + + let entryInfo: Awaited> + try { + entryInfo = await stat(fullPath) + } catch { + continue + } + + if (entryInfo.isDirectory()) { + if (!SKIP_DIRS.has(entryName)) { + await walk(fullPath, glob, results, signal) + } + } else if (entryInfo.isFile()) { + if (glob === undefined || matchesGlob(entryName, glob)) { + results.push(fullPath) + } + } + } +} + +/** + * Minimal glob match supporting `*.ext` and `**\/` forms. + */ +function matchesGlob(filename: string, glob: string): boolean { + // Strip leading **/ prefix — we already recurse into all directories + const pattern = glob.startsWith('**/') ? glob.slice(3) : glob + // Convert shell glob characters to regex equivalents + const regexSource = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape special regex chars first + .replace(/\*/g, '.*') // * -> .* + .replace(/\?/g, '.') // ? -> . + const re = new RegExp(`^${regexSource}$`, 'i') + return re.test(filename) +} + +// --------------------------------------------------------------------------- +// ripgrep availability check (cached per process) +// --------------------------------------------------------------------------- + +let rgAvailableCache: boolean | undefined + +async function isRipgrepAvailable(): Promise { + if (rgAvailableCache !== undefined) return rgAvailableCache + + rgAvailableCache = await new Promise((resolve) => { + const child = spawn('rg', ['--version'], { stdio: 'ignore' }) + child.on('close', (code) => resolve(code === 0)) + child.on('error', () => resolve(false)) + }) + + return rgAvailableCache +} diff --git a/src/tool/built-in/index.ts b/src/tool/built-in/index.ts new file mode 100644 index 0000000..06ff764 --- /dev/null +++ b/src/tool/built-in/index.ts @@ -0,0 +1,50 @@ +/** + * Built-in tool collection. + * + * Re-exports every built-in tool and provides a convenience function to + * register them all with a {@link ToolRegistry} in one call. + */ + +import type { ToolDefinition } from '../../types.js' +import { ToolRegistry } from '../framework.js' +import { bashTool } from './bash.js' +import { fileEditTool } from './file-edit.js' +import { fileReadTool } from './file-read.js' +import { fileWriteTool } from './file-write.js' +import { grepTool } from './grep.js' + +export { bashTool, fileEditTool, fileReadTool, fileWriteTool, grepTool } + +/** + * The ordered list of all built-in tools. Import this when you need to + * iterate over them without calling `registerBuiltInTools`. + * + * The array is typed as `ToolDefinition[]` so it can be passed to + * APIs that accept any ToolDefinition without requiring a union type. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const BUILT_IN_TOOLS: ToolDefinition[] = [ + bashTool, + fileReadTool, + fileWriteTool, + fileEditTool, + grepTool, +] + +/** + * Register all built-in tools with the given registry. + * + * @example + * ```ts + * import { ToolRegistry } from '../framework.js' + * import { registerBuiltInTools } from './built-in/index.js' + * + * const registry = new ToolRegistry() + * registerBuiltInTools(registry) + * ``` + */ +export function registerBuiltInTools(registry: ToolRegistry): void { + for (const tool of BUILT_IN_TOOLS) { + registry.register(tool) + } +} diff --git a/src/tool/executor.ts b/src/tool/executor.ts new file mode 100644 index 0000000..fed725b --- /dev/null +++ b/src/tool/executor.ts @@ -0,0 +1,178 @@ +/** + * Parallel tool executor with concurrency control and error isolation. + * + * Validates input via Zod schemas, enforces a maximum concurrency limit using + * a lightweight semaphore, tracks execution duration, and surfaces any + * execution errors as ToolResult objects rather than thrown exceptions. + * + * Types are imported from `../types` to ensure consistency with the rest of + * the framework. + */ + +import type { ToolResult, ToolUseContext } from '../types.js' +import type { ToolDefinition } from '../types.js' +import { ToolRegistry } from './framework.js' +import { Semaphore } from '../utils/semaphore.js' + +// --------------------------------------------------------------------------- +// ToolExecutor +// --------------------------------------------------------------------------- + +export interface ToolExecutorOptions { + /** + * Maximum number of tool calls that may run in parallel. + * Defaults to 4. + */ + maxConcurrency?: number +} + +/** Describes one call in a batch. */ +export interface BatchToolCall { + /** Caller-assigned ID used as the key in the result map. */ + id: string + /** Registered tool name. */ + name: string + /** Raw (unparsed) input object from the LLM. */ + input: Record +} + +/** + * Executes tools from a {@link ToolRegistry}, validating input against each + * tool's Zod schema and enforcing a concurrency limit for batch execution. + * + * All errors — including unknown tool names, Zod validation failures, and + * execution exceptions — are caught and returned as `ToolResult` objects with + * `isError: true` so the agent runner can forward them to the LLM. + */ +export class ToolExecutor { + private readonly registry: ToolRegistry + private readonly semaphore: Semaphore + + constructor(registry: ToolRegistry, options: ToolExecutorOptions = {}) { + this.registry = registry + this.semaphore = new Semaphore(options.maxConcurrency ?? 4) + } + + // ------------------------------------------------------------------------- + // Single execution + // ------------------------------------------------------------------------- + + /** + * Execute a single tool by name. + * + * Errors are caught and returned as a {@link ToolResult} with + * `isError: true` — this method itself never rejects. + * + * @param toolName The registered tool name. + * @param input Raw input object (before Zod validation). + * @param context Execution context forwarded to the tool. + */ + async execute( + toolName: string, + input: Record, + context: ToolUseContext, + ): Promise { + const tool = this.registry.get(toolName) + if (tool === undefined) { + return this.errorResult( + `Tool "${toolName}" is not registered in the ToolRegistry.`, + ) + } + + // Check abort before even starting + if (context.abortSignal?.aborted === true) { + return this.errorResult( + `Tool "${toolName}" was aborted before execution began.`, + ) + } + + return this.runTool(tool, input, context) + } + + // ------------------------------------------------------------------------- + // Batch execution + // ------------------------------------------------------------------------- + + /** + * Execute multiple tool calls in parallel, honouring the concurrency limit. + * + * Returns a `Map` from call ID to result. Every call in `calls` is + * guaranteed to produce an entry — errors are captured as results. + * + * @param calls Array of tool calls to execute. + * @param context Shared execution context for all calls in this batch. + */ + async executeBatch( + calls: BatchToolCall[], + context: ToolUseContext, + ): Promise> { + const results = new Map() + + await Promise.all( + calls.map(async (call) => { + const result = await this.semaphore.run(() => + this.execute(call.name, call.input, context), + ) + results.set(call.id, result) + }), + ) + + return results + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Validate input with the tool's Zod schema, then call `execute`. + * Any synchronous or asynchronous error is caught and turned into an error + * ToolResult. + */ + private async runTool( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tool: ToolDefinition, + rawInput: Record, + context: ToolUseContext, + ): Promise { + // --- Zod validation --- + const parseResult = tool.inputSchema.safeParse(rawInput) + if (!parseResult.success) { + const issues = parseResult.error.issues + .map((issue) => ` • ${issue.path.join('.')}: ${issue.message}`) + .join('\n') + return this.errorResult( + `Invalid input for tool "${tool.name}":\n${issues}`, + ) + } + + // --- Abort check after parse (parse can be expensive for large inputs) --- + if (context.abortSignal?.aborted === true) { + return this.errorResult( + `Tool "${tool.name}" was aborted before execution began.`, + ) + } + + // --- Execute --- + try { + const result = await tool.execute(parseResult.data, context) + return result + } catch (err) { + const message = + err instanceof Error + ? err.message + : typeof err === 'string' + ? err + : JSON.stringify(err) + return this.errorResult(`Tool "${tool.name}" threw an error: ${message}`) + } + } + + /** Construct an error ToolResult. */ + private errorResult(message: string): ToolResult { + return { + data: message, + isError: true, + } + } +} diff --git a/src/tool/framework.ts b/src/tool/framework.ts new file mode 100644 index 0000000..6b6a574 --- /dev/null +++ b/src/tool/framework.ts @@ -0,0 +1,557 @@ +/** + * Tool definition framework for open-multi-agent. + * + * Provides the core primitives for declaring, registering, and converting + * tools to the JSON Schema format that LLM APIs expect. + * + * Types shared with the rest of the framework (`ToolDefinition`, `ToolResult`, + * `ToolUseContext`) are imported from `../types` to ensure a single source of + * truth. This file re-exports them for the convenience of downstream callers + * who only need to import from `tool/framework`. + */ + +import { type ZodSchema } from 'zod' +import type { + ToolDefinition, + ToolResult, + ToolUseContext, + LLMToolDef, +} from '../types.js' + +// Re-export so consumers can `import { ToolDefinition } from './framework.js'` +export type { ToolDefinition, ToolResult, ToolUseContext } + +// --------------------------------------------------------------------------- +// LLM-facing JSON Schema types +// --------------------------------------------------------------------------- + +/** Minimal JSON Schema for a single property. */ +export type JSONSchemaProperty = + | { type: 'string'; description?: string; enum?: string[] } + | { type: 'number'; description?: string } + | { type: 'integer'; description?: string } + | { type: 'boolean'; description?: string } + | { type: 'null'; description?: string } + | { type: 'array'; items: JSONSchemaProperty; description?: string } + | { + type: 'object' + properties: Record + required?: string[] + description?: string + } + | { anyOf: JSONSchemaProperty[]; description?: string } + | { const: unknown; description?: string } + // Fallback for types we don't explicitly model + | Record + +// --------------------------------------------------------------------------- +// defineTool +// --------------------------------------------------------------------------- + +/** + * Define a typed tool. This is the single entry-point for creating tools + * that can be registered with a {@link ToolRegistry}. + * + * The returned object satisfies the {@link ToolDefinition} interface imported + * from `../types`. + * + * @example + * ```ts + * const echoTool = defineTool({ + * name: 'echo', + * description: 'Echo the input message back to the caller.', + * inputSchema: z.object({ message: z.string() }), + * execute: async ({ message }) => ({ + * data: message, + * isError: false, + * }), + * }) + * ``` + */ +export function defineTool(config: { + name: string + description: string + inputSchema: ZodSchema + execute: (input: TInput, context: ToolUseContext) => Promise +}): ToolDefinition { + return { + name: config.name, + description: config.description, + inputSchema: config.inputSchema, + execute: config.execute, + } +} + +// --------------------------------------------------------------------------- +// ToolRegistry +// --------------------------------------------------------------------------- + +/** + * Registry that holds a set of named tools and can produce the JSON Schema + * representation expected by LLM APIs (Anthropic, OpenAI, etc.). + */ +export class ToolRegistry { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly tools = new Map>() + + /** + * Add a tool to the registry. Throws if a tool with the same name has + * already been registered — prevents silent overwrites. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + register(tool: ToolDefinition): void { + if (this.tools.has(tool.name)) { + throw new Error( + `ToolRegistry: a tool named "${tool.name}" is already registered. ` + + 'Use a unique name or deregister the existing one first.', + ) + } + this.tools.set(tool.name, tool) + } + + /** Return a tool by name, or `undefined` if not found. */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + get(name: string): ToolDefinition | undefined { + return this.tools.get(name) + } + + /** + * Return all registered tool definitions as an array. + * + * Callers that only need names can do `registry.list().map(t => t.name)`. + * This matches the agent's `getTools()` pattern. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + list(): ToolDefinition[] { + return Array.from(this.tools.values()) + } + + /** + * Return all registered tool definitions as an array. + * Alias for {@link list} — available for callers that prefer explicit naming. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getAll(): ToolDefinition[] { + return Array.from(this.tools.values()) + } + + /** Return true when a tool with the given name is registered. */ + has(name: string): boolean { + return this.tools.has(name) + } + + /** + * Remove a tool by name. + * No-op if the tool was not registered — matches the agent's expected + * behaviour where `removeTool` is a graceful operation. + */ + unregister(name: string): void { + this.tools.delete(name) + } + + /** Alias for {@link unregister} — available for symmetry with `register`. */ + deregister(name: string): void { + this.tools.delete(name) + } + + /** + * Convert all registered tools to the {@link LLMToolDef} format used by LLM + * adapters. This is the primary method called by the agent runner before + * each LLM API call. + */ + toToolDefs(): LLMToolDef[] { + return Array.from(this.tools.values()).map((tool) => { + const schema = zodToJsonSchema(tool.inputSchema) + return { + name: tool.name, + description: tool.description, + inputSchema: schema, + } satisfies LLMToolDef + }) + } + + /** + * Convert all registered tools to the Anthropic-style `input_schema` + * format. Prefer {@link toToolDefs} for normal use; this method is exposed + * for callers that construct their own API payloads. + */ + toLLMTools(): Array<{ + name: string + description: string + input_schema: { + type: 'object' + properties: Record + required?: string[] + } + }> { + return Array.from(this.tools.values()).map((tool) => { + const schema = zodToJsonSchema(tool.inputSchema) + return { + name: tool.name, + description: tool.description, + input_schema: { + type: 'object' as const, + properties: + (schema.properties as Record) ?? {}, + ...(schema.required !== undefined + ? { required: schema.required as string[] } + : {}), + }, + } + }) + } +} + +// --------------------------------------------------------------------------- +// zodToJsonSchema +// --------------------------------------------------------------------------- + +/** + * Convert a Zod schema to a plain JSON Schema object suitable for inclusion + * in LLM API calls. + * + * Supported Zod types: + * z.string(), z.number(), z.boolean(), z.enum(), z.array(), z.object(), + * z.optional(), z.union(), z.literal(), z.describe(), z.nullable(), + * z.default(), z.intersection(), z.discriminatedUnion(), z.record(), + * z.tuple(), z.any(), z.unknown(), z.never(), z.effects() (transforms) + * + * Unsupported types fall back to `{}` (any) which is still valid JSON Schema. + */ +export function zodToJsonSchema(schema: ZodSchema): Record { + return convertZodType(schema) +} + +// Internal recursive converter. We access Zod's internal `_def` structure +// because Zod v3 does not ship a first-class JSON Schema exporter. +function convertZodType(schema: ZodSchema): Record { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const def = (schema as any)._def as ZodTypeDef + + const description: string | undefined = def.description + + const withDesc = (result: Record): Record => + description !== undefined ? { ...result, description } : result + + switch (def.typeName) { + // ----------------------------------------------------------------------- + // Primitives + // ----------------------------------------------------------------------- + case ZodTypeName.ZodString: + return withDesc({ type: 'string' }) + + case ZodTypeName.ZodNumber: + return withDesc({ type: 'number' }) + + case ZodTypeName.ZodBigInt: + return withDesc({ type: 'integer' }) + + case ZodTypeName.ZodBoolean: + return withDesc({ type: 'boolean' }) + + case ZodTypeName.ZodNull: + return withDesc({ type: 'null' }) + + case ZodTypeName.ZodUndefined: + return withDesc({ type: 'null' }) + + case ZodTypeName.ZodDate: + return withDesc({ type: 'string', format: 'date-time' }) + + // ----------------------------------------------------------------------- + // Literals + // ----------------------------------------------------------------------- + case ZodTypeName.ZodLiteral: { + const literalDef = def as ZodLiteralDef + return withDesc({ const: literalDef.value }) + } + + // ----------------------------------------------------------------------- + // Enums + // ----------------------------------------------------------------------- + case ZodTypeName.ZodEnum: { + const enumDef = def as ZodEnumDef + return withDesc({ type: 'string', enum: enumDef.values }) + } + + case ZodTypeName.ZodNativeEnum: { + const nativeEnumDef = def as ZodNativeEnumDef + const values = Object.values(nativeEnumDef.values as object).filter( + (v) => typeof v === 'string' || typeof v === 'number', + ) + return withDesc({ enum: values }) + } + + // ----------------------------------------------------------------------- + // Arrays + // ----------------------------------------------------------------------- + case ZodTypeName.ZodArray: { + const arrayDef = def as ZodArrayDef + return withDesc({ + type: 'array', + items: convertZodType(arrayDef.type), + }) + } + + case ZodTypeName.ZodTuple: { + const tupleDef = def as ZodTupleDef + return withDesc({ + type: 'array', + prefixItems: tupleDef.items.map(convertZodType), + }) + } + + // ----------------------------------------------------------------------- + // Objects + // ----------------------------------------------------------------------- + case ZodTypeName.ZodObject: { + const objectDef = def as ZodObjectDef + const properties: Record = {} + const required: string[] = [] + + for (const [key, value] of Object.entries(objectDef.shape())) { + properties[key] = convertZodType(value as ZodSchema) + + const innerDef = ((value as ZodSchema) as unknown as { _def: ZodTypeDef })._def + const isOptional = + innerDef.typeName === ZodTypeName.ZodOptional || + innerDef.typeName === ZodTypeName.ZodDefault || + innerDef.typeName === ZodTypeName.ZodNullable + if (!isOptional) { + required.push(key) + } + } + + const result: Record = { type: 'object', properties } + if (required.length > 0) result.required = required + return withDesc(result) + } + + case ZodTypeName.ZodRecord: { + const recordDef = def as ZodRecordDef + return withDesc({ + type: 'object', + additionalProperties: convertZodType(recordDef.valueType), + }) + } + + // ----------------------------------------------------------------------- + // Optional / Nullable / Default + // ----------------------------------------------------------------------- + case ZodTypeName.ZodOptional: { + const optionalDef = def as ZodOptionalDef + const inner = convertZodType(optionalDef.innerType) + return description !== undefined ? { ...inner, description } : inner + } + + case ZodTypeName.ZodNullable: { + const nullableDef = def as ZodNullableDef + const inner = convertZodType(nullableDef.innerType) + const type = inner.type + if (typeof type === 'string') { + return withDesc({ ...inner, type: [type, 'null'] }) + } + return withDesc({ anyOf: [inner, { type: 'null' }] }) + } + + case ZodTypeName.ZodDefault: { + const defaultDef = def as ZodDefaultDef + const inner = convertZodType(defaultDef.innerType) + return withDesc({ ...inner, default: defaultDef.defaultValue() }) + } + + // ----------------------------------------------------------------------- + // Union / Intersection / Discriminated Union + // ----------------------------------------------------------------------- + case ZodTypeName.ZodUnion: { + const unionDef = def as ZodUnionDef + const options = (unionDef.options as ZodSchema[]).map(convertZodType) + return withDesc({ anyOf: options }) + } + + case ZodTypeName.ZodDiscriminatedUnion: { + const duDef = def as ZodDiscriminatedUnionDef + const options = (duDef.options as ZodSchema[]).map(convertZodType) + return withDesc({ anyOf: options }) + } + + case ZodTypeName.ZodIntersection: { + const intDef = def as ZodIntersectionDef + return withDesc({ + allOf: [convertZodType(intDef.left), convertZodType(intDef.right)], + }) + } + + // ----------------------------------------------------------------------- + // Wrappers that forward to their inner type + // ----------------------------------------------------------------------- + case ZodTypeName.ZodEffects: { + const effectsDef = def as ZodEffectsDef + const inner = convertZodType(effectsDef.schema) + return description !== undefined ? { ...inner, description } : inner + } + + case ZodTypeName.ZodBranded: { + const brandedDef = def as ZodBrandedDef + return withDesc(convertZodType(brandedDef.type)) + } + + case ZodTypeName.ZodReadonly: { + const readonlyDef = def as ZodReadonlyDef + return withDesc(convertZodType(readonlyDef.innerType)) + } + + case ZodTypeName.ZodCatch: { + const catchDef = def as ZodCatchDef + return withDesc(convertZodType(catchDef.innerType)) + } + + case ZodTypeName.ZodPipeline: { + const pipelineDef = def as ZodPipelineDef + return withDesc(convertZodType(pipelineDef.in)) + } + + // ----------------------------------------------------------------------- + // Any / Unknown – JSON Schema wildcard + // ----------------------------------------------------------------------- + case ZodTypeName.ZodAny: + case ZodTypeName.ZodUnknown: + return withDesc({}) + + case ZodTypeName.ZodNever: + return withDesc({ not: {} }) + + case ZodTypeName.ZodVoid: + return withDesc({ type: 'null' }) + + // ----------------------------------------------------------------------- + // Fallback + // ----------------------------------------------------------------------- + default: + return withDesc({}) + } +} + +// --------------------------------------------------------------------------- +// Internal Zod type-name enum (mirrors Zod's internal ZodFirstPartyTypeKind) +// --------------------------------------------------------------------------- + +const enum ZodTypeName { + ZodString = 'ZodString', + ZodNumber = 'ZodNumber', + ZodBigInt = 'ZodBigInt', + ZodBoolean = 'ZodBoolean', + ZodDate = 'ZodDate', + ZodUndefined = 'ZodUndefined', + ZodNull = 'ZodNull', + ZodAny = 'ZodAny', + ZodUnknown = 'ZodUnknown', + ZodNever = 'ZodNever', + ZodVoid = 'ZodVoid', + ZodArray = 'ZodArray', + ZodObject = 'ZodObject', + ZodUnion = 'ZodUnion', + ZodDiscriminatedUnion = 'ZodDiscriminatedUnion', + ZodIntersection = 'ZodIntersection', + ZodTuple = 'ZodTuple', + ZodRecord = 'ZodRecord', + ZodMap = 'ZodMap', + ZodSet = 'ZodSet', + ZodFunction = 'ZodFunction', + ZodLazy = 'ZodLazy', + ZodLiteral = 'ZodLiteral', + ZodEnum = 'ZodEnum', + ZodEffects = 'ZodEffects', + ZodNativeEnum = 'ZodNativeEnum', + ZodOptional = 'ZodOptional', + ZodNullable = 'ZodNullable', + ZodDefault = 'ZodDefault', + ZodCatch = 'ZodCatch', + ZodPromise = 'ZodPromise', + ZodBranded = 'ZodBranded', + ZodPipeline = 'ZodPipeline', + ZodReadonly = 'ZodReadonly', +} + +// --------------------------------------------------------------------------- +// Internal Zod _def structure typings (narrow only what we access) +// --------------------------------------------------------------------------- + +interface ZodTypeDef { + typeName: string + description?: string +} + +interface ZodLiteralDef extends ZodTypeDef { + value: unknown +} + +interface ZodEnumDef extends ZodTypeDef { + values: string[] +} + +interface ZodNativeEnumDef extends ZodTypeDef { + values: object +} + +interface ZodArrayDef extends ZodTypeDef { + type: ZodSchema +} + +interface ZodTupleDef extends ZodTypeDef { + items: ZodSchema[] +} + +interface ZodObjectDef extends ZodTypeDef { + shape: () => Record +} + +interface ZodRecordDef extends ZodTypeDef { + valueType: ZodSchema +} + +interface ZodUnionDef extends ZodTypeDef { + options: unknown +} + +interface ZodDiscriminatedUnionDef extends ZodTypeDef { + options: unknown +} + +interface ZodIntersectionDef extends ZodTypeDef { + left: ZodSchema + right: ZodSchema +} + +interface ZodOptionalDef extends ZodTypeDef { + innerType: ZodSchema +} + +interface ZodNullableDef extends ZodTypeDef { + innerType: ZodSchema +} + +interface ZodDefaultDef extends ZodTypeDef { + innerType: ZodSchema + defaultValue: () => unknown +} + +interface ZodEffectsDef extends ZodTypeDef { + schema: ZodSchema +} + +interface ZodBrandedDef extends ZodTypeDef { + type: ZodSchema +} + +interface ZodReadonlyDef extends ZodTypeDef { + innerType: ZodSchema +} + +interface ZodCatchDef extends ZodTypeDef { + innerType: ZodSchema +} + +interface ZodPipelineDef extends ZodTypeDef { + in: ZodSchema +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2875a35 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,362 @@ +/** + * @fileoverview Core type definitions for the open-multi-agent orchestration framework. + * + * All public types are exported from this single module. Downstream modules + * import only what they need, keeping the dependency graph acyclic. + */ + +import type { ZodSchema } from 'zod' + +// --------------------------------------------------------------------------- +// Content blocks +// --------------------------------------------------------------------------- + +/** Plain-text content produced by a model or supplied by the user. */ +export interface TextBlock { + readonly type: 'text' + readonly text: string +} + +/** + * A request by the model to invoke a named tool with a structured input. + * The `id` is unique per turn and is referenced by {@link ToolResultBlock}. + */ +export interface ToolUseBlock { + readonly type: 'tool_use' + readonly id: string + readonly name: string + readonly input: Record +} + +/** + * The result of executing a tool, keyed back to the originating + * {@link ToolUseBlock} via `tool_use_id`. + */ +export interface ToolResultBlock { + readonly type: 'tool_result' + readonly tool_use_id: string + readonly content: string + readonly is_error?: boolean +} + +/** A base64-encoded image passed to or returned from a model. */ +export interface ImageBlock { + readonly type: 'image' + readonly source: { + readonly type: 'base64' + readonly media_type: string + readonly data: string + } +} + +/** Union of all content block variants that may appear in a message. */ +export type ContentBlock = TextBlock | ToolUseBlock | ToolResultBlock | ImageBlock + +// --------------------------------------------------------------------------- +// LLM messages & responses +// --------------------------------------------------------------------------- + +/** + * A single message in a conversation thread. + * System messages are passed separately via {@link LLMChatOptions.systemPrompt}. + */ +export interface LLMMessage { + readonly role: 'user' | 'assistant' + readonly content: ContentBlock[] +} + +/** Token accounting for a single API call. */ +export interface TokenUsage { + readonly input_tokens: number + readonly output_tokens: number +} + +/** Normalised response returned by every {@link LLMAdapter} implementation. */ +export interface LLMResponse { + readonly id: string + readonly content: ContentBlock[] + readonly model: string + readonly stop_reason: string + readonly usage: TokenUsage +} + +// --------------------------------------------------------------------------- +// Streaming +// --------------------------------------------------------------------------- + +/** + * A discrete event emitted during streaming generation. + * + * - `text` — incremental text delta + * - `tool_use` — the model has begun or completed a tool-use block + * - `tool_result` — a tool result has been appended to the stream + * - `done` — the stream has ended; `data` is the final {@link LLMResponse} + * - `error` — an unrecoverable error occurred; `data` is an `Error` + */ +export interface StreamEvent { + readonly type: 'text' | 'tool_use' | 'tool_result' | 'done' | 'error' + readonly data: unknown +} + +// --------------------------------------------------------------------------- +// Tool definitions +// --------------------------------------------------------------------------- + +/** The serialisable tool schema sent to the LLM provider. */ +export interface LLMToolDef { + readonly name: string + readonly description: string + /** JSON Schema object describing the tool's `input` parameter. */ + readonly inputSchema: Record +} + +/** + * Context injected into every tool execution. + * + * Both `abortSignal` and `abortController` are provided so that tools and the + * executor can choose the most ergonomic API for their use-case: + * + * - Long-running shell commands that need to kill a child process can use + * `abortController.signal` directly. + * - Simple cancellation checks can read `abortSignal?.aborted`. + * + * When constructing a context, set `abortController` and derive `abortSignal` + * from it, or provide both independently. + */ +export interface ToolUseContext { + /** High-level description of the agent invoking this tool. */ + readonly agent: AgentInfo + /** Team context, present when the tool runs inside a multi-agent team. */ + readonly team?: TeamInfo + /** + * Convenience reference to the abort signal. + * Equivalent to `abortController?.signal` when an `abortController` is set. + */ + readonly abortSignal?: AbortSignal + /** + * Full abort controller, available when the caller needs to inspect or + * programmatically abort the signal. + * Tools should prefer `abortSignal` for simple cancellation checks. + */ + readonly abortController?: AbortController + /** Working directory hint for file-system tools. */ + readonly cwd?: string + /** Arbitrary caller-supplied metadata (session ID, request ID, etc.). */ + readonly metadata?: Readonly> +} + +/** Minimal descriptor for the agent that is invoking a tool. */ +export interface AgentInfo { + readonly name: string + readonly role: string + readonly model: string +} + +/** Descriptor for a team of agents with shared memory. */ +export interface TeamInfo { + readonly name: string + readonly agents: readonly string[] + readonly sharedMemory: MemoryStore +} + +/** Value returned by a tool's `execute` function. */ +export interface ToolResult { + readonly data: string + readonly isError?: boolean +} + +/** + * A tool registered with the framework. + * + * `inputSchema` is a Zod schema used for validation before `execute` is called. + * At API call time it is converted to JSON Schema via {@link LLMToolDef}. + */ +export interface ToolDefinition> { + readonly name: string + readonly description: string + readonly inputSchema: ZodSchema + execute(input: TInput, context: ToolUseContext): Promise +} + +// --------------------------------------------------------------------------- +// Agent +// --------------------------------------------------------------------------- + +/** Static configuration for a single agent. */ +export interface AgentConfig { + readonly name: string + readonly model: string + readonly provider?: 'anthropic' | 'openai' + readonly systemPrompt?: string + /** Names of tools (from the tool registry) available to this agent. */ + readonly tools?: readonly string[] + readonly maxTurns?: number + readonly maxTokens?: number + readonly temperature?: number +} + +/** Lifecycle state tracked during an agent run. */ +export interface AgentState { + status: 'idle' | 'running' | 'completed' | 'error' + messages: LLMMessage[] + tokenUsage: TokenUsage + error?: Error +} + +/** A single recorded tool invocation within a run. */ +export interface ToolCallRecord { + readonly toolName: string + readonly input: Record + readonly output: string + /** Wall-clock duration in milliseconds. */ + readonly duration: number +} + +/** The final result produced when an agent run completes (or fails). */ +export interface AgentRunResult { + readonly success: boolean + readonly output: string + readonly messages: LLMMessage[] + readonly tokenUsage: TokenUsage + readonly toolCalls: ToolCallRecord[] +} + +// --------------------------------------------------------------------------- +// Team +// --------------------------------------------------------------------------- + +/** Static configuration for a team of cooperating agents. */ +export interface TeamConfig { + readonly name: string + readonly agents: readonly AgentConfig[] + readonly sharedMemory?: boolean + readonly maxConcurrency?: number +} + +/** Aggregated result for a full team run. */ +export interface TeamRunResult { + readonly success: boolean + /** Keyed by agent name. */ + readonly agentResults: Map + readonly totalTokenUsage: TokenUsage +} + +// --------------------------------------------------------------------------- +// Task +// --------------------------------------------------------------------------- + +/** Valid states for a {@link Task}. */ +export type TaskStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked' + +/** A discrete unit of work tracked by the orchestrator. */ +export interface Task { + readonly id: string + readonly title: string + readonly description: string + status: TaskStatus + /** Agent name responsible for executing this task. */ + assignee?: string + /** IDs of tasks that must complete before this one can start. */ + dependsOn?: readonly string[] + result?: string + readonly createdAt: Date + updatedAt: Date +} + +// --------------------------------------------------------------------------- +// Orchestrator +// --------------------------------------------------------------------------- + +/** Progress event emitted by the orchestrator during a run. */ +export interface OrchestratorEvent { + readonly type: + | 'agent_start' + | 'agent_complete' + | 'task_start' + | 'task_complete' + | 'message' + | 'error' + readonly agent?: string + readonly task?: string + readonly data?: unknown +} + +/** Top-level configuration for the orchestrator. */ +export interface OrchestratorConfig { + readonly maxConcurrency?: number + readonly defaultModel?: string + readonly defaultProvider?: 'anthropic' | 'openai' + onProgress?: (event: OrchestratorEvent) => void +} + +// --------------------------------------------------------------------------- +// Memory +// --------------------------------------------------------------------------- + +/** A single key-value record stored in a {@link MemoryStore}. */ +export interface MemoryEntry { + readonly key: string + readonly value: string + readonly metadata?: Readonly> + readonly createdAt: Date +} + +/** + * Persistent (or in-memory) key-value store shared across agents. + * Implementations may be backed by Redis, SQLite, or plain objects. + */ +export interface MemoryStore { + get(key: string): Promise + set(key: string, value: string, metadata?: Record): Promise + list(): Promise + delete(key: string): Promise + clear(): Promise +} + +// --------------------------------------------------------------------------- +// LLM adapter +// --------------------------------------------------------------------------- + +/** Options shared by both chat and streaming calls. */ +export interface LLMChatOptions { + readonly model: string + readonly tools?: readonly LLMToolDef[] + readonly maxTokens?: number + readonly temperature?: number + readonly systemPrompt?: string + readonly abortSignal?: AbortSignal +} + +/** + * Options for streaming calls. + * Extends {@link LLMChatOptions} without additional fields — the separation + * exists so callers can type-narrow and implementations can diverge later. + */ +export interface LLMStreamOptions extends LLMChatOptions {} + +/** + * Provider-agnostic interface that every LLM backend must implement. + * + * @example + * ```ts + * const adapter: LLMAdapter = createAdapter('anthropic') + * const response = await adapter.chat(messages, { model: 'claude-opus-4-6' }) + * ``` + */ +export interface LLMAdapter { + /** Human-readable provider name, e.g. `'anthropic'` or `'openai'`. */ + readonly name: string + + /** + * Send a chat request and return the complete response. + * Throws on non-retryable API errors. + */ + chat(messages: LLMMessage[], options: LLMChatOptions): Promise + + /** + * Send a chat request and yield {@link StreamEvent}s incrementally. + * The final event in the sequence always has `type === 'done'` on success, + * or `type === 'error'` on failure. + */ + stream(messages: LLMMessage[], options: LLMStreamOptions): AsyncIterable +} diff --git a/src/utils/semaphore.ts b/src/utils/semaphore.ts new file mode 100644 index 0000000..30fe61a --- /dev/null +++ b/src/utils/semaphore.ts @@ -0,0 +1,89 @@ +/** + * @fileoverview Shared counting semaphore for concurrency control. + * + * Used by both {@link ToolExecutor} and {@link AgentPool} to cap the number of + * concurrent async operations without requiring any third-party dependencies. + * + * This is intentionally self-contained and tuned for Promise/async use — + * not a general OS-semaphore replacement. + */ + +// --------------------------------------------------------------------------- +// Semaphore +// --------------------------------------------------------------------------- + +/** + * Classic counting semaphore for concurrency control. + * + * `acquire()` resolves immediately if a slot is free, otherwise queues the + * caller. `release()` unblocks the next waiter in FIFO order. + * + * Node.js is single-threaded, so this is safe without atomics or mutex + * primitives — the semaphore gates concurrent async operations, not CPU threads. + */ +export class Semaphore { + private current = 0 + private readonly queue: Array<() => void> = [] + + /** + * @param max - Maximum number of concurrent holders. Must be >= 1. + */ + constructor(private readonly max: number) { + if (max < 1) { + throw new RangeError(`Semaphore max must be at least 1, got ${max}`) + } + } + + /** + * Acquire a slot. Resolves immediately when one is free, or waits until a + * holder calls `release()`. + */ + acquire(): Promise { + if (this.current < this.max) { + this.current++ + return Promise.resolve() + } + + return new Promise(resolve => { + this.queue.push(resolve) + }) + } + + /** + * Release a previously acquired slot. + * If callers are queued, the next one is unblocked synchronously. + */ + release(): void { + const next = this.queue.shift() + if (next !== undefined) { + // A queued caller is waiting — hand the slot directly to it. + // `current` stays the same: we consumed the slot immediately. + next() + } else { + this.current-- + } + } + + /** + * Run `fn` while holding one slot, automatically releasing it afterward + * even if `fn` throws. + */ + async run(fn: () => Promise): Promise { + await this.acquire() + try { + return await fn() + } finally { + this.release() + } + } + + /** Number of slots currently in use. */ + get active(): number { + return this.current + } + + /** Number of callers waiting for a slot. */ + get pending(): number { + return this.queue.length + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..565bd8b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "examples", "tests"] +}