Compare commits
5 Commits
607ba57a69
...
dc8cbe0262
| Author | SHA1 | Date |
|---|---|---|
|
|
dc8cbe0262 | |
|
|
97c39b316c | |
|
|
48fbec6659 | |
|
|
9463dbb28e | |
|
|
cfbbd24601 |
56
README.md
56
README.md
|
|
@ -187,6 +187,54 @@ npx tsx examples/01-single-agent.ts
|
|||
| `file_edit` | Edit a file by replacing an exact string match. |
|
||||
| `grep` | Search file contents with regex. Uses ripgrep when available, falls back to Node.js. |
|
||||
|
||||
## Tool Configuration
|
||||
|
||||
Agents can be configured with fine-grained tool access control using presets, allowlists, and denylists.
|
||||
|
||||
### Tool Presets
|
||||
|
||||
Predefined tool sets for common use cases:
|
||||
|
||||
```typescript
|
||||
const readonlyAgent: AgentConfig = {
|
||||
name: 'reader',
|
||||
model: 'claude-sonnet-4-6',
|
||||
toolPreset: 'readonly', // file_read, grep, glob
|
||||
}
|
||||
|
||||
const readwriteAgent: AgentConfig = {
|
||||
name: 'editor',
|
||||
model: 'claude-sonnet-4-6',
|
||||
toolPreset: 'readwrite', // file_read, file_write, file_edit, grep, glob
|
||||
}
|
||||
|
||||
const fullAgent: AgentConfig = {
|
||||
name: 'executor',
|
||||
model: 'claude-sonnet-4-6',
|
||||
toolPreset: 'full', // file_read, file_write, file_edit, grep, glob, bash
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Filtering
|
||||
|
||||
Combine presets with allowlists and denylists for precise control:
|
||||
|
||||
```typescript
|
||||
const customAgent: AgentConfig = {
|
||||
name: 'custom',
|
||||
model: 'claude-sonnet-4-6',
|
||||
toolPreset: 'readwrite', // Start with: file_read, file_write, file_edit, grep, glob
|
||||
tools: ['file_read', 'grep'], // Allowlist: intersect with preset = file_read, grep
|
||||
disallowedTools: ['grep'], // Denylist: subtract = file_read only
|
||||
}
|
||||
```
|
||||
|
||||
**Resolution order:** preset → allowlist → denylist → framework safety rails.
|
||||
|
||||
### Custom Tools
|
||||
|
||||
Tools added via `agent.addTool()` are always available regardless of filtering.
|
||||
|
||||
## Supported Providers
|
||||
|
||||
| Provider | Config | Env var | Status |
|
||||
|
|
@ -258,16 +306,16 @@ Issues, feature requests, and PRs are welcome. Some areas where contributions wo
|
|||
## Contributors
|
||||
|
||||
<a href="https://github.com/JackChen-me/open-multi-agent/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=JackChen-me/open-multi-agent&v=20260405" />
|
||||
<img src="https://contrib.rocks/image?repo=JackChen-me/open-multi-agent&v=20260408" />
|
||||
</a>
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://star-history.com/#JackChen-me/open-multi-agent&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&theme=dark&v=20260405" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&v=20260405" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&v=20260405" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&theme=dark&v=20260408" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&v=20260408" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&v=20260408" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -259,16 +259,16 @@ const grokAgent: AgentConfig = {
|
|||
## 贡献者
|
||||
|
||||
<a href="https://github.com/JackChen-me/open-multi-agent/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=JackChen-me/open-multi-agent&v=20260405" />
|
||||
<img src="https://contrib.rocks/image?repo=JackChen-me/open-multi-agent&v=20260408" />
|
||||
</a>
|
||||
|
||||
## Star 趋势
|
||||
|
||||
<a href="https://star-history.com/#JackChen-me/open-multi-agent&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&theme=dark&v=20260405" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&v=20260405" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&v=20260405" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&theme=dark&v=20260408" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&v=20260408" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=JackChen-me/open-multi-agent&type=Date&v=20260408" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
|
|
|
|||
|
|
@ -146,7 +146,9 @@ export class Agent {
|
|||
maxTurns: this.config.maxTurns,
|
||||
maxTokens: this.config.maxTokens,
|
||||
temperature: this.config.temperature,
|
||||
toolPreset: this.config.toolPreset,
|
||||
allowedTools: this.config.tools,
|
||||
disallowedTools: this.config.disallowedTools,
|
||||
agentName: this.name,
|
||||
agentRole: this.config.systemPrompt?.slice(0, 50) ?? 'assistant',
|
||||
loopDetection: this.config.loopDetection,
|
||||
|
|
@ -261,7 +263,7 @@ export class Agent {
|
|||
* The tool becomes available to the next LLM call — no restart required.
|
||||
*/
|
||||
addTool(tool: FrameworkToolDefinition): void {
|
||||
this._toolRegistry.register(tool)
|
||||
this._toolRegistry.register(tool, { runtimeAdded: true })
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import type {
|
|||
TraceEvent,
|
||||
LoopDetectionConfig,
|
||||
LoopDetectionInfo,
|
||||
LLMToolDef,
|
||||
} from '../types.js'
|
||||
import { TokenBudgetExceededError } from '../errors.js'
|
||||
import { LoopDetector } from './loop-detector.js'
|
||||
|
|
@ -35,6 +36,22 @@ import { emitTrace } from '../utils/trace.js'
|
|||
import type { ToolRegistry } from '../tool/framework.js'
|
||||
import type { ToolExecutor } from '../tool/executor.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Predefined tool sets for common agent use cases. */
|
||||
export const TOOL_PRESETS = {
|
||||
readonly: ['file_read', 'grep', 'glob'],
|
||||
readwrite: ['file_read', 'file_write', 'file_edit', 'grep', 'glob'],
|
||||
full: ['file_read', 'file_write', 'file_edit', 'grep', 'glob', 'bash'],
|
||||
} as const satisfies Record<string, readonly string[]>
|
||||
|
||||
/** Framework-level disallowed tools for safety rails. */
|
||||
export const AGENT_FRAMEWORK_DISALLOWED: readonly string[] = [
|
||||
// Empty for now, infrastructure for future built-in tools
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public interfaces
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -60,11 +77,15 @@ export interface RunnerOptions {
|
|||
/** AbortSignal that cancels any in-flight adapter call and stops the loop. */
|
||||
readonly abortSignal?: AbortSignal
|
||||
/**
|
||||
* Whitelist of tool names this runner is allowed to use.
|
||||
* When provided, only tools whose name appears in this list are sent to the
|
||||
* LLM. When omitted, all registered tools are available.
|
||||
* Tool access control configuration.
|
||||
* - `toolPreset`: Predefined tool sets for common use cases
|
||||
* - `allowedTools`: Whitelist of tool names (allowlist)
|
||||
* - `disallowedTools`: Blacklist of tool names (denylist)
|
||||
* Tools are resolved in order: preset → allowlist → denylist
|
||||
*/
|
||||
readonly toolPreset?: 'readonly' | 'readwrite' | 'full'
|
||||
readonly allowedTools?: readonly string[]
|
||||
readonly disallowedTools?: readonly string[]
|
||||
/** Display name of the agent driving this runner (used in tool context). */
|
||||
readonly agentName?: string
|
||||
/** Short role description of the agent (used in tool context). */
|
||||
|
|
@ -180,6 +201,67 @@ export class AgentRunner {
|
|||
this.maxTurns = options.maxTurns ?? 10
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tool resolution
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Resolve the final set of tools available to this agent based on the
|
||||
* three-layer configuration: preset → allowlist → denylist → framework safety.
|
||||
*
|
||||
* Returns LLMToolDef[] for direct use with LLM adapters.
|
||||
*/
|
||||
private resolveTools(): LLMToolDef[] {
|
||||
// Validate configuration for contradictions
|
||||
if (this.options.toolPreset && this.options.allowedTools) {
|
||||
console.warn(
|
||||
'AgentRunner: both toolPreset and allowedTools are set. ' +
|
||||
'Final tool access will be the intersection of both.'
|
||||
)
|
||||
}
|
||||
|
||||
if (this.options.allowedTools && this.options.disallowedTools) {
|
||||
const overlap = this.options.allowedTools.filter(tool =>
|
||||
this.options.disallowedTools!.includes(tool)
|
||||
)
|
||||
if (overlap.length > 0) {
|
||||
console.warn(
|
||||
`AgentRunner: tools [${overlap.map(name => `"${name}"`).join(', ')}] appear in both allowedTools and disallowedTools. ` +
|
||||
'This is contradictory and may lead to unexpected behavior.'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const allTools = this.toolRegistry.toToolDefs()
|
||||
const runtimeCustomTools = this.toolRegistry.toRuntimeToolDefs()
|
||||
const runtimeCustomToolNames = new Set(runtimeCustomTools.map(t => t.name))
|
||||
let filteredTools = allTools.filter(t => !runtimeCustomToolNames.has(t.name))
|
||||
|
||||
// 1. Apply preset filter if set
|
||||
if (this.options.toolPreset) {
|
||||
const presetTools = new Set(TOOL_PRESETS[this.options.toolPreset] as readonly string[])
|
||||
filteredTools = filteredTools.filter(t => presetTools.has(t.name))
|
||||
}
|
||||
|
||||
// 2. Apply allowlist filter if set
|
||||
if (this.options.allowedTools) {
|
||||
filteredTools = filteredTools.filter(t => this.options.allowedTools!.includes(t.name))
|
||||
}
|
||||
|
||||
// 3. Apply denylist filter if set
|
||||
if (this.options.disallowedTools) {
|
||||
const denied = new Set(this.options.disallowedTools)
|
||||
filteredTools = filteredTools.filter(t => !denied.has(t.name))
|
||||
}
|
||||
|
||||
// 4. Apply framework-level safety rails
|
||||
const frameworkDenied = new Set(AGENT_FRAMEWORK_DISALLOWED)
|
||||
filteredTools = filteredTools.filter(t => !frameworkDenied.has(t.name))
|
||||
|
||||
// Runtime-added custom tools stay available regardless of filtering rules.
|
||||
return [...filteredTools, ...runtimeCustomTools]
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
|
@ -241,12 +323,8 @@ export class AgentRunner {
|
|||
let budgetExceeded = false
|
||||
|
||||
// Build the stable LLM options once; model / tokens / temp don't change.
|
||||
// toToolDefs() returns LLMToolDef[] (inputSchema, camelCase) — matches
|
||||
// LLMChatOptions.tools from types.ts directly.
|
||||
const allDefs = this.toolRegistry.toToolDefs()
|
||||
const toolDefs = this.options.allowedTools
|
||||
? allDefs.filter(d => this.options.allowedTools!.includes(d.name))
|
||||
: allDefs
|
||||
// resolveTools() returns LLMToolDef[] with three-layer filtering applied.
|
||||
const toolDefs = this.resolveTools()
|
||||
|
||||
// Per-call abortSignal takes precedence over the static one.
|
||||
const effectiveAbortSignal = options.abortSignal ?? this.options.abortSignal
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ import { TaskQueue } from '../task/queue.js'
|
|||
import { createTask } from '../task/task.js'
|
||||
import { Scheduler } from './scheduler.js'
|
||||
import { TokenBudgetExceededError } from '../errors.js'
|
||||
import { extractKeywords, keywordScore } from '../utils/keywords.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal constants
|
||||
|
|
@ -73,6 +74,119 @@ const ZERO_USAGE: TokenUsage = { input_tokens: 0, output_tokens: 0 }
|
|||
const DEFAULT_MAX_CONCURRENCY = 5
|
||||
const DEFAULT_MODEL = 'claude-opus-4-6'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Short-circuit helpers (exported for testability)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Regex patterns that indicate a goal requires multi-agent coordination.
|
||||
*
|
||||
* Each pattern targets a distinct complexity signal:
|
||||
* - Sequencing: "first … then", "step 1 / step 2", numbered lists
|
||||
* - Coordination: "collaborate", "coordinate", "review each other"
|
||||
* - Parallel work: "in parallel", "at the same time", "concurrently"
|
||||
* - Multi-phase: "phase", "stage", multiple distinct action verbs joined by connectives
|
||||
*/
|
||||
const COMPLEXITY_PATTERNS: RegExp[] = [
|
||||
// Explicit sequencing
|
||||
/\bfirst\b.{3,60}\bthen\b/i,
|
||||
/\bstep\s*\d/i,
|
||||
/\bphase\s*\d/i,
|
||||
/\bstage\s*\d/i,
|
||||
/^\s*\d+[\.\)]/m, // numbered list items ("1. …", "2) …")
|
||||
|
||||
// Coordination language — must be an imperative directive aimed at the agents
|
||||
// ("collaborate with X", "coordinate the team", "agents should coordinate"),
|
||||
// not a descriptive use ("how does X coordinate with Y" / "what does collaboration mean").
|
||||
// Match either an explicit preposition or a noun-phrase that names a group.
|
||||
/\bcollaborat(?:e|ing)\b\s+(?:with|on|to)\b/i,
|
||||
/\bcoordinat(?:e|ing)\b\s+(?:with|on|across|between|the\s+(?:team|agents?|workers?|effort|work))\b/i,
|
||||
/\breview\s+each\s+other/i,
|
||||
/\bwork\s+together\b/i,
|
||||
|
||||
// Parallel execution
|
||||
/\bin\s+parallel\b/i,
|
||||
/\bconcurrently\b/i,
|
||||
/\bat\s+the\s+same\s+time\b/i,
|
||||
|
||||
// Multiple deliverables joined by connectives
|
||||
// Matches patterns like "build X, then deploy Y and test Z"
|
||||
/\b(?:build|create|implement|design|write|develop)\b.{5,80}\b(?:and|then)\b.{5,80}\b(?:build|create|implement|design|write|develop|test|review|deploy)\b/i,
|
||||
]
|
||||
|
||||
|
||||
/**
|
||||
* Maximum goal length (in characters) below which a goal *may* be simple.
|
||||
*
|
||||
* Goals longer than this threshold almost always contain enough detail to
|
||||
* warrant multi-agent decomposition. The value is generous — short-circuit
|
||||
* is meant for genuinely simple, single-action goals.
|
||||
*/
|
||||
const SIMPLE_GOAL_MAX_LENGTH = 200
|
||||
|
||||
/**
|
||||
* Determine whether a goal is simple enough to skip coordinator decomposition.
|
||||
*
|
||||
* A goal is considered "simple" when ALL of the following hold:
|
||||
* 1. Its length is ≤ {@link SIMPLE_GOAL_MAX_LENGTH}.
|
||||
* 2. It does not match any {@link COMPLEXITY_PATTERNS}.
|
||||
*
|
||||
* The complexity patterns are deliberately conservative — they only fire on
|
||||
* imperative coordination directives (e.g. "collaborate with the team",
|
||||
* "coordinate the workers"), so descriptive uses ("how do pods coordinate
|
||||
* state", "explain microservice collaboration") remain classified as simple.
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function isSimpleGoal(goal: string): boolean {
|
||||
if (goal.length > SIMPLE_GOAL_MAX_LENGTH) return false
|
||||
return !COMPLEXITY_PATTERNS.some((re) => re.test(goal))
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the best-matching agent for a goal using keyword affinity scoring.
|
||||
*
|
||||
* The scoring logic mirrors {@link Scheduler}'s `capability-match` strategy
|
||||
* exactly, including its asymmetric use of the agent's `model` field:
|
||||
*
|
||||
* - `agentKeywords` is computed from `name + systemPrompt + model` so that
|
||||
* a goal which mentions a model name (e.g. "haiku") can boost an agent
|
||||
* bound to that model.
|
||||
* - `agentText` (used for the reverse direction) is computed from
|
||||
* `name + systemPrompt` only — model names should not bias the
|
||||
* text-vs-goal-keywords match.
|
||||
*
|
||||
* The two-direction sum (`scoreA + scoreB`) ensures both "agent describes
|
||||
* goal" and "goal mentions agent capability" contribute to the final score.
|
||||
*
|
||||
* Exported for unit testing.
|
||||
*/
|
||||
export function selectBestAgent(goal: string, agents: AgentConfig[]): AgentConfig {
|
||||
if (agents.length <= 1) return agents[0]!
|
||||
|
||||
const goalKeywords = extractKeywords(goal)
|
||||
|
||||
let bestAgent = agents[0]!
|
||||
let bestScore = -1
|
||||
|
||||
for (const agent of agents) {
|
||||
const agentText = `${agent.name} ${agent.systemPrompt ?? ''}`
|
||||
// Mirror Scheduler.capability-match: include `model` here only.
|
||||
const agentKeywords = extractKeywords(`${agent.name} ${agent.systemPrompt ?? ''} ${agent.model}`)
|
||||
|
||||
const scoreA = keywordScore(agentText, goalKeywords)
|
||||
const scoreB = keywordScore(goal, agentKeywords)
|
||||
const score = scoreA + scoreB
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestAgent = agent
|
||||
}
|
||||
}
|
||||
|
||||
return bestAgent
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -624,7 +738,11 @@ export class OpenMultiAgent {
|
|||
* @param config - Agent configuration.
|
||||
* @param prompt - The user prompt to send.
|
||||
*/
|
||||
async runAgent(config: AgentConfig, prompt: string): Promise<AgentRunResult> {
|
||||
async runAgent(
|
||||
config: AgentConfig,
|
||||
prompt: string,
|
||||
options?: { abortSignal?: AbortSignal },
|
||||
): Promise<AgentRunResult> {
|
||||
const effectiveBudget = resolveTokenBudget(config.maxTokenBudget, this.config.maxTokenBudget)
|
||||
const effective: AgentConfig = {
|
||||
...config,
|
||||
|
|
@ -640,11 +758,22 @@ export class OpenMultiAgent {
|
|||
data: { prompt },
|
||||
})
|
||||
|
||||
const traceOptions: Partial<RunOptions> | undefined = this.config.onTrace
|
||||
? { onTrace: this.config.onTrace, runId: generateRunId(), traceAgent: config.name }
|
||||
: undefined
|
||||
// Build run-time options: trace + optional abort signal. RunOptions has
|
||||
// readonly fields, so we assemble the literal in one shot.
|
||||
const traceFields = this.config.onTrace
|
||||
? {
|
||||
onTrace: this.config.onTrace,
|
||||
runId: generateRunId(),
|
||||
traceAgent: config.name,
|
||||
}
|
||||
: null
|
||||
const abortFields = options?.abortSignal ? { abortSignal: options.abortSignal } : null
|
||||
const runOptions: Partial<RunOptions> | undefined =
|
||||
traceFields || abortFields
|
||||
? { ...(traceFields ?? {}), ...(abortFields ?? {}) }
|
||||
: undefined
|
||||
|
||||
const result = await agent.run(prompt, traceOptions)
|
||||
const result = await agent.run(prompt, runOptions)
|
||||
|
||||
if (result.budgetExceeded) {
|
||||
this.config.onProgress?.({
|
||||
|
|
@ -699,6 +828,44 @@ export class OpenMultiAgent {
|
|||
async runTeam(team: Team, goal: string, options?: { abortSignal?: AbortSignal }): Promise<TeamRunResult> {
|
||||
const agentConfigs = team.getAgents()
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Short-circuit: skip coordinator for simple, single-action goals.
|
||||
//
|
||||
// When the goal is short and contains no multi-step / coordination
|
||||
// signals, dispatching it to a single agent is faster and cheaper
|
||||
// than spinning up a coordinator for decomposition + synthesis.
|
||||
//
|
||||
// The best-matching agent is selected via keyword affinity scoring
|
||||
// (same algorithm as the `capability-match` scheduler strategy).
|
||||
// ------------------------------------------------------------------
|
||||
if (agentConfigs.length > 0 && isSimpleGoal(goal)) {
|
||||
const bestAgent = selectBestAgent(goal, agentConfigs)
|
||||
|
||||
this.config.onProgress?.({
|
||||
type: 'agent_start',
|
||||
agent: bestAgent.name,
|
||||
data: { phase: 'short-circuit', goal },
|
||||
})
|
||||
|
||||
// Forward the caller's abort signal so short-circuit honours the same
|
||||
// cancellation contract as the full coordinator path.
|
||||
const result = await this.runAgent(
|
||||
bestAgent,
|
||||
goal,
|
||||
options?.abortSignal ? { abortSignal: options.abortSignal } : undefined,
|
||||
)
|
||||
const agentResults = new Map<string, AgentRunResult>()
|
||||
agentResults.set(bestAgent.name, result)
|
||||
|
||||
this.config.onProgress?.({
|
||||
type: 'agent_complete',
|
||||
agent: bestAgent.name,
|
||||
data: { phase: 'short-circuit', result },
|
||||
})
|
||||
|
||||
return this.buildTeamRunResult(agentResults)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Step 1: Coordinator decomposes goal into tasks
|
||||
// ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
|
||||
import type { AgentConfig, Task } from '../types.js'
|
||||
import type { TaskQueue } from '../task/queue.js'
|
||||
import { extractKeywords, keywordScore } from '../utils/keywords.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
|
|
@ -74,38 +75,6 @@ function countBlockedDependents(taskId: string, allTasks: Task[]): number {
|
|||
return visited.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a simple keyword-overlap score between `text` and `keywords`.
|
||||
*
|
||||
* Both the text and keywords are normalised to lower-case before comparison.
|
||||
* Each keyword that appears in the text contributes +1 to the score.
|
||||
*/
|
||||
function keywordScore(text: string, keywords: string[]): number {
|
||||
const lower = text.toLowerCase()
|
||||
return keywords.reduce((acc, kw) => acc + (lower.includes(kw.toLowerCase()) ? 1 : 0), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a list of meaningful keywords from a string for capability matching.
|
||||
*
|
||||
* Strips common stop-words so that incidental matches (e.g. "the", "and") do
|
||||
* not inflate scores. Returns unique words longer than three characters.
|
||||
*/
|
||||
function extractKeywords(text: string): string[] {
|
||||
const STOP_WORDS = new Set([
|
||||
'the', 'and', 'for', 'that', 'this', 'with', 'are', 'from', 'have',
|
||||
'will', 'your', 'you', 'can', 'all', 'each', 'when', 'then', 'they',
|
||||
'them', 'their', 'about', 'into', 'more', 'also', 'should', 'must',
|
||||
])
|
||||
|
||||
return [...new Set(
|
||||
text
|
||||
.toLowerCase()
|
||||
.split(/\W+/)
|
||||
.filter((w) => w.length > 3 && !STOP_WORDS.has(w)),
|
||||
)]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scheduler
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -93,13 +93,17 @@ export function defineTool<TInput>(config: {
|
|||
export class ToolRegistry {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private readonly tools = new Map<string, ToolDefinition<any>>()
|
||||
private readonly runtimeToolNames = new Set<string>()
|
||||
|
||||
/**
|
||||
* Add a tool to the registry. Throws if a tool with the same name has
|
||||
* already been registered — prevents silent overwrites.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
register(tool: ToolDefinition<any>): void {
|
||||
register(
|
||||
tool: ToolDefinition<any>,
|
||||
options?: { runtimeAdded?: boolean },
|
||||
): void {
|
||||
if (this.tools.has(tool.name)) {
|
||||
throw new Error(
|
||||
`ToolRegistry: a tool named "${tool.name}" is already registered. ` +
|
||||
|
|
@ -107,6 +111,9 @@ export class ToolRegistry {
|
|||
)
|
||||
}
|
||||
this.tools.set(tool.name, tool)
|
||||
if (options?.runtimeAdded === true) {
|
||||
this.runtimeToolNames.add(tool.name)
|
||||
}
|
||||
}
|
||||
|
||||
/** Return a tool by name, or `undefined` if not found. */
|
||||
|
|
@ -147,11 +154,12 @@ export class ToolRegistry {
|
|||
*/
|
||||
unregister(name: string): void {
|
||||
this.tools.delete(name)
|
||||
this.runtimeToolNames.delete(name)
|
||||
}
|
||||
|
||||
/** Alias for {@link unregister} — available for symmetry with `register`. */
|
||||
deregister(name: string): void {
|
||||
this.tools.delete(name)
|
||||
this.unregister(name)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -170,6 +178,14 @@ export class ToolRegistry {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Return only tools that were added dynamically at runtime (e.g. via
|
||||
* `agent.addTool()`), in LLM definition format.
|
||||
*/
|
||||
toRuntimeToolDefs(): LLMToolDef[] {
|
||||
return this.toToolDefs().filter(tool => this.runtimeToolNames.has(tool.name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert all registered tools to the Anthropic-style `input_schema`
|
||||
* format. Prefer {@link toToolDefs} for normal use; this method is exposed
|
||||
|
|
|
|||
|
|
@ -207,6 +207,10 @@ export interface AgentConfig {
|
|||
readonly systemPrompt?: string
|
||||
/** Names of tools (from the tool registry) available to this agent. */
|
||||
readonly tools?: readonly string[]
|
||||
/** Names of tools explicitly disallowed for this agent. */
|
||||
readonly disallowedTools?: readonly string[]
|
||||
/** Predefined tool preset for common use cases. */
|
||||
readonly toolPreset?: 'readonly' | 'readwrite' | 'full'
|
||||
readonly maxTurns?: number
|
||||
readonly maxTokens?: number
|
||||
/** Maximum cumulative tokens (input + output) allowed for this run. */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
* Shared keyword-affinity helpers used by capability-match scheduling
|
||||
* and short-circuit agent selection. Kept in one place so behaviour
|
||||
* can't drift between Scheduler and Orchestrator.
|
||||
*/
|
||||
|
||||
export const STOP_WORDS: ReadonlySet<string> = new Set([
|
||||
'the', 'and', 'for', 'that', 'this', 'with', 'are', 'from', 'have',
|
||||
'will', 'your', 'you', 'can', 'all', 'each', 'when', 'then', 'they',
|
||||
'them', 'their', 'about', 'into', 'more', 'also', 'should', 'must',
|
||||
])
|
||||
|
||||
/**
|
||||
* Tokenise `text` into a deduplicated set of lower-cased keywords.
|
||||
* Words shorter than 4 characters and entries in {@link STOP_WORDS}
|
||||
* are filtered out.
|
||||
*/
|
||||
export function extractKeywords(text: string): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
text
|
||||
.toLowerCase()
|
||||
.split(/\W+/)
|
||||
.filter((w) => w.length > 3 && !STOP_WORDS.has(w)),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many `keywords` appear (case-insensitively) in `text`.
|
||||
* Each keyword contributes at most 1 to the score.
|
||||
*/
|
||||
export function keywordScore(text: string, keywords: readonly string[]): number {
|
||||
const lower = text.toLowerCase()
|
||||
return keywords.reduce(
|
||||
(acc, kw) => acc + (lower.includes(kw.toLowerCase()) ? 1 : 0),
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { STOP_WORDS, extractKeywords, keywordScore } from '../src/utils/keywords.js'
|
||||
|
||||
// Regression coverage for the shared keyword helpers extracted from
|
||||
// orchestrator.ts and scheduler.ts (PR #70 review point 1).
|
||||
//
|
||||
// These tests pin behaviour so future drift between Scheduler and the
|
||||
// short-circuit selector is impossible — any edit must update the shared
|
||||
// module and these tests at once.
|
||||
|
||||
describe('utils/keywords', () => {
|
||||
describe('STOP_WORDS', () => {
|
||||
it('contains all 26 stop words', () => {
|
||||
// Sanity-check the canonical list — if anyone adds/removes a stop word
|
||||
// they should also update this assertion.
|
||||
expect(STOP_WORDS.size).toBe(26)
|
||||
})
|
||||
|
||||
it('includes "then" and "and" so they cannot dominate scoring', () => {
|
||||
expect(STOP_WORDS.has('then')).toBe(true)
|
||||
expect(STOP_WORDS.has('and')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractKeywords', () => {
|
||||
it('lowercases and dedupes', () => {
|
||||
const out = extractKeywords('TypeScript typescript TYPESCRIPT')
|
||||
expect(out).toEqual(['typescript'])
|
||||
})
|
||||
|
||||
it('drops words shorter than 4 characters', () => {
|
||||
const out = extractKeywords('a bb ccc dddd eeeee')
|
||||
expect(out).toEqual(['dddd', 'eeeee'])
|
||||
})
|
||||
|
||||
it('drops stop words', () => {
|
||||
const out = extractKeywords('the cat and the dog have meals')
|
||||
// 'cat', 'dog', 'have' filtered: 'cat'/'dog' too short, 'have' is a stop word
|
||||
expect(out).toEqual(['meals'])
|
||||
})
|
||||
|
||||
it('splits on non-word characters', () => {
|
||||
const out = extractKeywords('hello,world!writer-mode')
|
||||
expect(out.sort()).toEqual(['hello', 'mode', 'world', 'writer'])
|
||||
})
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(extractKeywords('')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('keywordScore', () => {
|
||||
it('counts each keyword at most once', () => {
|
||||
// 'code' appears twice in the text but contributes 1
|
||||
expect(keywordScore('code review code style', ['code'])).toBe(1)
|
||||
})
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(keywordScore('TYPESCRIPT', ['typescript'])).toBe(1)
|
||||
expect(keywordScore('typescript', ['TYPESCRIPT'])).toBe(1)
|
||||
})
|
||||
|
||||
it('returns 0 when no keywords match', () => {
|
||||
expect(keywordScore('hello world', ['rust', 'go'])).toBe(0)
|
||||
})
|
||||
|
||||
it('sums distinct keyword hits', () => {
|
||||
expect(keywordScore('write typescript code for the api', ['typescript', 'code', 'rust'])).toBe(2)
|
||||
})
|
||||
|
||||
it('returns 0 for empty keywords array', () => {
|
||||
expect(keywordScore('any text', [])).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -215,7 +215,7 @@ describe('OpenMultiAgent', () => {
|
|||
})
|
||||
const team = oma.createTeam('t', teamCfg())
|
||||
|
||||
const result = await oma.runTeam(team, 'Research AI safety')
|
||||
const result = await oma.runTeam(team, 'First research AI safety best practices, then write a comprehensive implementation guide')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
// Should have coordinator result
|
||||
|
|
@ -233,7 +233,7 @@ describe('OpenMultiAgent', () => {
|
|||
const oma = new OpenMultiAgent({ defaultModel: 'mock-model' })
|
||||
const team = oma.createTeam('t', teamCfg())
|
||||
|
||||
const result = await oma.runTeam(team, 'Do something')
|
||||
const result = await oma.runTeam(team, 'First design the database schema, then implement the REST API endpoints')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,432 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { isSimpleGoal, selectBestAgent } from '../src/orchestrator/orchestrator.js'
|
||||
import { OpenMultiAgent } from '../src/orchestrator/orchestrator.js'
|
||||
import type {
|
||||
AgentConfig,
|
||||
LLMChatOptions,
|
||||
LLMMessage,
|
||||
LLMResponse,
|
||||
OrchestratorEvent,
|
||||
TeamConfig,
|
||||
} from '../src/types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isSimpleGoal — pure function tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('isSimpleGoal', () => {
|
||||
describe('returns true for simple goals', () => {
|
||||
const simpleGoals = [
|
||||
'Say hello',
|
||||
'What is 2 + 2?',
|
||||
'Explain monads in one paragraph',
|
||||
'Translate this to French: Good morning',
|
||||
'List 3 blockchain security vulnerabilities',
|
||||
'Write a haiku about TypeScript',
|
||||
'Summarize this article',
|
||||
'你好,回一个字:哈',
|
||||
'Fix the typo in the README',
|
||||
]
|
||||
|
||||
for (const goal of simpleGoals) {
|
||||
it(`"${goal}"`, () => {
|
||||
expect(isSimpleGoal(goal)).toBe(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('returns false for complex goals', () => {
|
||||
it('goal with explicit sequencing (first…then)', () => {
|
||||
expect(isSimpleGoal('First design the API schema, then implement the endpoints')).toBe(false)
|
||||
})
|
||||
|
||||
it('goal with numbered steps', () => {
|
||||
expect(isSimpleGoal('1. Design the schema\n2. Implement the API\n3. Write tests')).toBe(false)
|
||||
})
|
||||
|
||||
it('goal with step N pattern', () => {
|
||||
expect(isSimpleGoal('Step 1: set up the project. Step 2: write the code.')).toBe(false)
|
||||
})
|
||||
|
||||
it('goal with collaboration language', () => {
|
||||
expect(isSimpleGoal('Collaborate on building a REST API with tests')).toBe(false)
|
||||
})
|
||||
|
||||
it('goal with coordination language', () => {
|
||||
expect(isSimpleGoal('Coordinate the team to build and deploy the service')).toBe(false)
|
||||
})
|
||||
|
||||
it('goal with parallel execution', () => {
|
||||
expect(isSimpleGoal('Run the linter and tests in parallel')).toBe(false)
|
||||
})
|
||||
|
||||
it('goal with multiple deliverables (build…and…test)', () => {
|
||||
expect(isSimpleGoal('Build the REST API endpoints and then write comprehensive integration tests for each one')).toBe(false)
|
||||
})
|
||||
|
||||
it('goal exceeding max length', () => {
|
||||
const longGoal = 'Explain the concept of ' + 'a'.repeat(200)
|
||||
expect(isSimpleGoal(longGoal)).toBe(false)
|
||||
})
|
||||
|
||||
it('goal with phase markers', () => {
|
||||
expect(isSimpleGoal('Phase 1 is planning, phase 2 is execution')).toBe(false)
|
||||
})
|
||||
|
||||
it('goal with "work together"', () => {
|
||||
expect(isSimpleGoal('Work together to build the frontend and backend')).toBe(false)
|
||||
})
|
||||
|
||||
it('goal with "review each other"', () => {
|
||||
expect(isSimpleGoal('Write code and review each other\'s pull requests')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('empty string is simple', () => {
|
||||
expect(isSimpleGoal('')).toBe(true)
|
||||
})
|
||||
|
||||
it('"and" alone does not trigger complexity', () => {
|
||||
// Unlike the original turbo implementation, common words like "and"
|
||||
// should NOT flag a goal as complex.
|
||||
expect(isSimpleGoal('Pros and cons of TypeScript')).toBe(true)
|
||||
})
|
||||
|
||||
it('"then" alone does not trigger complexity', () => {
|
||||
expect(isSimpleGoal('What happened then?')).toBe(true)
|
||||
})
|
||||
|
||||
it('"summarize" alone does not trigger complexity', () => {
|
||||
expect(isSimpleGoal('Summarize the article about AI safety')).toBe(true)
|
||||
})
|
||||
|
||||
it('"analyze" alone does not trigger complexity', () => {
|
||||
expect(isSimpleGoal('Analyze this error log')).toBe(true)
|
||||
})
|
||||
|
||||
it('goal exactly at length boundary (200) is simple if no patterns', () => {
|
||||
const goal = 'x'.repeat(200)
|
||||
expect(isSimpleGoal(goal)).toBe(true)
|
||||
})
|
||||
|
||||
it('goal at 201 chars is complex', () => {
|
||||
const goal = 'x'.repeat(201)
|
||||
expect(isSimpleGoal(goal)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Regression: tightened coordinate/collaborate regex (PR #70 review point 5)
|
||||
//
|
||||
// Descriptive uses of "coordinate" / "collaborate" / "collaboration" must
|
||||
// NOT be flagged as complex — only imperative directives aimed at agents.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
describe('tightened coordinate/collaborate patterns', () => {
|
||||
it('descriptive "how X coordinates" is simple', () => {
|
||||
expect(isSimpleGoal('Explain how Kubernetes pods coordinate state')).toBe(true)
|
||||
})
|
||||
|
||||
it('descriptive "collaboration" noun is simple', () => {
|
||||
expect(isSimpleGoal('What is microservice collaboration?')).toBe(true)
|
||||
})
|
||||
|
||||
it('descriptive "team that coordinates" is simple', () => {
|
||||
expect(isSimpleGoal('Describe a team that coordinates releases')).toBe(true)
|
||||
})
|
||||
|
||||
it('descriptive "without collaborating" is simple', () => {
|
||||
expect(isSimpleGoal('Show how to deploy without collaborating')).toBe(true)
|
||||
})
|
||||
|
||||
it('imperative "collaborate with X" is complex', () => {
|
||||
expect(isSimpleGoal('Collaborate with the writer to draft a post')).toBe(false)
|
||||
})
|
||||
|
||||
it('imperative "coordinate the team" is complex', () => {
|
||||
expect(isSimpleGoal('Coordinate the team for release')).toBe(false)
|
||||
})
|
||||
|
||||
it('imperative "coordinate across services" is complex', () => {
|
||||
expect(isSimpleGoal('Coordinate across services to roll out the change')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// selectBestAgent — keyword affinity scoring
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('selectBestAgent', () => {
|
||||
it('selects agent whose systemPrompt best matches the goal', () => {
|
||||
const agents: AgentConfig[] = [
|
||||
{ name: 'researcher', model: 'test', systemPrompt: 'You are a research expert who analyzes data and writes reports' },
|
||||
{ name: 'coder', model: 'test', systemPrompt: 'You are a software engineer who writes TypeScript code' },
|
||||
]
|
||||
|
||||
expect(selectBestAgent('Write TypeScript code for the API', agents)).toBe(agents[1])
|
||||
expect(selectBestAgent('Research the latest AI papers', agents)).toBe(agents[0])
|
||||
})
|
||||
|
||||
it('falls back to first agent when no keywords match', () => {
|
||||
const agents: AgentConfig[] = [
|
||||
{ name: 'alpha', model: 'test' },
|
||||
{ name: 'beta', model: 'test' },
|
||||
]
|
||||
|
||||
expect(selectBestAgent('xyzzy', agents)).toBe(agents[0])
|
||||
})
|
||||
|
||||
it('returns the only agent when team has one member', () => {
|
||||
const agents: AgentConfig[] = [
|
||||
{ name: 'solo', model: 'test', systemPrompt: 'General purpose agent' },
|
||||
]
|
||||
|
||||
expect(selectBestAgent('anything', agents)).toBe(agents[0])
|
||||
})
|
||||
|
||||
it('considers agent name in scoring', () => {
|
||||
const agents: AgentConfig[] = [
|
||||
{ name: 'writer', model: 'test', systemPrompt: 'You help with tasks' },
|
||||
{ name: 'reviewer', model: 'test', systemPrompt: 'You help with tasks' },
|
||||
]
|
||||
|
||||
// "review" should match "reviewer" agent name
|
||||
expect(selectBestAgent('Review this pull request', agents)).toBe(agents[1])
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Regression: model field asymmetry (PR #70 review point 2)
|
||||
//
|
||||
// selectBestAgent must mirror Scheduler.capability-match exactly:
|
||||
// - agentKeywords includes `model`
|
||||
// - agentText excludes `model`
|
||||
// This means a goal that mentions a model name should boost the agent
|
||||
// bound to that model (via scoreB), even if neither name nor system prompt
|
||||
// contains the keyword.
|
||||
// -------------------------------------------------------------------------
|
||||
it('matches scheduler asymmetry: model name in goal boosts the bound agent', () => {
|
||||
const agents: AgentConfig[] = [
|
||||
// Distinct, non-overlapping prompts so neither one wins on scoreA
|
||||
{ name: 'a1', model: 'haiku-fast-model', systemPrompt: 'You handle quick lookups' },
|
||||
{ name: 'a2', model: 'opus-deep-model', systemPrompt: 'You handle deep analysis' },
|
||||
]
|
||||
|
||||
// Mention "haiku" — this is only present in a1.model, so the bound
|
||||
// agent should win because agentKeywords (which includes model) matches.
|
||||
expect(selectBestAgent('Use the haiku model please', agents)).toBe(agents[0])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// runTeam short-circuit integration test
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let mockAdapterResponses: string[] = []
|
||||
|
||||
vi.mock('../src/llm/adapter.js', () => ({
|
||||
createAdapter: async () => {
|
||||
let callIndex = 0
|
||||
return {
|
||||
name: 'mock',
|
||||
async chat(_msgs: LLMMessage[], options: LLMChatOptions): Promise<LLMResponse> {
|
||||
const text = mockAdapterResponses[callIndex] ?? 'default mock response'
|
||||
callIndex++
|
||||
return {
|
||||
id: `resp-${callIndex}`,
|
||||
content: [{ type: 'text', text }],
|
||||
model: options.model ?? 'mock-model',
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 10, output_tokens: 20 },
|
||||
}
|
||||
},
|
||||
async *stream() {
|
||||
yield { type: 'done' as const, data: {} }
|
||||
},
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
function agentConfig(name: string, systemPrompt?: string): AgentConfig {
|
||||
return {
|
||||
name,
|
||||
model: 'mock-model',
|
||||
provider: 'openai',
|
||||
systemPrompt: systemPrompt ?? `You are ${name}.`,
|
||||
}
|
||||
}
|
||||
|
||||
function teamCfg(agents?: AgentConfig[]): TeamConfig {
|
||||
return {
|
||||
name: 'test-team',
|
||||
agents: agents ?? [
|
||||
agentConfig('researcher', 'You research topics and analyze data'),
|
||||
agentConfig('coder', 'You write TypeScript code'),
|
||||
],
|
||||
sharedMemory: true,
|
||||
}
|
||||
}
|
||||
|
||||
describe('runTeam short-circuit', () => {
|
||||
beforeEach(() => {
|
||||
mockAdapterResponses = []
|
||||
})
|
||||
|
||||
it('short-circuits simple goals to a single agent (no coordinator)', async () => {
|
||||
// Only ONE response needed — no coordinator decomposition or synthesis
|
||||
mockAdapterResponses = ['Direct answer without coordination']
|
||||
|
||||
const events: OrchestratorEvent[] = []
|
||||
const oma = new OpenMultiAgent({
|
||||
defaultModel: 'mock-model',
|
||||
onProgress: (e) => events.push(e),
|
||||
})
|
||||
const team = oma.createTeam('t', teamCfg())
|
||||
|
||||
const result = await oma.runTeam(team, 'Say hello')
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.agentResults.size).toBe(1)
|
||||
// Should NOT have coordinator results — short-circuit bypasses it
|
||||
expect(result.agentResults.has('coordinator')).toBe(false)
|
||||
})
|
||||
|
||||
it('emits progress events for short-circuit path', async () => {
|
||||
mockAdapterResponses = ['done']
|
||||
|
||||
const events: OrchestratorEvent[] = []
|
||||
const oma = new OpenMultiAgent({
|
||||
defaultModel: 'mock-model',
|
||||
onProgress: (e) => events.push(e),
|
||||
})
|
||||
const team = oma.createTeam('t', teamCfg())
|
||||
|
||||
await oma.runTeam(team, 'Say hello')
|
||||
|
||||
const types = events.map(e => e.type)
|
||||
expect(types).toContain('agent_start')
|
||||
expect(types).toContain('agent_complete')
|
||||
})
|
||||
|
||||
it('uses coordinator for complex goals', async () => {
|
||||
// Complex goal — needs coordinator decomposition + execution + synthesis
|
||||
mockAdapterResponses = [
|
||||
'```json\n[{"title": "Research", "description": "Research the topic", "assignee": "researcher"}]\n```',
|
||||
'Research results',
|
||||
'Final synthesis',
|
||||
]
|
||||
|
||||
const oma = new OpenMultiAgent({ defaultModel: 'mock-model' })
|
||||
const team = oma.createTeam('t', teamCfg())
|
||||
|
||||
const result = await oma.runTeam(
|
||||
team,
|
||||
'First research AI safety best practices, then write a comprehensive guide with code examples',
|
||||
)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
// Complex goal should go through coordinator
|
||||
expect(result.agentResults.has('coordinator')).toBe(true)
|
||||
})
|
||||
|
||||
it('selects best-matching agent for simple goals', async () => {
|
||||
mockAdapterResponses = ['code result']
|
||||
|
||||
const events: OrchestratorEvent[] = []
|
||||
const oma = new OpenMultiAgent({
|
||||
defaultModel: 'mock-model',
|
||||
onProgress: (e) => events.push(e),
|
||||
})
|
||||
const team = oma.createTeam('t', teamCfg())
|
||||
|
||||
await oma.runTeam(team, 'Write TypeScript code')
|
||||
|
||||
// Should pick 'coder' agent based on keyword match
|
||||
const startEvent = events.find(e => e.type === 'agent_start')
|
||||
expect(startEvent?.agent).toBe('coder')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Regression: abortSignal forwarding (PR #70 review point 4)
|
||||
//
|
||||
// The short-circuit path must forward `options.abortSignal` from runTeam
|
||||
// through to runAgent, otherwise simple-goal cancellations are silently
|
||||
// ignored — a regression vs the full coordinator path which already
|
||||
// honours the signal via PR #69.
|
||||
// -------------------------------------------------------------------------
|
||||
it('forwards abortSignal from runTeam to runAgent in short-circuit path', async () => {
|
||||
mockAdapterResponses = ['done']
|
||||
|
||||
const oma = new OpenMultiAgent({ defaultModel: 'mock-model' })
|
||||
const team = oma.createTeam('t', teamCfg())
|
||||
|
||||
// Spy on runAgent to capture the options argument
|
||||
const runAgentSpy = vi.spyOn(oma, 'runAgent')
|
||||
|
||||
const controller = new AbortController()
|
||||
await oma.runTeam(team, 'Say hello', { abortSignal: controller.signal })
|
||||
|
||||
expect(runAgentSpy).toHaveBeenCalledTimes(1)
|
||||
const callArgs = runAgentSpy.mock.calls[0]!
|
||||
// Third positional arg must contain the same signal we passed in
|
||||
expect(callArgs[2]).toBeDefined()
|
||||
expect(callArgs[2]?.abortSignal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('runAgent invoked without abortSignal when caller omits it', async () => {
|
||||
mockAdapterResponses = ['done']
|
||||
|
||||
const oma = new OpenMultiAgent({ defaultModel: 'mock-model' })
|
||||
const team = oma.createTeam('t', teamCfg())
|
||||
|
||||
const runAgentSpy = vi.spyOn(oma, 'runAgent')
|
||||
|
||||
await oma.runTeam(team, 'Say hello')
|
||||
|
||||
expect(runAgentSpy).toHaveBeenCalledTimes(1)
|
||||
const callArgs = runAgentSpy.mock.calls[0]!
|
||||
// Third positional arg should be undefined when caller doesn't pass one
|
||||
expect(callArgs[2]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('aborted signal causes the underlying agent loop to skip the LLM call', async () => {
|
||||
// Pre-aborted controller — runner should break before any chat() call
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
mockAdapterResponses = ['should never be returned']
|
||||
|
||||
const oma = new OpenMultiAgent({ defaultModel: 'mock-model' })
|
||||
const team = oma.createTeam('t', teamCfg())
|
||||
|
||||
const result = await oma.runTeam(team, 'Say hello', { abortSignal: controller.signal })
|
||||
|
||||
// Short-circuit ran one agent, but its loop bailed before any LLM call,
|
||||
// so the agent's output is the empty string and token usage is zero.
|
||||
const agentResult = result.agentResults.values().next().value
|
||||
expect(agentResult?.output).toBe('')
|
||||
expect(agentResult?.tokenUsage.input_tokens).toBe(0)
|
||||
expect(agentResult?.tokenUsage.output_tokens).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API surface — internal helpers must stay out of the barrel export
|
||||
// (PR #70 review point 3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('public API barrel', () => {
|
||||
it('does not re-export isSimpleGoal or selectBestAgent', async () => {
|
||||
const indexExports = await import('../src/index.js')
|
||||
expect((indexExports as Record<string, unknown>).isSimpleGoal).toBeUndefined()
|
||||
expect((indexExports as Record<string, unknown>).selectBestAgent).toBeUndefined()
|
||||
})
|
||||
|
||||
it('still re-exports the documented public symbols', async () => {
|
||||
const indexExports = await import('../src/index.js')
|
||||
expect(indexExports.OpenMultiAgent).toBeDefined()
|
||||
expect(indexExports.executeWithRetry).toBeDefined()
|
||||
expect(indexExports.computeRetryDelay).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
|
@ -238,7 +238,10 @@ describe('token budget enforcement', () => {
|
|||
sharedMemory: false,
|
||||
})
|
||||
|
||||
const result = await oma.runTeam(team, 'Do work')
|
||||
// Use a goal that explicitly mentions sequencing so the short-circuit
|
||||
// path is skipped and the coordinator decomposition + execution flow
|
||||
// (which this test is exercising) actually runs.
|
||||
const result = await oma.runTeam(team, 'First plan the work, then execute it')
|
||||
expect(result.totalTokenUsage.input_tokens + result.totalTokenUsage.output_tokens).toBe(70)
|
||||
expect(events.some(e => e.type === 'budget_exceeded')).toBe(true)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,328 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { AgentRunner, TOOL_PRESETS } from '../src/agent/runner.js'
|
||||
import { ToolRegistry, defineTool } from '../src/tool/framework.js'
|
||||
import { ToolExecutor } from '../src/tool/executor.js'
|
||||
import { z } from 'zod'
|
||||
import type { LLMAdapter, LLMResponse, LLMToolDef } from '../src/types.js'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock adapter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockAdapter: LLMAdapter = {
|
||||
name: 'mock',
|
||||
async chat() {
|
||||
return {
|
||||
id: 'mock-1',
|
||||
content: [{ type: 'text', text: 'response' }],
|
||||
model: 'mock-model',
|
||||
stop_reason: 'end_turn',
|
||||
usage: { input_tokens: 10, output_tokens: 20 },
|
||||
} satisfies LLMResponse
|
||||
},
|
||||
async *stream() {
|
||||
// Not used in these tests
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test tools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createTestTools() {
|
||||
const registry = new ToolRegistry()
|
||||
|
||||
// Register test tools that match our presets
|
||||
registry.register(defineTool({
|
||||
name: 'file_read',
|
||||
description: 'Read file',
|
||||
inputSchema: z.object({ path: z.string() }),
|
||||
execute: async () => ({ data: 'content', isError: false }),
|
||||
}))
|
||||
|
||||
registry.register(defineTool({
|
||||
name: 'file_write',
|
||||
description: 'Write file',
|
||||
inputSchema: z.object({ path: z.string(), content: z.string() }),
|
||||
execute: async () => ({ data: 'ok', isError: false }),
|
||||
}))
|
||||
|
||||
registry.register(defineTool({
|
||||
name: 'file_edit',
|
||||
description: 'Edit file',
|
||||
inputSchema: z.object({ path: z.string(), oldString: z.string(), newString: z.string() }),
|
||||
execute: async () => ({ data: 'ok', isError: false }),
|
||||
}))
|
||||
|
||||
registry.register(defineTool({
|
||||
name: 'grep',
|
||||
description: 'Search text',
|
||||
inputSchema: z.object({ pattern: z.string(), path: z.string() }),
|
||||
execute: async () => ({ data: 'matches', isError: false }),
|
||||
}))
|
||||
|
||||
registry.register(defineTool({
|
||||
name: 'bash',
|
||||
description: 'Run shell command',
|
||||
inputSchema: z.object({ command: z.string() }),
|
||||
execute: async () => ({ data: 'output', isError: false }),
|
||||
}))
|
||||
|
||||
// Extra tool not in any preset
|
||||
registry.register(defineTool({
|
||||
name: 'custom_tool',
|
||||
description: 'Custom tool',
|
||||
inputSchema: z.object({ input: z.string() }),
|
||||
execute: async () => ({ data: 'custom', isError: false }),
|
||||
}), { runtimeAdded: true })
|
||||
|
||||
return registry
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tool filtering', () => {
|
||||
const registry = createTestTools()
|
||||
const executor = new ToolExecutor(registry)
|
||||
|
||||
describe('TOOL_PRESETS', () => {
|
||||
it('readonly preset has correct tools', () => {
|
||||
expect(TOOL_PRESETS.readonly).toEqual(['file_read', 'grep', 'glob'])
|
||||
})
|
||||
|
||||
it('readwrite preset has correct tools', () => {
|
||||
expect(TOOL_PRESETS.readwrite).toEqual(['file_read', 'file_write', 'file_edit', 'grep', 'glob'])
|
||||
})
|
||||
|
||||
it('full preset has correct tools', () => {
|
||||
expect(TOOL_PRESETS.full).toEqual(['file_read', 'file_write', 'file_edit', 'grep', 'glob', 'bash'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTools - no filtering', () => {
|
||||
it('returns all tools when no filters are set', () => {
|
||||
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
||||
model: 'test-model',
|
||||
})
|
||||
|
||||
const tools = (runner as any).resolveTools() as LLMToolDef[]
|
||||
const toolNames = tools.map((t: LLMToolDef) => t.name).sort()
|
||||
|
||||
expect(toolNames).toEqual(['bash', 'custom_tool', 'file_edit', 'file_read', 'file_write', 'grep'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTools - preset filtering', () => {
|
||||
it('readonly preset filters correctly', () => {
|
||||
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
||||
model: 'test-model',
|
||||
toolPreset: 'readonly',
|
||||
})
|
||||
|
||||
const tools = (runner as any).resolveTools() as LLMToolDef[]
|
||||
const toolNames = tools.map((t: LLMToolDef) => t.name).sort()
|
||||
|
||||
expect(toolNames).toEqual(['custom_tool', 'file_read', 'grep'])
|
||||
})
|
||||
|
||||
it('readwrite preset filters correctly', () => {
|
||||
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
||||
model: 'test-model',
|
||||
toolPreset: 'readwrite',
|
||||
})
|
||||
|
||||
const tools = (runner as any).resolveTools() as LLMToolDef[]
|
||||
const toolNames = tools.map((t: LLMToolDef) => t.name).sort()
|
||||
|
||||
expect(toolNames).toEqual(['custom_tool', 'file_edit', 'file_read', 'file_write', 'grep'])
|
||||
})
|
||||
|
||||
it('full preset filters correctly', () => {
|
||||
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
||||
model: 'test-model',
|
||||
toolPreset: 'full',
|
||||
})
|
||||
|
||||
const tools = (runner as any).resolveTools() as LLMToolDef[]
|
||||
const toolNames = tools.map((t: LLMToolDef) => t.name).sort()
|
||||
|
||||
expect(toolNames).toEqual(['bash', 'custom_tool', 'file_edit', 'file_read', 'file_write', 'grep'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTools - allowlist filtering', () => {
|
||||
it('allowlist filters correctly', () => {
|
||||
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
||||
model: 'test-model',
|
||||
allowedTools: ['file_read', 'bash'],
|
||||
})
|
||||
|
||||
const tools = (runner as any).resolveTools() as LLMToolDef[]
|
||||
const toolNames = tools.map((t: LLMToolDef) => t.name).sort()
|
||||
|
||||
expect(toolNames).toEqual(['bash', 'custom_tool', 'file_read'])
|
||||
})
|
||||
|
||||
it('empty allowlist returns no tools', () => {
|
||||
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
||||
model: 'test-model',
|
||||
allowedTools: [],
|
||||
})
|
||||
|
||||
const tools = (runner as any).resolveTools()
|
||||
expect((tools as LLMToolDef[]).map(t => t.name)).toEqual(['custom_tool'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTools - denylist filtering', () => {
|
||||
it('denylist filters correctly', () => {
|
||||
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
||||
model: 'test-model',
|
||||
disallowedTools: ['bash', 'custom_tool'],
|
||||
})
|
||||
|
||||
const tools = (runner as any).resolveTools() as LLMToolDef[]
|
||||
const toolNames = tools.map((t: LLMToolDef) => t.name).sort()
|
||||
|
||||
expect(toolNames).toEqual(['custom_tool', 'file_edit', 'file_read', 'file_write', 'grep'])
|
||||
})
|
||||
|
||||
it('empty denylist returns all tools', () => {
|
||||
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
||||
model: 'test-model',
|
||||
disallowedTools: [],
|
||||
})
|
||||
|
||||
const tools = (runner as any).resolveTools()
|
||||
expect(tools).toHaveLength(6) // All registered tools
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTools - combined filtering (preset + allowlist + denylist)', () => {
|
||||
it('preset + allowlist + denylist work together', () => {
|
||||
// Start with readwrite preset: ['file_read', 'file_write', 'file_edit', 'grep']
|
||||
// Then allowlist: intersect with ['file_read', 'file_write', 'grep'] = ['file_read', 'file_write', 'grep']
|
||||
// Then denylist: subtract ['file_write'] = ['file_read', 'grep']
|
||||
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
||||
model: 'test-model',
|
||||
toolPreset: 'readwrite',
|
||||
allowedTools: ['file_read', 'file_write', 'grep'],
|
||||
disallowedTools: ['file_write'],
|
||||
})
|
||||
|
||||
const tools = (runner as any).resolveTools() as LLMToolDef[]
|
||||
const toolNames = tools.map((t: LLMToolDef) => t.name).sort()
|
||||
|
||||
expect(toolNames).toEqual(['custom_tool', 'file_read', 'grep'])
|
||||
})
|
||||
|
||||
it('preset filters first, then allowlist intersects, then denylist subtracts', () => {
|
||||
// Start with readonly preset: ['file_read', 'grep']
|
||||
// Allowlist intersect with ['file_read', 'bash']: ['file_read']
|
||||
// Denylist subtract ['file_read']: []
|
||||
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
||||
model: 'test-model',
|
||||
toolPreset: 'readonly',
|
||||
allowedTools: ['file_read', 'bash'],
|
||||
disallowedTools: ['file_read'],
|
||||
})
|
||||
|
||||
const tools = (runner as any).resolveTools()
|
||||
expect((tools as LLMToolDef[]).map(t => t.name)).toEqual(['custom_tool'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTools - custom tool behavior', () => {
|
||||
it('always includes custom tools regardless of filtering', () => {
|
||||
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
||||
model: 'test-model',
|
||||
toolPreset: 'readonly',
|
||||
allowedTools: ['file_read'],
|
||||
disallowedTools: ['file_read', 'bash', 'grep'],
|
||||
})
|
||||
|
||||
const tools = (runner as any).resolveTools() as LLMToolDef[]
|
||||
const toolNames = tools.map((t: LLMToolDef) => t.name).sort()
|
||||
|
||||
expect(toolNames).toEqual(['custom_tool'])
|
||||
})
|
||||
|
||||
it('runtime-added tools bypass filtering regardless of tool name', () => {
|
||||
const runtimeBuiltinNamedRegistry = new ToolRegistry()
|
||||
runtimeBuiltinNamedRegistry.register(defineTool({
|
||||
name: 'file_read',
|
||||
description: 'Runtime override',
|
||||
inputSchema: z.object({ path: z.string() }),
|
||||
execute: async () => ({ data: 'runtime', isError: false }),
|
||||
}), { runtimeAdded: true })
|
||||
|
||||
const runtimeBuiltinNamedRunner = new AgentRunner(
|
||||
mockAdapter,
|
||||
runtimeBuiltinNamedRegistry,
|
||||
new ToolExecutor(runtimeBuiltinNamedRegistry),
|
||||
{
|
||||
model: 'test-model',
|
||||
disallowedTools: ['file_read'],
|
||||
},
|
||||
)
|
||||
|
||||
const tools = (runtimeBuiltinNamedRunner as any).resolveTools() as LLMToolDef[]
|
||||
expect(tools.map(t => t.name)).toEqual(['file_read'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveTools - validation warnings', () => {
|
||||
let consoleWarnSpy: any
|
||||
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('warns when tool appears in both allowedTools and disallowedTools', () => {
|
||||
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
||||
model: 'test-model',
|
||||
allowedTools: ['file_read', 'bash'],
|
||||
disallowedTools: ['bash', 'grep'],
|
||||
})
|
||||
|
||||
;(runner as any).resolveTools()
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('tools ["bash"] appear in both allowedTools and disallowedTools')
|
||||
)
|
||||
})
|
||||
|
||||
it('warns when both toolPreset and allowedTools are set', () => {
|
||||
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
||||
model: 'test-model',
|
||||
toolPreset: 'readonly',
|
||||
allowedTools: ['file_read', 'bash'],
|
||||
})
|
||||
|
||||
;(runner as any).resolveTools()
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('both toolPreset and allowedTools are set')
|
||||
)
|
||||
})
|
||||
|
||||
it('does not warn when no overlap between allowedTools and disallowedTools', () => {
|
||||
const runner = new AgentRunner(mockAdapter, registry, executor, {
|
||||
model: 'test-model',
|
||||
allowedTools: ['file_read'],
|
||||
disallowedTools: ['bash'],
|
||||
})
|
||||
|
||||
;(runner as any).resolveTools()
|
||||
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -451,3 +451,4 @@ describe('Agent trace events', () => {
|
|||
expect(llmTraces[1]!.turn).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue