Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .changeset/wire-squad-route.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@bradygaster/squad-sdk': minor
---

fix(sdk): wire `squad_route` tool handler to actually create target sessions

The `squad_route` tool is documented in `packages/squad-sdk/README.md`,
`docs/src/content/docs/reference/sdk.md`,
`docs/src/content/docs/reference/api-reference.md`, and the
`build-autonomous-agent` guide as routing tasks and creating new sessions
Comment thread
jagilber marked this conversation as resolved.
Resolved
for the target agent. The previous implementation returned
`resultType: 'success'` with the hard-coded text "Session creation will be
implemented when session lifecycle is in place" without ever spawning a
session. SDK embedders following the documented usage
(`toolRegistry.getTool('squad_route').handler({ ... })`) received a
confident "Task routed to X" success payload while no agent ever started —
a silent no-op masquerading as success.

This change:

- Adds an optional 5th positional `fanOutDepsGetter` constructor parameter
to `ToolRegistry`. Existing 4-arg constructions are unaffected.
- When `fanOutDepsGetter` returns dependencies, `squad_route` calls
`spawnParallel` with a single config (charter compile → model resolve →
`createSession` → initial message), matching the SDK `Coordinator`
fan-out path.
- When `fanOutDepsGetter` is absent or returns `undefined`, the handler
returns `resultType: 'failure'` with `error: 'fan-out-deps-unavailable'`
and remediation guidance, replacing the previous false-success.
- Normalizes `targetAgent` to lowercase (matching charter-loading convention).
- Distinguishes roster "not found" errors from infrastructure I/O failures.
- Updates `docs/src/content/docs/guide/build-autonomous-agent.md` with a
note about the `fanOutDepsGetter` requirement and `fan-out-deps-unavailable`
failure mode.

Behavior change for embedders that relied on the stub's unconditional
success: those calls now return an honest failure. Remediation is to
either (a) construct `ToolRegistry` with a `fanOutDepsGetter`, or
(b) intercept `squad_route` via `SquadSessionHooks.onPreToolUse`.

Related: #1029 (named-agent delegation answering inline) — different code
path (Copilot CLI `task` tool / coordinator prompt), not closed by this PR,
but shares the same root-cause family of silent-no-op delegation primitives.
4 changes: 4 additions & 0 deletions docs/src/content/docs/guide/build-autonomous-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,10 @@ The autonomous-pipeline sample demonstrates three coordination patterns that age

These patterns let agents coordinate without a central orchestrator. Each agent makes local decisions that accumulate into a shared knowledge base.

:::note[`squad_route` requires `fanOutDepsGetter`]
For `squad_route` to actually spawn agent sessions, the `ToolRegistry` must be constructed with a `fanOutDepsGetter` callback that provides fan-out dependencies (`sessionPool`, `modelClient`, `squadRoot`, `configGetter`). Without it, the tool returns an honest `fan-out-deps-unavailable` error instead of silently succeeding. See the [SDK reference](/reference/sdk/#toolregistry) for wiring details.
:::

---

## Add observability
Expand Down
11 changes: 10 additions & 1 deletion docs/src/content/docs/reference/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,16 +288,25 @@ const myTool = defineTool<{ query: string }>({
Manage the built-in tool set.

```typescript
import { ToolRegistry } from '@bradygaster/squad-sdk/tools';
import type { FanOutDependencies } from '@bradygaster/squad-sdk/coordinator';

const registry = new ToolRegistry('./.squad');
const tools = registry.getTools();
const agentTools = registry.getToolsForAgent(['squad_route', 'squad_decide']);
```

**Constructor:** `new ToolRegistry(squadRoot?, sessionPoolGetter?, storage?, state?, fanOutDepsGetter?)`

- `fanOutDepsGetter` — Required for `squad_route` to spawn sessions via `spawnParallel`. Returns a `FanOutDependencies` object (from `@bradygaster/squad-sdk/coordinator`). Without it, `squad_route` returns `resultType: 'failure'` with `error: 'fan-out-deps-unavailable'`.
- `state` — When provided, `squad_route` validates that the target agent exists in the team roster before spawning.
- Agent names must match `/^[a-zA-Z0-9_-]+$/`. Spawn errors are sanitized before being returned to the LLM.

**Built-in tools:**

| Tool | Purpose |
|------|---------|
| `squad_route` | Route a task to another agent |
| `squad_route` | Route a task to another agent (requires `fanOutDepsGetter`) |
| `squad_decide` | Write decisions to the inbox |
| `squad_memory` | Append to agent history |
| `squad_status` | Query session pool state |
Expand Down
10 changes: 9 additions & 1 deletion docs/src/content/docs/reference/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -470,17 +470,25 @@ const myTool = defineTool<{ query: string }>({
### `ToolRegistry`

```typescript
import { ToolRegistry } from '@bradygaster/squad-sdk/tools';
import type { FanOutDependencies } from '@bradygaster/squad-sdk/coordinator';

const registry = new ToolRegistry('./.squad');
registry.getTools(); // All tools
registry.getToolsForAgent(['squad_route', 'squad_decide']); // Agent-specific
registry.getTool('squad_route'); // Single lookup
```

**Constructor:** `new ToolRegistry(squadRoot?, sessionPoolGetter?, storage?, state?, fanOutDepsGetter?)`

- `fanOutDepsGetter` — Required for `squad_route` to create sessions via `spawnParallel`. Returns a `FanOutDependencies` object (from `@bradygaster/squad-sdk/coordinator`). Without it, `squad_route` returns `error: 'fan-out-deps-unavailable'`.
- `state` — When provided, `squad_route` validates that the target agent exists in the roster before spawning.

**Built-in tools:**

| Tool | Purpose |
|------|---------|
| `squad_route` | Route a task to another agent |
| `squad_route` | Route a task to another agent (requires `fanOutDepsGetter`) |
| `squad_decide` | Write decisions to the inbox |
| `squad_memory` | Append to agent history |
| `squad_status` | Query session pool state |
Expand Down
27 changes: 26 additions & 1 deletion packages/squad-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ Five tools let agents coordinate without calling you back. Here are the three yo
```typescript
const tool = toolRegistry.getTool('squad_route');
await tool.handler({
targetAgent: 'McManus',
targetAgent: 'mcmanus', // lowercase — agent names are normalized
task: 'Write a blog post on the new casting system',
priority: 'high',
context: 'Feature launches next week',
Expand All @@ -94,6 +94,31 @@ await tool.handler({

The lead routes a task to DevRel. A new session is created, context is passed, and the task is queued with priority. No human in the loop.

> **Wiring requirement:** `squad_route` creates sessions via `spawnParallel`, which requires fan-out dependencies. Pass a `fanOutDepsGetter` as the 5th argument to `new ToolRegistry(...)`:
>
> ```typescript
> import { ToolRegistry } from '@bradygaster/squad-sdk/tools';
> import type { FanOutDependencies } from '@bradygaster/squad-sdk/coordinator';
>
> const fanOutDeps: FanOutDependencies = {
> compileCharter: async (name) => { /* load agent charter */ },
> resolveModel: async (charter, override) => override ?? charter.modelPreference ?? 'default',
> createSession: async (config) => ({ sessionId: '...', sendMessage: async () => {} }),
> sessionPool, // SessionPool instance
> eventBus, // EventBus instance
> };
>
> const registry = new ToolRegistry(
> './.squad',
> () => sessionPool, // sessionPoolGetter
> storageProvider, // storage
> squadState, // state (enables roster validation)
> () => fanOutDeps, // fanOutDepsGetter
> );
> ```
>
> Without it, `squad_route` returns `resultType: 'failure'` with `error: 'fan-out-deps-unavailable'`. Agent names must match `/^[a-zA-Z0-9_-]+$/` and, when state is provided, exist in the team roster.

### `squad_decide` — Record a team decision

```typescript
Expand Down
137 changes: 122 additions & 15 deletions packages/squad-sdk/src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { trace, SpanStatusCode } from '../runtime/otel-api.js';
import type { StorageProvider } from '../storage/storage-provider.js';
import { FSStorageProvider } from '../storage/fs-storage-provider.js';
import type { SquadState } from '../state/squad-state.js';
import { spawnParallel, type FanOutDependencies } from '../coordinator/fan-out.js';

const tracer = trace.getTracer('squad-sdk');

Expand Down Expand Up @@ -165,15 +166,24 @@ export function defineTool<TArgs = unknown>(config: {
};
}

// --- Validation ---

/** Agent name format: alphanumeric, hyphens, underscores. Same rule as squad_decide/squad_memory. */
const AGENT_NAME_RE = /^[a-zA-Z0-9_-]+$/;

// --- Error Sanitization ---

/**
* Sanitize error messages before sending to LLM.
* Strips absolute filesystem paths by replacing the squadRoot prefix with [team-root].
* Strips absolute filesystem paths by replacing the squadRoot prefix with [team-root],
* and collapses multi-line errors to prevent stack trace leakage.
*/
function sanitizeErrorForLlm(error: unknown, squadRoot: string): string {
const msg = error instanceof Error ? error.message : String(error);
return msg.replace(new RegExp(squadRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '[team-root]');
const raw = error instanceof Error ? error.message : String(error);
const stripped = raw.replace(new RegExp(squadRoot.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '[team-root]');
// Collapse to first line to avoid leaking stack-like multi-line details
const firstLine = stripped.split('\n')[0] ?? stripped;
return firstLine.slice(0, 512);
}

// --- Tool Registry ---
Expand All @@ -184,12 +194,20 @@ export class ToolRegistry {
private sessionPoolGetter?: () => any;
private storage: StorageProvider;
private state?: SquadState;

constructor(squadRoot = '.squad', sessionPoolGetter?: () => any, storage: StorageProvider = new FSStorageProvider(), state?: SquadState) {
private fanOutDepsGetter?: () => FanOutDependencies | undefined;

constructor(
squadRoot = '.squad',
sessionPoolGetter?: () => any,
storage: StorageProvider = new FSStorageProvider(),
state?: SquadState,
fanOutDepsGetter?: () => FanOutDependencies | undefined,
) {
this.squadRoot = squadRoot;
this.sessionPoolGetter = sessionPoolGetter;
this.storage = storage;
this.state = state;
this.fanOutDepsGetter = fanOutDepsGetter;
this.registerSquadTools();
}

Expand Down Expand Up @@ -223,28 +241,117 @@ export class ToolRegistry {
required: ['targetAgent', 'task'],
},
handler: async (args) => {
// Validate target agent exists (stub for now, will check roster later)
if (!args.targetAgent || args.targetAgent.trim() === '') {
// Normalize + validate target agent name (lowercase to match charter loading convention)
const targetAgent = (args.targetAgent ?? '').trim().toLowerCase();
if (!targetAgent) {
return {
textResultForLlm: 'Error: Target agent name is required',
resultType: 'failure',
error: 'Invalid target agent',
};
}
if (!AGENT_NAME_RE.test(targetAgent)) {
return {
textResultForLlm: 'Invalid target agent name: must contain only letters, numbers, hyphens, and underscores',
resultType: 'failure',
error: 'invalid-agent-name',
};
}

// Roster check: verify the agent exists when state is available
if (this.state) {
try {
const handle = this.state.agents.get(targetAgent);
await handle.charter();
} catch (err: unknown) {
// Distinguish "not found" from infrastructure errors (I/O, permissions)
const isNotFound =
err instanceof Error && (err.constructor.name === 'NotFoundError' || err.message.includes('not found'));
if (isNotFound) {
return {
textResultForLlm: `Agent '${targetAgent}' not found in the team roster. Check .squad/agents/ for available agents.`,
resultType: 'failure',
error: 'agent-not-in-roster',
};
}
return {
textResultForLlm: `Roster check failed for '${targetAgent}': ${sanitizeErrorForLlm(err, this.squadRoot)}`,
resultType: 'failure',
error: 'roster-check-failed',
};
}
}

// Create route request (session creation wired later)
const priority = args.priority || 'normal';
const routeRequest: RouteRequest = {
targetAgent: args.targetAgent,
targetAgent,
task: args.task,
priority: args.priority || 'normal',
priority,
context: args.context,
};

return {
textResultForLlm: `Task routed to ${args.targetAgent}. Priority: ${routeRequest.priority}. Session creation will be implemented when session lifecycle is in place.`,
resultType: 'success',
toolTelemetry: { routeRequest },
};
// Resolve fan-out dependencies. Without them, the SDK cannot create
// sessions on behalf of the LLM. Returning fake-success here would
// cause the coordinator to claim work it never did (#1029).
let fanOutDeps: ReturnType<NonNullable<typeof this.fanOutDepsGetter>>;
try {
fanOutDeps = this.fanOutDepsGetter?.();
} catch (error) {
return {
textResultForLlm: `Cannot route to ${targetAgent}: ${sanitizeErrorForLlm(error, this.squadRoot)}`,
resultType: 'failure',
error: 'fan-out-deps-unavailable',
toolTelemetry: { routeRequest },
};
}
if (!fanOutDeps) {
return {
textResultForLlm:
`Cannot route to ${targetAgent}: fan-out dependencies are not configured. ` +
`Wire a fanOutDepsGetter into ToolRegistry, or intercept squad_route via SquadSessionHooks.onPreToolUse.`,
resultType: 'failure',
error: 'fan-out-deps-unavailable',
toolTelemetry: { routeRequest },
};
}

// Spawn the target agent via the production fan-out path.
// spawnParallel with a single config matches CLI Path A behavior
// (charter compile → model resolve → createSession → initial message).
try {
const results = await spawnParallel(
[{
agentName: targetAgent,
task: args.task,
priority,
context: args.context,
}],
fanOutDeps,
);
const result = results[0];

if (!result || result.status !== 'success') {
return {
textResultForLlm: `Failed to route to ${targetAgent}: ${sanitizeErrorForLlm(result?.error ?? 'unknown error', this.squadRoot)}`,
resultType: 'failure',
error: 'spawn-failed',
toolTelemetry: { routeRequest, agentName: targetAgent },
};
}

return {
textResultForLlm: `Spawned session ${result.sessionId} for ${targetAgent} with priority ${priority}.`,
resultType: 'success',
toolTelemetry: { routeRequest, sessionId: result.sessionId },
};
} catch (error) {
return {
textResultForLlm: `Failed to route to ${targetAgent}: ${sanitizeErrorForLlm(error, this.squadRoot)}`,
resultType: 'failure',
error: 'spawn-exception',
toolTelemetry: { routeRequest },
};
}
},
});

Expand Down
9 changes: 7 additions & 2 deletions test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,11 @@ describe('Integration: Tool → Hook Pipeline', () => {
const hookResult = await pipeline.runPreToolHooks(ctx);
expect(hookResult.action).toBe('allow');

// Execute tool
// Execute tool. With no fanOutDepsGetter wired into ToolRegistry,
// the handler returns a structured failure (fan-out-deps-unavailable)
// rather than the previous fake-success. The point of this test is that
// the hook pipeline allows the call through; the tool's own outcome is
// determined by infra wiring (covered separately in test/tools.test.ts).
const toolResult = await tool.handler(
{ targetAgent: 'fenster', task: 'Implement feature' } as RouteRequest,
{
Expand All @@ -99,7 +103,8 @@ describe('Integration: Tool → Hook Pipeline', () => {
}
);

expect(toolResult.resultType).toBe('success');
expect(toolResult.resultType).toBe('failure');
expect((toolResult as { error?: string }).error).toBe('fan-out-deps-unavailable');
});

it('should block squad_route when custom hook blocks it', async () => {
Expand Down
Loading
Loading