Compare commits

...

8 Commits

Author SHA1 Message Date
NamelessNATM 62107938bb
Merge fb6051146f into 0fb8a38284 2026-04-12 16:56:02 +08:00
JackChen 0fb8a38284
Merge pull request #88 from ibrahimkzmv/feat.context-strategy
feat: Add contextStrategy to control conversation growth and prevent token explosion in agent runs
2026-04-12 16:54:41 +08:00
MrAvalonApple 629d9c8253 feat: implement synthetic framing for user messages and enhance context strategy handling 2026-04-12 00:18:36 +03:00
JackChen 1fbed196ca Revert "ci: add release workflow triggered by v*.*.* tags" 2026-04-11 15:25:24 +08:00
JackChen a220b6ecc5 ci: add release workflow triggered by v*.*.* tags 2026-04-11 15:23:41 +08:00
JackChen 89311dc4d4 chore: release 1.1.0 2026-04-11 15:15:15 +08:00
MrAvalonApple eb484d9bbf feat: add context management strategies (sliding-window, summarize, custom) to prevent unbounded conversation growth 2026-04-09 19:40:15 +03:00
NamelessNATM fb6051146f 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.
2026-04-08 05:58:10 +00:00
18 changed files with 1354 additions and 21 deletions

View File

@ -114,6 +114,8 @@ const conversationAgent = new Agent(
model: 'claude-sonnet-4-6',
systemPrompt: 'You are a TypeScript tutor. Give short, direct answers.',
maxTurns: 2,
// Keep only the most recent turn in long prompt() conversations.
contextStrategy: { type: 'sliding-window', maxTurns: 1 },
},
new ToolRegistry(), // no tools needed for this conversation
new ToolExecutor(new ToolRegistry()),

View File

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

340
package-lock.json generated
View File

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

View File

@ -1,6 +1,6 @@
{
"name": "@jackchen_me/open-multi-agent",
"version": "1.0.1",
"version": "1.1.0",
"description": "TypeScript multi-agent framework — one runTeam() call from goal to result. Auto task decomposition, parallel execution. 3 dependencies, deploys anywhere Node.js runs.",
"files": [
"dist",

View File

@ -153,6 +153,7 @@ export class Agent {
agentRole: this.config.systemPrompt?.slice(0, 50) ?? 'assistant',
loopDetection: this.config.loopDetection,
maxTokenBudget: this.config.maxTokenBudget,
contextStrategy: this.config.contextStrategy,
}
this.runner = new AgentRunner(

View File

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

View File

@ -23,16 +23,19 @@ import type {
StreamEvent,
ToolResult,
ToolUseContext,
TeamInfo,
LLMAdapter,
LLMChatOptions,
TraceEvent,
LoopDetectionConfig,
LoopDetectionInfo,
LLMToolDef,
ContextStrategy,
} from '../types.js'
import { TokenBudgetExceededError } from '../errors.js'
import { LoopDetector } from './loop-detector.js'
import { emitTrace } from '../utils/trace.js'
import { estimateTokens } from '../utils/tokens.js'
import type { ToolRegistry } from '../tool/framework.js'
import type { ToolExecutor } from '../tool/executor.js'
@ -94,6 +97,8 @@ export interface RunnerOptions {
readonly loopDetection?: LoopDetectionConfig
/** Maximum cumulative tokens (input + output) allowed for this run. */
readonly maxTokenBudget?: number
/** Optional context compression strategy for long multi-turn runs. */
readonly contextStrategy?: ContextStrategy
}
/**
@ -125,6 +130,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. */
@ -172,6 +182,31 @@ function addTokenUsage(a: TokenUsage, b: TokenUsage): TokenUsage {
const ZERO_USAGE: TokenUsage = { input_tokens: 0, output_tokens: 0 }
/**
* Prepends synthetic framing text to the first user message so we never emit
* consecutive `user` turns (Bedrock) and summaries do not concatenate onto
* the original user prompt (direct API). If there is no user message yet,
* inserts a single assistant text preamble.
*/
function prependSyntheticPrefixToFirstUser(
messages: LLMMessage[],
prefix: string,
): LLMMessage[] {
const userIdx = messages.findIndex(m => m.role === 'user')
if (userIdx < 0) {
return [{
role: 'assistant',
content: [{ type: 'text', text: prefix.trimEnd() }],
}, ...messages]
}
const target = messages[userIdx]!
const merged: LLMMessage = {
role: 'user',
content: [{ type: 'text', text: prefix }, ...target.content],
}
return [...messages.slice(0, userIdx), merged, ...messages.slice(userIdx + 1)]
}
// ---------------------------------------------------------------------------
// AgentRunner
// ---------------------------------------------------------------------------
@ -191,6 +226,10 @@ const ZERO_USAGE: TokenUsage = { input_tokens: 0, output_tokens: 0 }
*/
export class AgentRunner {
private readonly maxTurns: number
private summarizeCache: {
oldSignature: string
summaryPrefix: string
} | null = null
constructor(
private readonly adapter: LLMAdapter,
@ -201,6 +240,172 @@ export class AgentRunner {
this.maxTurns = options.maxTurns ?? 10
}
private serializeMessage(message: LLMMessage): string {
return JSON.stringify(message)
}
private truncateToSlidingWindow(messages: LLMMessage[], maxTurns: number): LLMMessage[] {
if (maxTurns <= 0) {
return messages
}
const firstUserIndex = messages.findIndex(m => m.role === 'user')
const firstUser = firstUserIndex >= 0 ? messages[firstUserIndex]! : null
const afterFirst = firstUserIndex >= 0
? messages.slice(firstUserIndex + 1)
: messages.slice()
if (afterFirst.length <= maxTurns * 2) {
return messages
}
const kept = afterFirst.slice(-maxTurns * 2)
const result: LLMMessage[] = []
if (firstUser !== null) {
result.push(firstUser)
}
const droppedPairs = Math.floor((afterFirst.length - kept.length) / 2)
if (droppedPairs > 0) {
const notice =
`[Earlier conversation history truncated — ${droppedPairs} turn(s) removed]\n\n`
result.push(...prependSyntheticPrefixToFirstUser(kept, notice))
return result
}
result.push(...kept)
return result
}
private async summarizeMessages(
messages: LLMMessage[],
maxTokens: number,
summaryModel: string | undefined,
baseChatOptions: LLMChatOptions,
turns: number,
options: RunOptions,
): Promise<{ messages: LLMMessage[]; usage: TokenUsage }> {
const estimated = estimateTokens(messages)
if (estimated <= maxTokens || messages.length < 4) {
return { messages, usage: ZERO_USAGE }
}
const firstUserIndex = messages.findIndex(m => m.role === 'user')
if (firstUserIndex < 0 || firstUserIndex === messages.length - 1) {
return { messages, usage: ZERO_USAGE }
}
const firstUser = messages[firstUserIndex]!
const rest = messages.slice(firstUserIndex + 1)
if (rest.length < 2) {
return { messages, usage: ZERO_USAGE }
}
// Split on an even boundary so we never separate a tool_use assistant turn
// from its tool_result user message (rest is user/assistant pairs).
const splitAt = Math.max(2, Math.floor(rest.length / 4) * 2)
const oldPortion = rest.slice(0, splitAt)
const recentPortion = rest.slice(splitAt)
const oldSignature = oldPortion.map(m => this.serializeMessage(m)).join('\n')
if (this.summarizeCache !== null && this.summarizeCache.oldSignature === oldSignature) {
const mergedRecent = prependSyntheticPrefixToFirstUser(
recentPortion,
`${this.summarizeCache.summaryPrefix}\n\n`,
)
return { messages: [firstUser, ...mergedRecent], usage: ZERO_USAGE }
}
const summaryPrompt = [
'Summarize the following conversation history for an LLM.',
'- Preserve user goals, constraints, and decisions.',
'- Keep key tool outputs and unresolved questions.',
'- Use concise bullets.',
'- Do not fabricate details.',
].join('\n')
const summaryInput: LLMMessage[] = [
{
role: 'user',
content: [
{ type: 'text', text: summaryPrompt },
{ type: 'text', text: `\n\nConversation:\n${oldSignature}` },
],
},
]
const summaryOptions: LLMChatOptions = {
...baseChatOptions,
model: summaryModel ?? this.options.model,
tools: undefined,
}
const summaryStartMs = Date.now()
const summaryResponse = await this.adapter.chat(summaryInput, summaryOptions)
if (options.onTrace) {
const summaryEndMs = Date.now()
emitTrace(options.onTrace, {
type: 'llm_call',
runId: options.runId ?? '',
taskId: options.taskId,
agent: options.traceAgent ?? this.options.agentName ?? 'unknown',
model: summaryOptions.model,
phase: 'summary',
turn: turns,
tokens: summaryResponse.usage,
startMs: summaryStartMs,
endMs: summaryEndMs,
durationMs: summaryEndMs - summaryStartMs,
})
}
const summaryText = extractText(summaryResponse.content).trim()
const summaryPrefix = summaryText.length > 0
? `[Conversation summary]\n${summaryText}`
: '[Conversation summary unavailable]'
this.summarizeCache = { oldSignature, summaryPrefix }
const mergedRecent = prependSyntheticPrefixToFirstUser(
recentPortion,
`${summaryPrefix}\n\n`,
)
return {
messages: [firstUser, ...mergedRecent],
usage: summaryResponse.usage,
}
}
private async applyContextStrategy(
messages: LLMMessage[],
strategy: ContextStrategy,
baseChatOptions: LLMChatOptions,
turns: number,
options: RunOptions,
): Promise<{ messages: LLMMessage[]; usage: TokenUsage }> {
if (strategy.type === 'sliding-window') {
return { messages: this.truncateToSlidingWindow(messages, strategy.maxTurns), usage: ZERO_USAGE }
}
if (strategy.type === 'summarize') {
return this.summarizeMessages(
messages,
strategy.maxTokens,
strategy.summaryModel,
baseChatOptions,
turns,
options,
)
}
const estimated = estimateTokens(messages)
const compressed = await strategy.compress(messages, estimated)
if (!Array.isArray(compressed) || compressed.length === 0) {
throw new Error('contextStrategy.custom.compress must return a non-empty LLMMessage[]')
}
return { messages: compressed, usage: ZERO_USAGE }
}
// -------------------------------------------------------------------------
// Tool resolution
// -------------------------------------------------------------------------
@ -313,7 +518,7 @@ export class AgentRunner {
options: RunOptions = {},
): AsyncGenerator<StreamEvent> {
// Working copy of the conversation — mutated as turns progress.
const conversationMessages: LLMMessage[] = [...initialMessages]
let conversationMessages: LLMMessage[] = [...initialMessages]
// Accumulated state across all turns.
let totalUsage: TokenUsage = ZERO_USAGE
@ -363,6 +568,19 @@ export class AgentRunner {
turns++
// Optionally compact context before each LLM call after the first turn.
if (this.options.contextStrategy && turns > 1) {
const compacted = await this.applyContextStrategy(
conversationMessages,
this.options.contextStrategy,
baseChatOptions,
turns,
options,
)
conversationMessages = compacted.messages
totalUsage = addTokenUsage(totalUsage, compacted.usage)
}
// ------------------------------------------------------------------
// Step 1: Call the LLM and collect the full response for this turn.
// ------------------------------------------------------------------
@ -376,6 +594,7 @@ export class AgentRunner {
taskId: options.taskId,
agent: options.traceAgent ?? this.options.agentName ?? 'unknown',
model: this.options.model,
phase: 'turn',
turn: turns,
tokens: response.usage,
startMs: llmStartMs,
@ -495,7 +714,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 +849,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 } : {}),
}
}
}

View File

@ -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,
@ -153,6 +157,7 @@ export type {
ToolCallRecord,
LoopDetectionConfig,
LoopDetectionInfo,
ContextStrategy,
// Team
TeamConfig,

View File

@ -50,6 +50,7 @@ import type {
Task,
TaskStatus,
TeamConfig,
TeamInfo,
TeamRunResult,
TokenUsage,
} from '../types.js'
@ -73,6 +74,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'
// ---------------------------------------------------------------------------
@ -207,11 +209,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)
}
@ -402,6 +407,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<RunOptions>,
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<AgentRunResult> => {
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<RunOptions> = {
...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.
@ -503,16 +556,28 @@ async function executeQueue(
// Build the prompt: task description + dependency-only context by default.
const prompt = await buildTaskPrompt(task, team, queue)
// Build trace context for this task's agent run
const traceOptions: Partial<RunOptions> | 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<RunOptions> = {
...(config.onTrace
? {
onTrace: config.onTrace,
runId: ctx.runId ?? '',
taskId: task.id,
traceAgent: assignee,
}
: {}),
...(ctx.abortSignal ? { abortSignal: ctx.abortSignal } : {}),
}
const runOptions: Partial<RunOptions> = {
...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++
@ -705,12 +770,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,
@ -1403,7 +1470,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
}

View File

@ -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<z.infer<typeof inputSchema>> = {
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<ToolResult> {
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,
}
},
}

View File

@ -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<any>[] = [
grepTool,
]
/** All built-ins including `delegate_to_agent` (for team registry setup). */
export const ALL_BUILT_IN_TOOLS_WITH_DELEGATE: ToolDefinition<any>[] = [
...BUILT_IN_TOOLS,
delegateToAgentTool,
]
/**
* Register all built-in tools with the given registry.
*
@ -43,8 +59,14 @@ export const BUILT_IN_TOOLS: ToolDefinition<any>[] = [
* 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)
}
}

View File

@ -65,6 +65,18 @@ export interface LLMMessage {
readonly content: ContentBlock[]
}
/** Context management strategy for long-running agent conversations. */
export type ContextStrategy =
| { type: 'sliding-window'; maxTurns: number }
| { type: 'summarize'; maxTokens: number; summaryModel?: string }
| {
type: 'custom'
compress: (
messages: LLMMessage[],
estimatedTokens: number,
) => Promise<LLMMessage[]> | LLMMessage[]
}
/** Token accounting for a single API call. */
export interface TokenUsage {
readonly input_tokens: number
@ -153,11 +165,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<AgentRunResult>
}
/** Value returned by a tool's `execute` function. */
@ -215,6 +245,8 @@ export interface AgentConfig {
readonly maxTokens?: number
/** Maximum cumulative tokens (input + output) allowed for this run. */
readonly maxTokenBudget?: number
/** Optional context compression policy to control input growth across turns. */
readonly contextStrategy?: ContextStrategy
readonly temperature?: number
/**
* Maximum wall-clock time (in milliseconds) for the entire agent run.
@ -401,6 +433,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
@ -493,6 +530,8 @@ export interface TraceEventBase {
export interface LLMCallTrace extends TraceEventBase {
readonly type: 'llm_call'
readonly model: string
/** Distinguishes normal turn calls from context-summary calls. */
readonly phase?: 'turn' | 'summary'
readonly turn: number
readonly tokens: TokenUsage
}

View File

@ -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()`.

27
src/utils/tokens.ts Normal file
View File

@ -0,0 +1,27 @@
import type { LLMMessage } from '../types.js'
/**
* Estimate token count using a lightweight character heuristic.
* This intentionally avoids model-specific tokenizer dependencies.
*/
export function estimateTokens(messages: LLMMessage[]): number {
let chars = 0
for (const message of messages) {
for (const block of message.content) {
if (block.type === 'text') {
chars += block.text.length
} else if (block.type === 'tool_result') {
chars += block.content.length
} else if (block.type === 'tool_use') {
chars += JSON.stringify(block.input).length
} else if (block.type === 'image') {
// Account for non-text payloads with a small fixed cost.
chars += 64
}
}
}
// Conservative English heuristic: ~4 chars per token.
return Math.ceil(chars / 4)
}

View File

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

View File

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

View File

@ -0,0 +1,202 @@
import { describe, it, expect, vi } from 'vitest'
import { z } from 'zod'
import { AgentRunner } from '../src/agent/runner.js'
import { ToolRegistry, defineTool } from '../src/tool/framework.js'
import { ToolExecutor } from '../src/tool/executor.js'
import type { LLMAdapter, LLMChatOptions, LLMMessage, LLMResponse, TraceEvent } from '../src/types.js'
function textResponse(text: string): LLMResponse {
return {
id: `resp-${Math.random().toString(36).slice(2)}`,
content: [{ type: 'text', text }],
model: 'mock-model',
stop_reason: 'end_turn',
usage: { input_tokens: 10, output_tokens: 20 },
}
}
function toolUseResponse(toolName: string, input: Record<string, unknown>): LLMResponse {
return {
id: `resp-${Math.random().toString(36).slice(2)}`,
content: [{
type: 'tool_use',
id: `tu-${Math.random().toString(36).slice(2)}`,
name: toolName,
input,
}],
model: 'mock-model',
stop_reason: 'tool_use',
usage: { input_tokens: 15, output_tokens: 25 },
}
}
function buildRegistryAndExecutor(): { registry: ToolRegistry; executor: ToolExecutor } {
const registry = new ToolRegistry()
registry.register(
defineTool({
name: 'echo',
description: 'Echo input',
inputSchema: z.object({ message: z.string() }),
async execute({ message }) {
return { data: message }
},
}),
)
return { registry, executor: new ToolExecutor(registry) }
}
describe('AgentRunner contextStrategy', () => {
it('keeps baseline behavior when contextStrategy is not set', async () => {
const calls: LLMMessage[][] = []
const adapter: LLMAdapter = {
name: 'mock',
async chat(messages) {
calls.push(messages.map(m => ({ role: m.role, content: m.content })))
return calls.length === 1
? toolUseResponse('echo', { message: 'hello' })
: textResponse('done')
},
async *stream() {
/* unused */
},
}
const { registry, executor } = buildRegistryAndExecutor()
const runner = new AgentRunner(adapter, registry, executor, {
model: 'mock-model',
allowedTools: ['echo'],
maxTurns: 4,
})
await runner.run([{ role: 'user', content: [{ type: 'text', text: 'start' }] }])
expect(calls).toHaveLength(2)
expect(calls[0]).toHaveLength(1)
expect(calls[1]!.length).toBeGreaterThan(calls[0]!.length)
})
it('sliding-window truncates old turns and preserves the first user message', async () => {
const calls: LLMMessage[][] = []
const responses = [
toolUseResponse('echo', { message: 't1' }),
toolUseResponse('echo', { message: 't2' }),
toolUseResponse('echo', { message: 't3' }),
textResponse('done'),
]
let idx = 0
const adapter: LLMAdapter = {
name: 'mock',
async chat(messages) {
calls.push(messages.map(m => ({ role: m.role, content: m.content })))
return responses[idx++]!
},
async *stream() {
/* unused */
},
}
const { registry, executor } = buildRegistryAndExecutor()
const runner = new AgentRunner(adapter, registry, executor, {
model: 'mock-model',
allowedTools: ['echo'],
maxTurns: 8,
contextStrategy: { type: 'sliding-window', maxTurns: 1 },
})
await runner.run([{ role: 'user', content: [{ type: 'text', text: 'original prompt' }] }])
const laterCall = calls[calls.length - 1]!
const firstUserText = laterCall[0]!.content[0]
expect(firstUserText).toMatchObject({ type: 'text', text: 'original prompt' })
const flattenedText = laterCall.flatMap(m => m.content.filter(c => c.type === 'text'))
expect(flattenedText.some(c => c.type === 'text' && c.text.includes('truncated'))).toBe(true)
})
it('summarize strategy replaces old context and emits summary trace call', async () => {
const calls: Array<{ messages: LLMMessage[]; options: LLMChatOptions }> = []
const traces: TraceEvent[] = []
const responses = [
toolUseResponse('echo', { message: 'first turn payload '.repeat(20) }),
toolUseResponse('echo', { message: 'second turn payload '.repeat(20) }),
textResponse('This is a concise summary.'),
textResponse('final answer'),
]
let idx = 0
const adapter: LLMAdapter = {
name: 'mock',
async chat(messages, options) {
calls.push({ messages: messages.map(m => ({ role: m.role, content: m.content })), options })
return responses[idx++]!
},
async *stream() {
/* unused */
},
}
const { registry, executor } = buildRegistryAndExecutor()
const runner = new AgentRunner(adapter, registry, executor, {
model: 'mock-model',
allowedTools: ['echo'],
maxTurns: 8,
contextStrategy: { type: 'summarize', maxTokens: 20 },
})
const result = await runner.run(
[{ role: 'user', content: [{ type: 'text', text: 'start' }] }],
{ onTrace: (e) => { traces.push(e) }, runId: 'run-summary', traceAgent: 'context-agent' },
)
const summaryCall = calls.find(c => c.messages.length === 1 && c.options.tools === undefined)
expect(summaryCall).toBeDefined()
const llmTraces = traces.filter(t => t.type === 'llm_call')
expect(llmTraces.some(t => t.type === 'llm_call' && t.phase === 'summary')).toBe(true)
// Summary adapter usage must count toward RunResult.tokenUsage (maxTokenBudget).
expect(result.tokenUsage.input_tokens).toBe(15 + 15 + 10 + 10)
expect(result.tokenUsage.output_tokens).toBe(25 + 25 + 20 + 20)
// After compaction, summary text is folded into the next user turn (not a
// standalone user message), preserving user/assistant alternation.
const turnAfterSummary = calls.find(
c => c.messages.some(
m => m.role === 'user' && m.content.some(
b => b.type === 'text' && b.text.includes('[Conversation summary]'),
),
),
)
expect(turnAfterSummary).toBeDefined()
const rolesAfterFirstUser = turnAfterSummary!.messages.map(m => m.role).join(',')
expect(rolesAfterFirstUser).not.toMatch(/^user,user/)
})
it('custom strategy calls compress callback and uses returned messages', async () => {
const compress = vi.fn((messages: LLMMessage[]) => messages.slice(-1))
const calls: LLMMessage[][] = []
const responses = [
toolUseResponse('echo', { message: 'hello' }),
textResponse('done'),
]
let idx = 0
const adapter: LLMAdapter = {
name: 'mock',
async chat(messages) {
calls.push(messages.map(m => ({ role: m.role, content: m.content })))
return responses[idx++]!
},
async *stream() {
/* unused */
},
}
const { registry, executor } = buildRegistryAndExecutor()
const runner = new AgentRunner(adapter, registry, executor, {
model: 'mock-model',
allowedTools: ['echo'],
maxTurns: 4,
contextStrategy: {
type: 'custom',
compress,
},
})
await runner.run([{ role: 'user', content: [{ type: 'text', text: 'custom prompt' }] }])
expect(compress).toHaveBeenCalledOnce()
expect(calls[1]).toHaveLength(1)
})
})

View File

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