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.
This commit is contained in:
JackChen 2026-04-01 04:33:15 +08:00
commit a6244cfe64
35 changed files with 10071 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
*.tgz
.DS_Store
reddit-promotion.md

21
LICENSE Normal file
View File

@ -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.

276
README.md Normal file
View File

@ -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<StreamEvent>`
- **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

131
examples/01-single-agent.ts Normal file
View File

@ -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, <name>!"
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.')

View File

@ -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<string, number>()
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))
}

View File

@ -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<string, number>()
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))
}

View File

@ -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<typeof defineTool>[],
): 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": <rate>, "USD/GBP": <rate>, ... }`,
)
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}`)

1907
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
package.json Normal file
View File

@ -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"
}
}

364
src/agent/agent.ts Normal file
View File

@ -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<AgentRunner> {
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<AgentRunResult> {
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<AgentRunResult> {
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<StreamEvent> {
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<AgentRunResult> {
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<StreamEvent> {
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,
}
}
}

278
src/agent/pool.ts Normal file
View File

@ -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<string, Agent> = 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<AgentRunResult> {
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<agentName, AgentRunResult>`. 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<Map<string, AgentRunResult>> {
const resultMap = new Map<string, AgentRunResult>()
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<AgentRunResult> {
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<void> {
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: [],
}
}
}

413
src/agent/runner.ts Normal file
View File

@ -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<string, unknown>) => 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<RunResult> {
// 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<StreamEvent> {
// 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,
}
}
}

166
src/index.ts Normal file
View File

@ -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'

74
src/llm/adapter.ts Normal file
View File

@ -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<LLMAdapter> {
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)}`)
}
}
}

388
src/llm/anthropic.ts Normal file
View File

@ -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<string, unknown>),
},
}))
}
/**
* 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<string, unknown>,
}
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<LLMResponse> {
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<StreamEvent> {
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<number, { id: string; name: string; json: string }>()
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<string, unknown> = {}
try {
const parsed: unknown = JSON.parse(buf.json)
if (
parsed !== null &&
typeof parsed === 'object' &&
!Array.isArray(parsed)
) {
parsedInput = parsed as Record<string, unknown>
}
} 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,
}

522
src/llm/openai.ts Normal file
View File

@ -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<string, unknown>,
},
}
}
/**
* 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<string, unknown> = {}
try {
const parsed: unknown = JSON.parse(toolCall.function.arguments)
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
parsedInput = parsed as Record<string, unknown>
}
} 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<LLMResponse> {
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<StreamEvent> {
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<string, unknown> = {}
try {
const parsed: unknown = JSON.parse(buf.argsJson)
if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) {
parsedInput = parsed as Record<string, unknown>
}
} 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,
}

181
src/memory/shared.ts Normal file
View File

@ -0,0 +1,181 @@
/**
* @fileoverview Shared memory layer for teams of cooperating agents.
*
* Each agent writes under its own namespace (`<agentName>/<key>`) 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 `<agentName>/<key>` 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 `<agentName>/<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<string, unknown>,
): Promise<void> {
const namespacedKey = SharedMemory.namespaceKey(agentName, key)
await this.store.set(namespacedKey, value, {
...metadata,
agent: agentName,
})
}
// ---------------------------------------------------------------------------
// Read
// ---------------------------------------------------------------------------
/**
* Read an entry by its fully-qualified key (`<agentName>/<key>`).
*
* Returns `null` when the key is absent.
*/
async read(key: string): Promise<MemoryEntry | null> {
return this.store.get(key)
}
// ---------------------------------------------------------------------------
// List
// ---------------------------------------------------------------------------
/** Returns every entry in the shared store, regardless of agent. */
async listAll(): Promise<MemoryEntry[]> {
return this.store.list()
}
/**
* Returns all entries written by `agentName` (i.e. those whose key starts
* with `<agentName>/`).
*/
async listByAgent(agentName: string): Promise<MemoryEntry[]> {
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<string> {
const all = await this.store.list()
if (all.length === 0) return ''
// Group entries by agent name.
const byAgent = new Map<string, Array<{ localKey: string; value: string }>>()
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}`
}
}

124
src/memory/store.ts Normal file
View File

@ -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<string, MemoryEntry>()
// ---------------------------------------------------------------------------
// MemoryStore interface
// ---------------------------------------------------------------------------
/** Returns the entry for `key`, or `null` if not present. */
async get(key: string): Promise<MemoryEntry | null> {
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<string, unknown>,
): Promise<void> {
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<MemoryEntry[]> {
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<void> {
this.data.delete(key)
}
/** Removes **all** entries from the store. */
async clear(): Promise<void> {
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<MemoryEntry[]> {
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)
}
}

View File

@ -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<string, unknown>
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<string, AgentRunResult>
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<void> {
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<void> => {
// 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<string> {
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<OrchestratorConfig, 'onProgress'>
> & Pick<OrchestratorConfig, 'onProgress'>
private readonly teams: Map<string, Team> = 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<AgentRunResult> {
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<TeamRunResult> {
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<string, AgentRunResult>()
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<TeamRunResult> {
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<string, AgentRunResult>()
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<void> {
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<string> {
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 titleid map first,
* then resolving them to real IDs before adding tasks to the queue.
*/
private loadSpecsIntoQueue(
specs: ReadonlyArray<ParsedTaskSpec>,
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<string, string>()
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<string, AgentRunResult>,
): TeamRunResult {
let totalUsage: TokenUsage = ZERO_USAGE
let overallSuccess = true
const collapsed = new Map<string, AgentRunResult>()
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,
}
}
}

View File

@ -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<string, Task>(allTasks.map((t) => [t.id, t]))
// Build reverse adjacency: dependencyId -> tasks that depend on it
const dependents = new Map<string, string[]>()
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<string>()
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<taskId, agentName>` for every unassigned pending task.
*/
schedule(tasks: Task[], agents: AgentConfig[]): Map<string, string> {
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<string, string> {
const result = new Map<string, string>()
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<string, string> {
// Build initial in-progress count per agent.
const load = new Map<string, number>(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<string, string>()
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<string, string> {
const result = new Map<string, string>()
// Pre-compute keyword lists for each agent to avoid re-extracting per task.
const agentKeywords = new Map<string, string[]>(
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<string, string> {
// 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<string, string>()
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
}
}

394
src/task/queue.ts Normal file
View File

@ -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 TaskQueueEvent> = 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<string, Task>()
/** Listeners keyed by event type, stored as symbol → handler pairs. */
private readonly listeners = new Map<
TaskQueueEvent,
Map<symbol, TaskHandler | AllCompleteHandler>
>()
// ---------------------------------------------------------------------------
// 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<Pick<Task, 'status' | 'result' | 'assignee'>>,
): 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<E extends TaskQueueEvent>(
event: E,
handler: HandlerFor<E>,
): () => 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<string, Task>(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
}
}

232
src/task/task.ts Normal file
View File

@ -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 idtask 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<string, Task>,
): boolean {
if (task.status !== 'pending') return false
if (!task.dependsOn || task.dependsOn.length === 0) return true
const map = taskById ?? new Map<string, Task>(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<string, Task>(tasks.map((t) => [t.id, t]))
// Build adjacency: dependsOn edges become "predecessors" for in-degree count.
const inDegree = new Map<string, number>()
// successors[id] = list of task IDs that depend on `id`
const successors = new Map<string, string[]>()
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<string, Task>(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<string, 0 | 1 | 2>()
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 }
}

230
src/team/messaging.ts Normal file
View File

@ -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<string, Set<string>>()
/**
* 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<symbol, (message: Message) => 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<string>()
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<string>()
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)
}
}
}

334
src/team/team.ts Normal file
View File

@ -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<string, Map<symbol, EventHandler>>()
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<string, AgentConfig>
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, 'id' | 'createdAt' | 'updatedAt'>,
): 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>): 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)
}
}

187
src/tool/built-in/bash.ts Normal file
View File

@ -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<RunResult> {
return new Promise<RunResult>((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')
}

View File

@ -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('')
}

View File

@ -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<line>". ' +
'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,
}
},
})

View File

@ -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,
}
},
})

362
src/tool/built-in/grep.ts Normal file
View File

@ -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<ToolResult> {
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<ToolResult>((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<ToolResult> {
// 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<string[]> {
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<void> {
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<ReturnType<typeof stat>>
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 `**\/<pattern>` 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<boolean> {
if (rgAvailableCache !== undefined) return rgAvailableCache
rgAvailableCache = await new Promise<boolean>((resolve) => {
const child = spawn('rg', ['--version'], { stdio: 'ignore' })
child.on('close', (code) => resolve(code === 0))
child.on('error', () => resolve(false))
})
return rgAvailableCache
}

View File

@ -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<unknown>[]` 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<any>[] = [
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)
}
}

178
src/tool/executor.ts Normal file
View File

@ -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<string, unknown>
}
/**
* 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<string, unknown>,
context: ToolUseContext,
): Promise<ToolResult> {
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<Map<string, ToolResult>> {
const results = new Map<string, ToolResult>()
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<any>,
rawInput: Record<string, unknown>,
context: ToolUseContext,
): Promise<ToolResult> {
// --- 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,
}
}
}

557
src/tool/framework.ts Normal file
View File

@ -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<string, JSONSchemaProperty>
required?: string[]
description?: string
}
| { anyOf: JSONSchemaProperty[]; description?: string }
| { const: unknown; description?: string }
// Fallback for types we don't explicitly model
| Record<string, unknown>
// ---------------------------------------------------------------------------
// 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<TInput>(config: {
name: string
description: string
inputSchema: ZodSchema<TInput>
execute: (input: TInput, context: ToolUseContext) => Promise<ToolResult>
}): ToolDefinition<TInput> {
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<string, ToolDefinition<any>>()
/**
* 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<any>): 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<any> | 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<any>[] {
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<any>[] {
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<string, JSONSchemaProperty>
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<string, JSONSchemaProperty>) ?? {},
...(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<string, unknown> {
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<string, unknown> {
// 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<string, unknown>): Record<string, unknown> =>
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<string, unknown> = {}
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<string, unknown> = { 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<string, ZodSchema>
}
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
}

362
src/types.ts Normal file
View File

@ -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<string, unknown>
}
/**
* 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<string, unknown>
}
/**
* 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<Record<string, unknown>>
}
/** 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<TInput = Record<string, unknown>> {
readonly name: string
readonly description: string
readonly inputSchema: ZodSchema<TInput>
execute(input: TInput, context: ToolUseContext): Promise<ToolResult>
}
// ---------------------------------------------------------------------------
// 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<string, unknown>
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<string, AgentRunResult>
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<Record<string, unknown>>
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<MemoryEntry | null>
set(key: string, value: string, metadata?: Record<string, unknown>): Promise<void>
list(): Promise<MemoryEntry[]>
delete(key: string): Promise<void>
clear(): Promise<void>
}
// ---------------------------------------------------------------------------
// 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<LLMResponse>
/**
* 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<StreamEvent>
}

89
src/utils/semaphore.ts Normal file
View File

@ -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<void> {
if (this.current < this.max) {
this.current++
return Promise.resolve()
}
return new Promise<void>(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<T>(fn: () => Promise<T>): Promise<T> {
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
}
}

25
tsconfig.json Normal file
View File

@ -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"]
}