Skip to content

Commit 6dbb0bb

Browse files
authored
Require meaningful slugs for spawned workers (#4621)
Require `spawn_worker` calls to include a meaningful slug, then append a short random suffix so spawned worker URLs are readable and generally unique. This makes child worker paths easier to identify in wake messages, logs, and the UI. ## Root Cause Workers previously used an opaque `nanoid(10)` id, so spawned worker URLs were unique but not meaningful. When multiple workers were dispatched, agents and humans had to infer purpose from surrounding context instead of the worker path itself. ## Approach - Add a required `slug` parameter to the `spawn_worker` tool schema. - Normalize the slug into a safe path prefix: - trim whitespace - lowercase - replace unsupported characters with `-` - collapse repeated hyphens - trim leading/trailing hyphens - cap at 48 characters - Append a short random hex suffix to avoid routine collisions: ```ts const id = `${normalizedSlug}-${randomBytes(3).toString(`hex`)}` ``` - Reject slugs that normalize to no meaningful letter/number content before spawning. - Update Horton’s worker-spawning instructions to tell it to provide short meaningful slugs. - Remove stale fork-tool wording that said fork ids mirror the old `spawn_worker` id parameter. - Add a changeset for `@electric-ax/agents`. ## Key Invariants - Every spawned worker id starts with a meaningful slug. - Worker ids remain generally unique via the random suffix. - Invalid slugs do not call `ctx.spawn`. - Existing worker behavior remains otherwise unchanged: - selected tools are forwarded - model config is forwarded - initial message is forwarded - `runFinished` wake with response remains enabled - sandbox inheritance remains enabled ## Non-goals - This does not require globally collision-proof worker ids. If an unlikely slug/suffix collision occurs, spawning fails and the agent can retry. - This does not restrict slugs to hyphen-only text; dots and underscores remain allowed because worker ids become URLs and can be encoded. - This does not change the fork id generation behavior beyond removing stale documentation. ## Trade-offs The implementation uses a small random suffix rather than UUID/nanoid-level entropy to keep worker URLs compact. That preserves readability while still avoiding collisions in normal use. Slugs are capped at 48 characters to avoid unwieldy URLs, at the cost of truncating very long model-provided labels. ## Verification ```bash pnpm --filter @electric-ax/agents typecheck pnpm --filter @electric-ax/agents exec vitest run test/spawn-worker-tool.test.ts GITHUB_BASE_REF=main node scripts/check-changeset.mjs ``` ## Files changed - `.changeset/meaningful-worker-slugs.md` - Adds a patch changeset for `@electric-ax/agents`. - `packages/agents/src/tools/spawn-worker.ts` - Requires `slug`. - Normalizes and caps slug values. - Appends a random hex suffix to form the worker id. - Rejects non-meaningful slugs before spawning. - `packages/agents/test/spawn-worker-tool.test.ts` - Updates existing spawn-worker tests to pass slugs. - Adds coverage for slug normalization, slug length capping, and invalid slug rejection. - `packages/agents/src/agents/horton.ts` - Updates Horton’s instructions to provide meaningful slugs when spawning workers. - `packages/agents/src/tools/fork.ts` - Removes stale wording tying fork ids to `spawn_worker` id behavior.
1 parent 354b01b commit 6dbb0bb

5 files changed

Lines changed: 108 additions & 9 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@electric-ax/agents': patch
3+
---
4+
5+
Require meaningful slugs when spawning workers and append a random suffix so worker URLs are easier to identify while remaining generally unique.

packages/agents/src/agents/horton.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -333,7 +333,7 @@ Dispatch a worker when:
333333
- The subtask is independent and can run in parallel with other work.
334334
- You need an isolated context (e.g., focused coding on one file without pulling its full content into our chat).
335335
336-
When you spawn a worker, write its system prompt the way you'd brief a colleague who just walked in: include file paths, line numbers, what specifically to do, and what form of answer you want back. The system prompt sets the worker's persona and constraints; the required initialMessage is the concrete task you're handing off — that's what kicks the worker off, so without it the worker sits idle.
336+
When you spawn a worker, provide a short meaningful slug for the worker path (for example, \`auth-audit\` or \`api-test-fix\`) and write its system prompt the way you'd brief a colleague who just walked in: include file paths, line numbers, what specifically to do, and what form of answer you want back. The system prompt sets the worker's persona and constraints; the required initialMessage is the concrete task you're handing off — that's what kicks the worker off, so without it the worker sits idle.
337337
338338
After spawning, end your turn (optionally with a brief "I've dispatched a worker for X; I'll respond when it finishes"). When the worker finishes, you'll receive a message describing which worker completed and what it returned. Multiple workers may finish at different times — check the message for the worker URL to know which one you're hearing about.
339339

packages/agents/src/tools/fork.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Omit 'entityUrl' to fork your own session. Pass a different session's URL to for
2323
),
2424
id: Type.Optional(
2525
Type.String({
26-
description: `Instance id for the new fork (the \`<id>\` in \`/horton/<id>\`). Mirrors spawn_worker's id parameter. Omit to let the server assign one.`,
26+
description: `Instance id for the new fork (the \`<id>\` in \`/horton/<id>\`). Omit to let the server assign one.`,
2727
})
2828
),
2929
initialMessage: Type.Optional(
@@ -52,8 +52,7 @@ Omit 'entityUrl' to fork your own session. Pass a different session's URL to for
5252
// The library API (`ctx.fork` / `ctx.forkSelf`) requires an id
5353
// — same shape as `ctx.spawn(type, id, ...)`. The model layer
5454
// doesn't need to know this; we generate one via nanoid when
55-
// it's not supplied (same pattern `createSpawnWorkerTool` uses
56-
// for the worker's id).
55+
// it's not supplied.
5756
const forkId = id ?? `fork-${nanoid(10)}`
5857
const handle =
5958
entityUrl !== undefined

packages/agents/src/tools/spawn-worker.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Type } from '@sinclair/typebox'
2-
import { nanoid } from 'nanoid'
2+
import { randomBytes } from 'node:crypto'
33
import { serverLog } from '../log'
44
import type { BuiltinAgentModelConfig } from '../model-catalog'
55
import type { AgentTool } from '@mariozechner/pi-agent-core'
@@ -18,15 +18,33 @@ export const WORKER_TOOL_NAMES = [
1818

1919
export type WorkerToolName = (typeof WORKER_TOOL_NAMES)[number]
2020

21+
const MAX_WORKER_SLUG_LENGTH = 48
22+
23+
function normalizeWorkerSlug(slug: unknown): string {
24+
if (typeof slug !== `string`) return ``
25+
26+
return slug
27+
.trim()
28+
.toLowerCase()
29+
.replace(/[^a-z0-9._-]+/g, `-`)
30+
.replace(/-+/g, `-`)
31+
.replace(/^-+|-+$/g, ``)
32+
.slice(0, MAX_WORKER_SLUG_LENGTH)
33+
.replace(/^-+|-+$/g, ``)
34+
}
35+
2136
export function createSpawnWorkerTool(
2237
ctx: HandlerContext,
2338
modelConfig?: BuiltinAgentModelConfig
2439
): AgentTool {
2540
return {
2641
name: `spawn_worker`,
2742
label: `Spawn Worker`,
28-
description: `Dispatch a subagent (worker) to perform an isolated subtask. Provide a brief system prompt to give it its role and then a detailed initialMessage which briefs the worker like a colleague who just walked into the room (file paths, line numbers, what specifically to do, what form of answer you want back) and pick the subset of tools the worker needs.`,
43+
description: `Dispatch a subagent (worker) to perform an isolated subtask. Provide a meaningful slug for the worker path, a brief system prompt to give it its role, then a detailed initialMessage which briefs the worker like a colleague who just walked into the room (file paths, line numbers, what specifically to do, what form of answer you want back), and pick the subset of tools the worker needs. The slug is normalized and a few random bytes are appended to keep the worker path unique.`,
2944
parameters: Type.Object({
45+
slug: Type.String({
46+
description: `Short, meaningful slug for this worker, used as the start of its path. Use lowercase words separated by hyphens, e.g. "audit-auth-flow". A random suffix is added automatically for uniqueness.`,
47+
}),
3048
systemPrompt: Type.String({
3149
description: `System prompt for the worker.`,
3250
}),
@@ -41,11 +59,25 @@ export function createSpawnWorkerTool(
4159
}),
4260
}),
4361
execute: async (_toolCallId, params) => {
44-
const { systemPrompt, tools, initialMessage } = params as {
62+
const { slug, systemPrompt, tools, initialMessage } = params as {
63+
slug: string
4564
systemPrompt: string
4665
tools: Array<WorkerToolName>
4766
initialMessage: string
4867
}
68+
const normalizedSlug = normalizeWorkerSlug(slug)
69+
if (normalizedSlug.length === 0 || !/[a-z0-9]/.test(normalizedSlug)) {
70+
return {
71+
content: [
72+
{
73+
type: `text` as const,
74+
text: `Error: slug is required and must contain at least one letter or number.`,
75+
},
76+
],
77+
details: { spawned: false },
78+
}
79+
}
80+
4981
if (!Array.isArray(tools) || tools.length === 0) {
5082
return {
5183
content: [
@@ -69,7 +101,7 @@ export function createSpawnWorkerTool(
69101
}
70102
}
71103

72-
const id = nanoid(10)
104+
const id = `${normalizedSlug}-${randomBytes(3).toString(`hex`)}`
73105
const workerModelArgs = modelConfig
74106
? {
75107
provider: modelConfig.provider,

packages/agents/test/spawn-worker-tool.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ describe(`spawn_worker tool`, () => {
1111
const ctx = { spawn } as any
1212
const tool = createSpawnWorkerTool(ctx)
1313
const result = await tool.execute(`call-1`, {
14+
slug: `file-size-check`,
1415
systemPrompt: `Read /tmp/foo and report its size`,
1516
tools: [`read`, `bash`],
1617
initialMessage: `Please check the size of /tmp/foo and report back`,
@@ -21,7 +22,7 @@ describe(`spawn_worker tool`, () => {
2122
const [type, id, args, opts] = call as Array<any>
2223
expect(type).toBe(`worker`)
2324
expect(typeof id).toBe(`string`)
24-
expect(id.length).toBeGreaterThanOrEqual(10)
25+
expect(id).toMatch(/^file-size-check-[0-9a-f]{6}$/)
2526
expect(args).toEqual({
2627
systemPrompt: `Read /tmp/foo and report its size`,
2728
tools: [`read`, `bash`],
@@ -52,6 +53,7 @@ describe(`spawn_worker tool`, () => {
5253
})
5354

5455
await tool.execute(`call-model`, {
56+
slug: `model-worker`,
5557
systemPrompt: `Do a thing`,
5658
tools: [`read`],
5759
initialMessage: `Please do it`,
@@ -67,11 +69,70 @@ describe(`spawn_worker tool`, () => {
6769
})
6870
})
6971

72+
it(`normalizes the slug before appending a random suffix`, async () => {
73+
const spawn = vi.fn(async (type, id) => ({
74+
entityUrl: `/${type}/${id}`,
75+
writeToken: `tok`,
76+
txid: 1,
77+
}))
78+
const ctx = { spawn } as any
79+
const tool = createSpawnWorkerTool(ctx)
80+
81+
await tool.execute(`call-slug`, {
82+
slug: ` Audit Auth Flow!! `,
83+
systemPrompt: `Do a thing`,
84+
tools: [`read`],
85+
initialMessage: `Please do it`,
86+
})
87+
88+
const [, id] = spawn.mock.calls[0]! as Array<any>
89+
expect(id).toMatch(/^audit-auth-flow-[0-9a-f]{6}$/)
90+
})
91+
92+
it(`caps the normalized slug before appending a random suffix`, async () => {
93+
const spawn = vi.fn(async (type, id) => ({
94+
entityUrl: `/${type}/${id}`,
95+
writeToken: `tok`,
96+
txid: 1,
97+
}))
98+
const ctx = { spawn } as any
99+
const tool = createSpawnWorkerTool(ctx)
100+
101+
await tool.execute(`call-long-slug`, {
102+
slug: `this is a very long worker slug that should be capped before it reaches the worker path`,
103+
systemPrompt: `Do a thing`,
104+
tools: [`read`],
105+
initialMessage: `Please do it`,
106+
})
107+
108+
const [, id] = spawn.mock.calls[0]! as Array<any>
109+
expect(id).toMatch(
110+
/^this-is-a-very-long-worker-slug-that-should-be-c-[0-9a-f]{6}$/
111+
)
112+
})
113+
114+
it(`rejects when slug is missing or empty after normalization`, async () => {
115+
const spawn = vi.fn()
116+
const ctx = { spawn } as any
117+
const tool = createSpawnWorkerTool(ctx)
118+
const result = await tool.execute(`call-no-slug`, {
119+
slug: `!!!`,
120+
systemPrompt: `do something`,
121+
tools: [`bash`],
122+
initialMessage: `go`,
123+
})
124+
expect((result.content[0] as { text: string }).text).toMatch(
125+
/slug is required/i
126+
)
127+
expect(spawn).not.toHaveBeenCalled()
128+
})
129+
70130
it(`rejects when tools is empty`, async () => {
71131
const spawn = vi.fn()
72132
const ctx = { spawn } as any
73133
const tool = createSpawnWorkerTool(ctx)
74134
const result = await tool.execute(`call-2`, {
135+
slug: `empty-tools`,
75136
systemPrompt: `do something`,
76137
tools: [],
77138
initialMessage: `go`,
@@ -87,13 +148,15 @@ describe(`spawn_worker tool`, () => {
87148
const ctx = { spawn } as any
88149
const tool = createSpawnWorkerTool(ctx)
89150
const missing = await tool.execute(`call-3`, {
151+
slug: `missing-message`,
90152
systemPrompt: `do something`,
91153
tools: [`bash`],
92154
} as any)
93155
expect((missing.content[0] as { text: string }).text).toMatch(
94156
/initialMessage is required/i
95157
)
96158
const empty = await tool.execute(`call-4`, {
159+
slug: `empty-message`,
97160
systemPrompt: `do something`,
98161
tools: [`bash`],
99162
initialMessage: ``,

0 commit comments

Comments
 (0)