Compare commits

...

5 Commits

Author SHA1 Message Date
JackChen dc8cbe0262 chore: bump contrib.rocks cache version to refresh contributors 2026-04-08 02:08:32 +08:00
Ibrahim Kazimov 97c39b316c
feat: add tool allowlist, denylist, preset list (#83)
* feat: add allowlist denylist and preset list for tools

* feat: update readme and add AGENT_FRAMEWORK_DISALLOWED

* fix: update filtering logic to allow custom tools

* fix: enhance tool registration and filtering for runtime-added tools

---------

Co-authored-by: MrAvalonApple <74775400+ibrahimkazimov@users.noreply.github.com>
2026-04-08 02:04:40 +08:00
JackChen 48fbec6659
Merge pull request #70 from EchoOfZion/feature/smart-shortcircuit
feat: skip coordinator for simple goals in runTeam()
2026-04-07 23:52:16 +08:00
EchoOfZion 9463dbb28e refactor(orchestrator): address PR #70 review feedback
Addresses all five review points from @JackChen-me on PR #70:

1. Extract shared keyword helpers into src/utils/keywords.ts so the
   short-circuit selector and Scheduler.capability-match cannot drift.
   Both orchestrator.ts and scheduler.ts now import the same module.

2. selectBestAgent now mirrors Scheduler.capability-match exactly,
   including the asymmetric use of agent.model: agentKeywords includes
   model, agentText does not. This restores parity with the documented
   capability-match behaviour.

3. Remove isSimpleGoal and selectBestAgent from the public barrel
   (src/index.ts). They remain exported from orchestrator.ts for unit
   tests but are no longer part of the package API surface.

4. Forward the AbortSignal from runTeam(options) through the
   short-circuit path. runAgent() now accepts an optional
   { abortSignal } argument; runTeam's short-circuit branch passes
   the caller's signal so cancellation works for simple goals too.

5. Tighten the collaborate/coordinate complexity regexes so they only
   fire on imperative directives ("collaborate with X", "coordinate
   the team") and not on descriptive uses ("explain how pods
   coordinate", "what is microservice collaboration").

Also fixes a pre-existing test failure in token-budget.test.ts:
"enforces orchestrator budget in runTeam" was using "Do work" as its
goal which now short-circuits, so the coordinator path the test was
exercising never ran. Switched to a multi-step goal.

Adds 60 new tests across short-circuit.test.ts and the new
keywords.test.ts covering all five fixes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-07 21:46:03 +09:00
EchoOfZion cfbbd24601 feat: skip coordinator for simple goals in runTeam()
When a goal is short (<200 chars) and contains no multi-step or
coordination signals, runTeam() now dispatches directly to the
best-matching agent — skipping the coordinator decomposition and
synthesis round-trips. This saves ~2 LLM calls worth of tokens
and latency for genuinely simple goals.

Complexity detection uses regex patterns for sequencing markers
(first...then, step N, numbered lists), coordination language
(collaborate, coordinate, work together), parallel execution
signals, and multi-deliverable patterns.

Agent selection reuses the same keyword-affinity scoring as the
capability-match scheduler strategy to pick the most relevant
agent from the team roster.

- Add isSimpleGoal() and selectBestAgent() (exported for testing)
- Add 35 unit tests covering heuristic edge cases and integration
- Update existing runTeam tests to use complex goals

Co-Authored-By: Claude <noreply@anthropic.com>
2026-04-07 21:21:36 +09:00
15 changed files with 1222 additions and 60 deletions

View File

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

View File

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

View File

@ -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 })
}
/**

View File

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

View File

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

View File

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

View File

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

View File

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

39
src/utils/keywords.ts Normal file
View File

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

75
tests/keywords.test.ts Normal file
View File

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

View File

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

432
tests/short-circuit.test.ts Normal file
View File

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

View File

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

View File

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

View File

@ -451,3 +451,4 @@ describe('Agent trace events', () => {
expect(llmTraces[1]!.turn).toBe(2)
})
})