From fb6051146fa3d50b7ae472b34899366b17ec0780 Mon Sep 17 00:00:00 2001 From: NamelessNATM Date: Wed, 8 Apr 2026 05:58:10 +0000 Subject: [PATCH] feat: implement delegation mechanism for agent orchestration - Introduced `delegate_to_agent` tool for orchestrating agent tasks. - Enhanced `AgentPool` to manage available run slots, preventing deadlocks during nested runs. - Updated `TeamInfo` and `RunOptions` to support delegation context. - Added tests for delegation functionality, including error handling for self-delegation and depth limits. - Refactored built-in tools registration to conditionally include the new delegation tool. --- examples/16-agent-handoff.ts | 64 ++++++ package-lock.json | 340 +++++++++++++++++++++++++++++++ src/agent/pool.ts | 10 + src/agent/runner.ts | 13 +- src/index.ts | 4 + src/orchestrator/orchestrator.ts | 85 +++++++- src/tool/built-in/delegate.ts | 98 +++++++++ src/tool/built-in/index.ts | 26 ++- src/types.ts | 27 ++- src/utils/semaphore.ts | 5 + tests/agent-pool.test.ts | 27 +++ tests/built-in-tools.test.ts | 206 ++++++++++++++++++- tests/semaphore.test.ts | 4 + 13 files changed, 890 insertions(+), 19 deletions(-) create mode 100644 examples/16-agent-handoff.ts create mode 100644 src/tool/built-in/delegate.ts diff --git a/examples/16-agent-handoff.ts b/examples/16-agent-handoff.ts new file mode 100644 index 0000000..fd2775c --- /dev/null +++ b/examples/16-agent-handoff.ts @@ -0,0 +1,64 @@ +/** + * Example 16 — Synchronous agent handoff via `delegate_to_agent` + * + * During `runTeam` / `runTasks`, pool agents register the built-in + * `delegate_to_agent` tool so one specialist can run a sub-prompt on another + * roster agent and read the answer in the same conversation turn. + * + * Whitelist `delegate_to_agent` in `tools` when you want the model to see it; + * standalone `runAgent()` does not register this tool by default. + * + * Run: + * npx tsx examples/16-agent-handoff.ts + * + * Prerequisites: + * ANTHROPIC_API_KEY + */ + +import { OpenMultiAgent } from '../src/index.js' +import type { AgentConfig } from '../src/types.js' + +const researcher: AgentConfig = { + name: 'researcher', + model: 'claude-sonnet-4-6', + provider: 'anthropic', + systemPrompt: + 'You answer factual questions briefly. When the user asks for a second opinion ' + + 'from the analyst, use delegate_to_agent to ask the analyst agent, then summarize both views.', + tools: ['delegate_to_agent'], + maxTurns: 6, +} + +const analyst: AgentConfig = { + name: 'analyst', + model: 'claude-sonnet-4-6', + provider: 'anthropic', + systemPrompt: 'You give short, skeptical analysis of claims. Push back when evidence is weak.', + tools: [], + maxTurns: 4, +} + +async function main(): Promise { + const orchestrator = new OpenMultiAgent({ maxConcurrency: 2 }) + const team = orchestrator.createTeam('handoff-demo', { + name: 'handoff-demo', + agents: [researcher, analyst], + sharedMemory: true, + }) + + const goal = + 'In one paragraph: state a simple fact about photosynthesis. ' + + 'Then ask the analyst (via delegate_to_agent) for a one-sentence critique of overstated claims in popular science. ' + + 'Merge both into a final short answer.' + + const result = await orchestrator.runTeam(team, goal) + console.log('Success:', result.success) + for (const [name, ar] of result.agentResults) { + console.log(`\n--- ${name} ---\n${ar.output.slice(0, 2000)}`) + } +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/package-lock.json b/package-lock.json index 06b7034..80a40bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -731,6 +731,34 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.60.1", "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", @@ -745,6 +773,314 @@ "darwin" ] }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", @@ -3064,6 +3400,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -3147,6 +3484,7 @@ "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", @@ -3369,6 +3707,7 @@ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -3390,6 +3729,7 @@ "resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/src/agent/pool.ts b/src/agent/pool.ts index 18545b4..62dcfa7 100644 --- a/src/agent/pool.ts +++ b/src/agent/pool.ts @@ -77,6 +77,16 @@ export class AgentPool { this.semaphore = new Semaphore(maxConcurrency) } + /** + * Pool semaphore slots not currently held (`maxConcurrency - active`). + * Used to avoid deadlocks when a nested `run()` would wait forever for a slot + * held by the parent run. Best-effort only if multiple nested runs start in + * parallel after the same synchronous check. + */ + get availableRunSlots(): number { + return this.maxConcurrency - this.semaphore.active + } + // ------------------------------------------------------------------------- // Registry operations // ------------------------------------------------------------------------- diff --git a/src/agent/runner.ts b/src/agent/runner.ts index 81155e8..85d733c 100644 --- a/src/agent/runner.ts +++ b/src/agent/runner.ts @@ -23,6 +23,7 @@ import type { StreamEvent, ToolResult, ToolUseContext, + TeamInfo, LLMAdapter, LLMChatOptions, TraceEvent, @@ -125,6 +126,11 @@ export interface RunOptions { * {@link RunnerOptions.abortSignal}. Useful for per-run timeouts. */ readonly abortSignal?: AbortSignal + /** + * Team context for built-in tools such as `delegate_to_agent`. + * Injected by the orchestrator during `runTeam` / `runTasks` pool runs. + */ + readonly team?: TeamInfo } /** The aggregated result returned when a full run completes. */ @@ -495,7 +501,7 @@ export class AgentRunner { // 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 toolContext: ToolUseContext = this.buildToolContext(options) const executionPromises = toolUseBlocks.map(async (block): Promise<{ resultBlock: ToolResultBlock @@ -630,14 +636,15 @@ export class AgentRunner { * Build the {@link ToolUseContext} passed to every tool execution. * Identifies this runner as the invoking agent. */ - private buildToolContext(): ToolUseContext { + private buildToolContext(options: RunOptions = {}): ToolUseContext { return { agent: { name: this.options.agentName ?? 'runner', role: this.options.agentRole ?? 'assistant', model: this.options.model, }, - abortSignal: this.options.abortSignal, + abortSignal: options.abortSignal ?? this.options.abortSignal, + ...(options.team !== undefined ? { team: options.team } : {}), } } } diff --git a/src/index.ts b/src/index.ts index 20d8d1a..f215d6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -94,12 +94,15 @@ export type { ToolExecutorOptions, BatchToolCall } from './tool/executor.js' export { registerBuiltInTools, BUILT_IN_TOOLS, + ALL_BUILT_IN_TOOLS_WITH_DELEGATE, bashTool, + delegateToAgentTool, fileReadTool, fileWriteTool, fileEditTool, grepTool, } from './tool/built-in/index.js' +export type { RegisterBuiltInToolsOptions } from './tool/built-in/index.js' // --------------------------------------------------------------------------- // LLM adapters @@ -144,6 +147,7 @@ export type { ToolUseContext, AgentInfo, TeamInfo, + DelegationPoolView, // Agent AgentConfig, diff --git a/src/orchestrator/orchestrator.ts b/src/orchestrator/orchestrator.ts index 5fd6060..46d9961 100644 --- a/src/orchestrator/orchestrator.ts +++ b/src/orchestrator/orchestrator.ts @@ -49,6 +49,7 @@ import type { Task, TaskStatus, TeamConfig, + TeamInfo, TeamRunResult, TokenUsage, } from '../types.js' @@ -72,6 +73,7 @@ import { extractKeywords, keywordScore } from '../utils/keywords.js' const ZERO_USAGE: TokenUsage = { input_tokens: 0, output_tokens: 0 } const DEFAULT_MAX_CONCURRENCY = 5 +const DEFAULT_MAX_DELEGATION_DEPTH = 3 const DEFAULT_MODEL = 'claude-opus-4-6' // --------------------------------------------------------------------------- @@ -206,11 +208,14 @@ function resolveTokenBudget(primary?: number, fallback?: number): number | undef /** * Build a minimal {@link Agent} with its own fresh registry/executor. - * Registers all built-in tools so coordinator/worker agents can use them. + * Pool workers pass `includeDelegateTool` so `delegate_to_agent` is available during `runTeam` / `runTasks`. */ -function buildAgent(config: AgentConfig): Agent { +function buildAgent( + config: AgentConfig, + toolRegistration?: { readonly includeDelegateTool?: boolean }, +): Agent { const registry = new ToolRegistry() - registerBuiltInTools(registry) + registerBuiltInTools(registry, toolRegistration) const executor = new ToolExecutor(registry) return new Agent(config, registry, executor) } @@ -393,6 +398,54 @@ interface RunContext { budgetExceededReason?: string } +/** + * Build {@link TeamInfo} for tool context, including nested `runDelegatedAgent` + * that respects pool capacity to avoid semaphore deadlocks. + */ +function buildTaskAgentTeamInfo( + ctx: RunContext, + taskId: string, + traceBase: Partial, + delegationDepth: number, +): TeamInfo { + const sharedMem = ctx.team.getSharedMemoryInstance() + const maxDepth = ctx.config.maxDelegationDepth + const agentNames = ctx.team.getAgents().map((a) => a.name) + + const runDelegatedAgent = async (targetAgent: string, prompt: string): Promise => { + const pool = ctx.pool + if (pool.availableRunSlots < 1) { + return { + success: false, + output: + 'Agent pool has no free concurrency slot for a delegated run (would deadlock). ' + + 'Increase maxConcurrency or reduce parallel delegation.', + messages: [], + tokenUsage: ZERO_USAGE, + toolCalls: [], + } + } + const nestedTeam = buildTaskAgentTeamInfo(ctx, taskId, traceBase, delegationDepth + 1) + const childOpts: Partial = { + ...traceBase, + traceAgent: targetAgent, + taskId, + team: nestedTeam, + } + return pool.run(targetAgent, prompt, childOpts) + } + + return { + name: ctx.team.name, + agents: agentNames, + ...(sharedMem ? { sharedMemory: sharedMem.getStore() } : {}), + delegationDepth, + maxDelegationDepth: maxDepth, + delegationPool: ctx.pool, + runDelegatedAgent, + } +} + /** * Execute all tasks in `queue` using agents in `pool`, respecting dependencies * and running independent tasks in parallel. @@ -494,16 +547,28 @@ async function executeQueue( // Build the prompt: inject shared memory context + task description const prompt = await buildTaskPrompt(task, team) - // Build trace context for this task's agent run - const traceOptions: Partial | undefined = config.onTrace - ? { onTrace: config.onTrace, runId: ctx.runId ?? '', taskId: task.id, traceAgent: assignee, abortSignal: ctx.abortSignal } - : ctx.abortSignal ? { abortSignal: ctx.abortSignal } : undefined + // Trace + abort + team tool context (delegate_to_agent) + const traceBase: Partial = { + ...(config.onTrace + ? { + onTrace: config.onTrace, + runId: ctx.runId ?? '', + taskId: task.id, + traceAgent: assignee, + } + : {}), + ...(ctx.abortSignal ? { abortSignal: ctx.abortSignal } : {}), + } + const runOptions: Partial = { + ...traceBase, + team: buildTaskAgentTeamInfo(ctx, task.id, traceBase, 0), + } const taskStartMs = config.onTrace ? Date.now() : 0 let retryCount = 0 const result = await executeWithRetry( - () => pool.run(assignee, prompt, traceOptions), + () => pool.run(assignee, prompt, runOptions), task, (retryData) => { retryCount++ @@ -681,12 +746,14 @@ export class OpenMultiAgent { * * Sensible defaults: * - `maxConcurrency`: 5 + * - `maxDelegationDepth`: 3 * - `defaultModel`: `'claude-opus-4-6'` * - `defaultProvider`: `'anthropic'` */ constructor(config: OrchestratorConfig = {}) { this.config = { maxConcurrency: config.maxConcurrency ?? DEFAULT_MAX_CONCURRENCY, + maxDelegationDepth: config.maxDelegationDepth ?? DEFAULT_MAX_DELEGATION_DEPTH, defaultModel: config.defaultModel ?? DEFAULT_MODEL, defaultProvider: config.defaultProvider ?? 'anthropic', defaultBaseURL: config.defaultBaseURL, @@ -1315,7 +1382,7 @@ export class OpenMultiAgent { baseURL: config.baseURL ?? this.config.defaultBaseURL, apiKey: config.apiKey ?? this.config.defaultApiKey, } - pool.add(buildAgent(effective)) + pool.add(buildAgent(effective, { includeDelegateTool: true })) } return pool } diff --git a/src/tool/built-in/delegate.ts b/src/tool/built-in/delegate.ts new file mode 100644 index 0000000..e7ad581 --- /dev/null +++ b/src/tool/built-in/delegate.ts @@ -0,0 +1,98 @@ +/** + * @fileoverview Built-in `delegate_to_agent` tool for synchronous handoff to a roster agent. + */ + +import { z } from 'zod' +import type { ToolDefinition, ToolResult, ToolUseContext } from '../../types.js' + +const inputSchema = z.object({ + target_agent: z.string().min(1).describe('Name of the team agent to run the sub-task.'), + prompt: z.string().min(1).describe('Instructions / question for the target agent.'), +}) + +/** + * Delegates a sub-task to another agent on the team and returns that agent's final text output. + * + * Only available when the orchestrator injects {@link ToolUseContext.team} with + * `runDelegatedAgent` (pool-backed `runTeam` / `runTasks`). Standalone `runAgent` + * does not register this tool by default. + * + * Nested {@link AgentRunResult.tokenUsage} from the delegated run is not merged into + * the parent agent's run totals (traces may still record usage via `onTrace`). + */ +export const delegateToAgentTool: ToolDefinition> = { + name: 'delegate_to_agent', + description: + 'Run a sub-task on another agent from this team and return that agent\'s final answer as the tool result. ' + + 'Use when you need a specialist teammate to produce output you will incorporate. ' + + 'The target agent runs in a fresh conversation for this prompt only.', + inputSchema, + async execute( + { target_agent: targetAgent, prompt }, + context: ToolUseContext, + ): Promise { + const team = context.team + if (!team?.runDelegatedAgent) { + return { + data: + 'delegate_to_agent is only available during orchestrated team runs with the delegation tool enabled. ' + + 'Use SharedMemory or explicit tasks instead.', + isError: true, + } + } + + const depth = team.delegationDepth ?? 0 + const maxDepth = team.maxDelegationDepth ?? 3 + if (depth >= maxDepth) { + return { + data: `Maximum delegation depth (${maxDepth}) reached; cannot delegate further.`, + isError: true, + } + } + + if (targetAgent === context.agent.name) { + return { + data: 'Cannot delegate to yourself; use another team member.', + isError: true, + } + } + + if (!team.agents.includes(targetAgent)) { + return { + data: `Unknown agent "${targetAgent}". Roster: ${team.agents.join(', ')}`, + isError: true, + } + } + + if (team.delegationPool !== undefined && team.delegationPool.availableRunSlots < 1) { + return { + data: + 'Agent pool has no free concurrency slot for a delegated run (nested run would block indefinitely). ' + + 'Increase orchestrator maxConcurrency, wait for parallel work to finish, or avoid delegating while the pool is saturated.', + isError: true, + } + } + + const result = await team.runDelegatedAgent(targetAgent, prompt) + // Nested run tokenUsage is not merged into the parent agent's AgentRunResult (onTrace may still show it). + + if (team.sharedMemory) { + const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}` + const key = `delegation:${targetAgent}:${suffix}` + try { + await team.sharedMemory.set(`${context.agent.name}/${key}`, result.output, { + agent: context.agent.name, + delegatedTo: targetAgent, + success: String(result.success), + }) + } catch { + // Audit is best-effort; do not fail the tool on store errors. + } + } + + return { + data: result.output, + isError: !result.success, + } + }, +} diff --git a/src/tool/built-in/index.ts b/src/tool/built-in/index.ts index 06ff764..eb43866 100644 --- a/src/tool/built-in/index.ts +++ b/src/tool/built-in/index.ts @@ -8,12 +8,22 @@ import type { ToolDefinition } from '../../types.js' import { ToolRegistry } from '../framework.js' import { bashTool } from './bash.js' +import { delegateToAgentTool } from './delegate.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 } +export { bashTool, delegateToAgentTool, fileEditTool, fileReadTool, fileWriteTool, grepTool } + +/** Options for {@link registerBuiltInTools}. */ +export interface RegisterBuiltInToolsOptions { + /** + * When true, registers `delegate_to_agent` (team orchestration handoff). + * Default false so standalone agents and `runAgent` do not expose a tool that always errors. + */ + readonly includeDelegateTool?: boolean +} /** * The ordered list of all built-in tools. Import this when you need to @@ -31,6 +41,12 @@ export const BUILT_IN_TOOLS: ToolDefinition[] = [ grepTool, ] +/** All built-ins including `delegate_to_agent` (for team registry setup). */ +export const ALL_BUILT_IN_TOOLS_WITH_DELEGATE: ToolDefinition[] = [ + ...BUILT_IN_TOOLS, + delegateToAgentTool, +] + /** * Register all built-in tools with the given registry. * @@ -43,8 +59,14 @@ export const BUILT_IN_TOOLS: ToolDefinition[] = [ * registerBuiltInTools(registry) * ``` */ -export function registerBuiltInTools(registry: ToolRegistry): void { +export function registerBuiltInTools( + registry: ToolRegistry, + options?: RegisterBuiltInToolsOptions, +): void { for (const tool of BUILT_IN_TOOLS) { registry.register(tool) } + if (options?.includeDelegateTool) { + registry.register(delegateToAgentTool) + } } diff --git a/src/types.ts b/src/types.ts index 24f0e5c..4ab4915 100644 --- a/src/types.ts +++ b/src/types.ts @@ -153,11 +153,29 @@ export interface AgentInfo { readonly model: string } -/** Descriptor for a team of agents with shared memory. */ +/** + * Minimal pool surface used by `delegate_to_agent` to detect nested-run capacity. + * {@link AgentPool} satisfies this structurally via {@link AgentPool.availableRunSlots}. + */ +export interface DelegationPoolView { + readonly availableRunSlots: number +} + +/** Descriptor for a team of agents (orchestrator-injected into tool context). */ export interface TeamInfo { readonly name: string readonly agents: readonly string[] - readonly sharedMemory: MemoryStore + /** When the team has shared memory enabled; used for delegation audit writes. */ + readonly sharedMemory?: MemoryStore + /** Zero-based depth of nested delegation from the root task run. */ + readonly delegationDepth?: number + readonly maxDelegationDepth?: number + readonly delegationPool?: DelegationPoolView + /** + * Run another roster agent to completion and return its result. + * Only set during orchestrated pool execution (`runTeam` / `runTasks`). + */ + readonly runDelegatedAgent?: (targetAgent: string, prompt: string) => Promise } /** Value returned by a tool's `execute` function. */ @@ -395,6 +413,11 @@ export interface OrchestratorEvent { /** Top-level configuration for the orchestrator. */ export interface OrchestratorConfig { readonly maxConcurrency?: number + /** + * Maximum depth of `delegate_to_agent` chains from a task run (default `3`). + * Depth is per nested delegated run, not per team. + */ + readonly maxDelegationDepth?: number /** Maximum cumulative tokens (input + output) allowed per orchestrator run. */ readonly maxTokenBudget?: number readonly defaultModel?: string diff --git a/src/utils/semaphore.ts b/src/utils/semaphore.ts index 30fe61a..d4f4c01 100644 --- a/src/utils/semaphore.ts +++ b/src/utils/semaphore.ts @@ -34,6 +34,11 @@ export class Semaphore { } } + /** Maximum concurrent holders configured for this semaphore. */ + get limit(): number { + return this.max + } + /** * Acquire a slot. Resolves immediately when one is free, or waits until a * holder calls `release()`. diff --git a/tests/agent-pool.test.ts b/tests/agent-pool.test.ts index 343c5b4..a774120 100644 --- a/tests/agent-pool.test.ts +++ b/tests/agent-pool.test.ts @@ -291,5 +291,32 @@ describe('AgentPool', () => { expect(maxConcurrent).toBeLessThanOrEqual(2) }) + + it('availableRunSlots matches maxConcurrency when idle', () => { + const pool = new AgentPool(3) + pool.add(createMockAgent('a')) + expect(pool.availableRunSlots).toBe(3) + }) + + it('availableRunSlots is zero while a run holds the pool slot', async () => { + const pool = new AgentPool(1) + const agent = createMockAgent('solo') + pool.add(agent) + + let finishRun!: (value: AgentRunResult) => void + const holdPromise = new Promise((resolve) => { + finishRun = resolve + }) + vi.mocked(agent.run).mockReturnValue(holdPromise) + + const runPromise = pool.run('solo', 'hold-slot') + await Promise.resolve() + await Promise.resolve() + expect(pool.availableRunSlots).toBe(0) + + finishRun(SUCCESS_RESULT) + await runPromise + expect(pool.availableRunSlots).toBe(1) + }) }) }) diff --git a/tests/built-in-tools.test.ts b/tests/built-in-tools.test.ts index 440fd42..e5b6d5f 100644 --- a/tests/built-in-tools.test.ts +++ b/tests/built-in-tools.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { mkdtemp, rm, writeFile, readFile } from 'fs/promises' import { join } from 'path' import { tmpdir } from 'os' @@ -7,9 +7,14 @@ import { fileWriteTool } from '../src/tool/built-in/file-write.js' import { fileEditTool } from '../src/tool/built-in/file-edit.js' import { bashTool } from '../src/tool/built-in/bash.js' import { grepTool } from '../src/tool/built-in/grep.js' -import { registerBuiltInTools, BUILT_IN_TOOLS } from '../src/tool/built-in/index.js' +import { + registerBuiltInTools, + BUILT_IN_TOOLS, + delegateToAgentTool, +} from '../src/tool/built-in/index.js' import { ToolRegistry } from '../src/tool/framework.js' -import type { ToolUseContext } from '../src/types.js' +import { InMemoryStore } from '../src/memory/store.js' +import type { AgentRunResult, ToolUseContext } from '../src/types.js' // --------------------------------------------------------------------------- // Helpers @@ -43,6 +48,13 @@ describe('registerBuiltInTools', () => { expect(registry.get('file_write')).toBeDefined() expect(registry.get('file_edit')).toBeDefined() expect(registry.get('grep')).toBeDefined() + expect(registry.get('delegate_to_agent')).toBeUndefined() + }) + + it('registers delegate_to_agent when includeDelegateTool is set', () => { + const registry = new ToolRegistry() + registerBuiltInTools(registry, { includeDelegateTool: true }) + expect(registry.get('delegate_to_agent')).toBeDefined() }) it('BUILT_IN_TOOLS has correct length', () => { @@ -391,3 +403,191 @@ describe('grep', () => { expect(result.data.toLowerCase()).toContain('no such file') }) }) + +// =========================================================================== +// delegate_to_agent +// =========================================================================== + +const DELEGATE_OK: AgentRunResult = { + success: true, + output: 'research done', + messages: [], + tokenUsage: { input_tokens: 1, output_tokens: 2 }, + toolCalls: [], +} + +describe('delegate_to_agent', () => { + it('returns delegated agent output on success', async () => { + const runDelegatedAgent = vi.fn().mockResolvedValue(DELEGATE_OK) + const ctx: ToolUseContext = { + agent: { name: 'alice', role: 'lead', model: 'test' }, + team: { + name: 't', + agents: ['alice', 'bob'], + delegationDepth: 0, + maxDelegationDepth: 3, + delegationPool: { availableRunSlots: 2 }, + runDelegatedAgent, + }, + } + + const result = await delegateToAgentTool.execute( + { target_agent: 'bob', prompt: 'Summarize X.' }, + ctx, + ) + + expect(result.isError).toBe(false) + expect(result.data).toBe('research done') + expect(runDelegatedAgent).toHaveBeenCalledWith('bob', 'Summarize X.') + }) + + it('errors when delegation is not configured', async () => { + const ctx: ToolUseContext = { + agent: { name: 'alice', role: 'lead', model: 'test' }, + team: { name: 't', agents: ['alice', 'bob'] }, + } + + const result = await delegateToAgentTool.execute( + { target_agent: 'bob', prompt: 'Hi' }, + ctx, + ) + + expect(result.isError).toBe(true) + expect(result.data).toMatch(/only available during orchestrated team runs/i) + }) + + it('errors for unknown target agent', async () => { + const ctx: ToolUseContext = { + agent: { name: 'alice', role: 'lead', model: 'test' }, + team: { + name: 't', + agents: ['alice', 'bob'], + runDelegatedAgent: vi.fn(), + delegationPool: { availableRunSlots: 1 }, + }, + } + + const result = await delegateToAgentTool.execute( + { target_agent: 'charlie', prompt: 'Hi' }, + ctx, + ) + + expect(result.isError).toBe(true) + expect(result.data).toMatch(/Unknown agent/) + }) + + it('errors on self-delegation', async () => { + const ctx: ToolUseContext = { + agent: { name: 'alice', role: 'lead', model: 'test' }, + team: { + name: 't', + agents: ['alice', 'bob'], + runDelegatedAgent: vi.fn(), + delegationPool: { availableRunSlots: 1 }, + }, + } + + const result = await delegateToAgentTool.execute( + { target_agent: 'alice', prompt: 'Hi' }, + ctx, + ) + + expect(result.isError).toBe(true) + expect(result.data).toMatch(/yourself/) + }) + + it('errors when delegation depth limit is reached', async () => { + const ctx: ToolUseContext = { + agent: { name: 'alice', role: 'lead', model: 'test' }, + team: { + name: 't', + agents: ['alice', 'bob'], + delegationDepth: 3, + maxDelegationDepth: 3, + runDelegatedAgent: vi.fn(), + delegationPool: { availableRunSlots: 1 }, + }, + } + + const result = await delegateToAgentTool.execute( + { target_agent: 'bob', prompt: 'Hi' }, + ctx, + ) + + expect(result.isError).toBe(true) + expect(result.data).toMatch(/Maximum delegation depth/) + }) + + it('errors fast when pool has no free slots without calling runDelegatedAgent', async () => { + const runDelegatedAgent = vi.fn() + const ctx: ToolUseContext = { + agent: { name: 'alice', role: 'lead', model: 'test' }, + team: { + name: 't', + agents: ['alice', 'bob'], + delegationPool: { availableRunSlots: 0 }, + runDelegatedAgent, + }, + } + + const result = await delegateToAgentTool.execute( + { target_agent: 'bob', prompt: 'Hi' }, + ctx, + ) + + expect(result.isError).toBe(true) + expect(result.data).toMatch(/no free concurrency slot/i) + expect(runDelegatedAgent).not.toHaveBeenCalled() + }) + + it('writes unique SharedMemory audit keys for repeated delegations', async () => { + const store = new InMemoryStore() + const runDelegatedAgent = vi.fn().mockResolvedValue(DELEGATE_OK) + const ctx: ToolUseContext = { + agent: { name: 'alice', role: 'lead', model: 'test' }, + team: { + name: 't', + agents: ['alice', 'bob'], + sharedMemory: store, + delegationPool: { availableRunSlots: 2 }, + runDelegatedAgent, + }, + } + + await delegateToAgentTool.execute({ target_agent: 'bob', prompt: 'a' }, ctx) + await delegateToAgentTool.execute({ target_agent: 'bob', prompt: 'b' }, ctx) + + const keys = (await store.list()).map((e) => e.key) + const delegationKeys = keys.filter((k) => k.includes('delegation:bob:')) + expect(delegationKeys).toHaveLength(2) + expect(delegationKeys[0]).not.toBe(delegationKeys[1]) + }) + + it('returns isError when delegated run reports success false', async () => { + const runDelegatedAgent = vi.fn().mockResolvedValue({ + success: false, + output: 'delegated agent failed', + messages: [], + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + toolCalls: [], + } satisfies AgentRunResult) + + const ctx: ToolUseContext = { + agent: { name: 'alice', role: 'lead', model: 'test' }, + team: { + name: 't', + agents: ['alice', 'bob'], + delegationPool: { availableRunSlots: 1 }, + runDelegatedAgent, + }, + } + + const result = await delegateToAgentTool.execute( + { target_agent: 'bob', prompt: 'Hi' }, + ctx, + ) + + expect(result.isError).toBe(true) + expect(result.data).toBe('delegated agent failed') + }) +}) diff --git a/tests/semaphore.test.ts b/tests/semaphore.test.ts index ddc1b34..734d07c 100644 --- a/tests/semaphore.test.ts +++ b/tests/semaphore.test.ts @@ -6,6 +6,10 @@ describe('Semaphore', () => { expect(() => new Semaphore(0)).toThrow() }) + it('exposes configured limit', () => { + expect(new Semaphore(5).limit).toBe(5) + }) + it('allows up to max concurrent holders', async () => { const sem = new Semaphore(2) let running = 0