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:
commit
a6244cfe64
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.tgz
|
||||
.DS_Store
|
||||
reddit-promotion.md
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
||||
[](https://www.npmjs.com/package/open-multi-agent)
|
||||
[](./LICENSE)
|
||||
[](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
|
||||
|
|
@ -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.')
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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}`)
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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}`
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 title→id 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* @fileoverview Pure task utility functions.
|
||||
*
|
||||
* These helpers operate on plain {@link Task} values without any mutable
|
||||
* state, making them safe to use in reducers, tests, and reactive pipelines.
|
||||
* Stateful orchestration belongs in {@link TaskQueue}.
|
||||
*/
|
||||
|
||||
import type { Task, TaskStatus } from '../types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates a new {@link Task} with a generated UUID, `'pending'` status, and
|
||||
* `createdAt`/`updatedAt` timestamps set to the current instant.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const task = createTask({
|
||||
* title: 'Research competitors',
|
||||
* description: 'Identify the top 5 competitors and their pricing',
|
||||
* assignee: 'researcher',
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export function createTask(input: {
|
||||
title: string
|
||||
description: string
|
||||
assignee?: string
|
||||
dependsOn?: string[]
|
||||
}): Task {
|
||||
const now = new Date()
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
status: 'pending' as TaskStatus,
|
||||
assignee: input.assignee,
|
||||
dependsOn: input.dependsOn ? [...input.dependsOn] : undefined,
|
||||
result: undefined,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Readiness
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns `true` when `task` can be started immediately.
|
||||
*
|
||||
* A task is considered ready when:
|
||||
* 1. Its status is `'pending'`.
|
||||
* 2. Every task listed in `task.dependsOn` has status `'completed'`.
|
||||
*
|
||||
* Tasks whose dependencies are missing from `allTasks` are treated as
|
||||
* unresolvable and therefore **not** ready.
|
||||
*
|
||||
* @param task - The task to evaluate.
|
||||
* @param allTasks - The full collection of tasks in the current queue/plan.
|
||||
* @param taskById - Optional pre-built id→task map. When provided the function
|
||||
* skips rebuilding the map, reducing the complexity of
|
||||
* call-sites that invoke `isTaskReady` inside a loop from
|
||||
* O(n²) to O(n).
|
||||
*/
|
||||
export function isTaskReady(
|
||||
task: Task,
|
||||
allTasks: Task[],
|
||||
taskById?: Map<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 }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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('')
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue