Skip to content

Commit 7fbbbfe

Browse files
committed
feat: add registered worker subagents
1 parent 8053ef2 commit 7fbbbfe

28 files changed

Lines changed: 939 additions & 35 deletions

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,9 @@ After every significant code change, follow this sequence:
6565
- `pnpm build && pnpm tsc --noEmit && npx vitest run`
6666
- Any failure → fix and re-run. If fix touches source code, go back to step 1.
6767
4. **Daemon smoke test** — if changes touch daemon code (`packages/cli/src/daemon/`), run `./scripts/daemon-smoke-test.sh` and ensure it passes before considering the task done.
68+
- Before smoke, always refresh the local CLI with `bash scripts/install-cli.sh`.
69+
- Smoke is mandatory. Missing arguments are not a reason to skip it: discover existing resources with `ak get board -o json`, `ak get repo -o json`, and `ak get agent -o json`, or create the missing resources.
70+
- The default smoke target is the Demo board with the `slink` repository. The smoke script auto-discovers these defaults when arguments are omitted.
6871

6972
## Testing
7073
- Framework: vitest (root `vitest.config.ts`)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE agents ADD COLUMN subagents TEXT;

apps/web/server/agentRepo.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { type D1, parseJsonFields } from "./db";
44
import { addSubkey, getOrCreateRootKey } from "./gpgKeyRepo";
55
import { runtimeReadyPredicateSql } from "./machineRepo";
66

7-
const parseAgent = <T extends Agent>(row: T) => parseJsonFields(row, ["skills", "handoff_to"]);
7+
const parseAgent = <T extends Agent>(row: T) => parseJsonFields(row, ["skills", "subagents", "handoff_to"]);
88

99
export interface PreparedAgent extends Agent {
1010
privateKeyJwk: JsonWebKey;
@@ -40,6 +40,7 @@ export async function prepareAgent(
4040
runtime: input.runtime,
4141
model: input.model ?? null,
4242
skills: input.skills ?? null,
43+
subagents: input.subagents ?? null,
4344
public_key: publicKeyBase64,
4445
fingerprint,
4546
builtin: builtin ? 1 : 0,
@@ -51,11 +52,12 @@ export async function prepareAgent(
5152

5253
export async function insertAgent(db: D1, agent: PreparedAgent, extras?: { mailboxToken?: string; gpgSubkeyId?: string }): Promise<Agent> {
5354
const skillsJson = agent.skills ? JSON.stringify(agent.skills) : null;
55+
const subagentsJson = agent.subagents ? JSON.stringify(agent.subagents) : null;
5456
const handoffJson = agent.handoff_to ? JSON.stringify(agent.handoff_to) : null;
5557
await db
5658
.prepare(`
57-
INSERT INTO agents (id, owner_id, name, username, bio, soul, role, kind, handoff_to, runtime, model, skills, public_key, private_key, fingerprint, builtin, mailbox_token, gpg_subkey_id, created_at, updated_at)
58-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
59+
INSERT INTO agents (id, owner_id, name, username, bio, soul, role, kind, handoff_to, runtime, model, skills, subagents, public_key, private_key, fingerprint, builtin, mailbox_token, gpg_subkey_id, created_at, updated_at)
60+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
5961
`)
6062
.bind(
6163
agent.id,
@@ -70,6 +72,7 @@ export async function insertAgent(db: D1, agent: PreparedAgent, extras?: { mailb
7072
agent.runtime,
7173
agent.model,
7274
skillsJson,
75+
subagentsJson,
7376
agent.public_key,
7477
JSON.stringify(agent.privateKeyJwk),
7578
agent.fingerprint,
@@ -123,7 +126,7 @@ export async function listAgents(db: D1, ownerId: string): Promise<AgentWithActi
123126
const runtimeCutoff = new Date(Date.now() - MACHINE_STALE_TIMEOUT_MS).toISOString();
124127
const result = await db
125128
.prepare(`
126-
SELECT a.id, a.owner_id, a.name, a.username, a.gpg_subkey_id, a.bio, a.soul, a.role, a.kind, a.handoff_to, a.runtime, a.model, a.skills,
129+
SELECT a.id, a.owner_id, a.name, a.username, a.gpg_subkey_id, a.bio, a.soul, a.role, a.kind, a.handoff_to, a.runtime, a.model, a.skills, a.subagents,
127130
a.public_key, a.fingerprint, a.builtin, a.created_at, a.updated_at,
128131
CASE WHEN EXISTS (
129132
SELECT 1 FROM machines m, json_each(m.runtimes) rt
@@ -155,7 +158,7 @@ export async function getAgent(db: D1, agentId: string, ownerId: string): Promis
155158
const runtimeCutoff = new Date(Date.now() - MACHINE_STALE_TIMEOUT_MS).toISOString();
156159
return db
157160
.prepare(`
158-
SELECT a.id, a.owner_id, a.name, a.username, a.gpg_subkey_id, a.bio, a.soul, a.role, a.kind, a.handoff_to, a.runtime, a.model, a.skills,
161+
SELECT a.id, a.owner_id, a.name, a.username, a.gpg_subkey_id, a.bio, a.soul, a.role, a.kind, a.handoff_to, a.runtime, a.model, a.skills, a.subagents,
159162
a.public_key, a.fingerprint, a.builtin, a.created_at, a.updated_at,
160163
CASE WHEN EXISTS (
161164
SELECT 1 FROM machines m, json_each(m.runtimes) rt
@@ -185,22 +188,29 @@ export async function getAgent(db: D1, agentId: string, ownerId: string): Promis
185188
export async function updateAgent(
186189
db: D1,
187190
agentId: string,
188-
updates: Partial<Pick<Agent, "name" | "bio" | "soul" | "role" | "handoff_to" | "runtime" | "model" | "skills">>,
191+
updates: Partial<Pick<Agent, "name" | "bio" | "soul" | "role" | "handoff_to" | "runtime" | "model" | "skills" | "subagents">>,
189192
): Promise<Agent | null> {
190-
const agent = await db.prepare("SELECT * FROM agents WHERE id = ?").bind(agentId).first<Agent>();
193+
const agent = await db
194+
.prepare(
195+
"SELECT id, owner_id, name, username, gpg_subkey_id, bio, soul, role, kind, handoff_to, runtime, model, skills, subagents, public_key, fingerprint, builtin, created_at, updated_at FROM agents WHERE id = ?",
196+
)
197+
.bind(agentId)
198+
.first<Agent>();
191199
if (!agent) return null;
192200

193201
const now = new Date().toISOString();
194202
const sets: string[] = ["updated_at = ?"];
195203
const binds: unknown[] = [now];
204+
const applied: Partial<Agent> = {};
196205

197-
const jsonFields = new Set(["skills", "handoff_to"]);
198-
const fields = ["name", "bio", "soul", "role", "handoff_to", "runtime", "model", "skills"] as const;
206+
const jsonFields = new Set(["skills", "subagents", "handoff_to"]);
207+
const fields = ["name", "bio", "soul", "role", "handoff_to", "runtime", "model", "skills", "subagents"] as const;
199208
for (const field of fields) {
200209
if (field in updates && (updates as any)[field] !== undefined) {
201210
sets.push(`${field} = ?`);
202211
const val = (updates as any)[field];
203212
binds.push(jsonFields.has(field) && val != null ? JSON.stringify(val) : val);
213+
(applied as any)[field] = val;
204214
}
205215
}
206216

@@ -209,7 +219,7 @@ export async function updateAgent(
209219
.prepare(`UPDATE agents SET ${sets.join(", ")} WHERE id = ?`)
210220
.bind(...binds)
211221
.run();
212-
return parseAgent({ ...agent, ...updates, updated_at: now });
222+
return parseAgent({ ...agent, ...applied, updated_at: now });
213223
}
214224

215225
export async function deleteAgent(db: D1, agentId: string): Promise<boolean> {

apps/web/server/routes.ts

Lines changed: 84 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import type { Env } from "./types";
5959

6060
const api = new Hono<{ Bindings: Env }>();
6161
const logger = createLogger("api");
62+
const SUBAGENT_RUNTIMES = new Set(["claude", "codex"]);
6263

6364
function assertValidSkillRefs(skills: unknown) {
6465
if (skills === undefined) return;
@@ -71,6 +72,71 @@ function assertValidSkillRefs(skills: unknown) {
7172
}
7273
}
7374

75+
function assertJsonObject(value: unknown, name: string): asserts value is Record<string, unknown> {
76+
if (!value || typeof value !== "object" || Array.isArray(value)) {
77+
throw new HTTPException(400, { message: `${name} must be a JSON object` });
78+
}
79+
}
80+
81+
function assertSubagentList(subagents: unknown) {
82+
if (subagents === undefined) return;
83+
if (!Array.isArray(subagents) || subagents.some((agent) => typeof agent !== "string" || agent.length === 0)) {
84+
throw new HTTPException(400, { message: "subagents must be an array of worker agent IDs" });
85+
}
86+
}
87+
88+
function assertSubagentRuntime(runtime: string, subagents: string[] | null | undefined) {
89+
if (!subagents || subagents.length === 0) return;
90+
if (!SUBAGENT_RUNTIMES.has(runtime)) {
91+
throw new HTTPException(400, { message: `Runtime "${runtime}" does not support subagents yet` });
92+
}
93+
}
94+
95+
function assertValidAgentRuntime(runtime: string | undefined): void {
96+
if (runtime === undefined) return;
97+
if (!AGENT_RUNTIMES.includes(runtime as any)) {
98+
throw new HTTPException(400, { message: `Invalid runtime "${runtime}". Must be one of: ${AGENT_RUNTIMES.join(", ")}` });
99+
}
100+
}
101+
102+
async function assertRegisteredWorkerSubagents(
103+
db: Env["DB"],
104+
ownerId: string,
105+
subagents: string[] | null | undefined,
106+
currentAgentId?: string,
107+
): Promise<void> {
108+
if (!subagents || subagents.length === 0) return;
109+
const ids = [...new Set(subagents)];
110+
if (currentAgentId && ids.includes(currentAgentId)) {
111+
throw new HTTPException(400, { message: "Agent cannot include itself as a subagent" });
112+
}
113+
114+
const placeholders = ids.map(() => "?").join(", ");
115+
const result = await db
116+
.prepare(`SELECT id, kind FROM agents WHERE owner_id = ? AND id IN (${placeholders})`)
117+
.bind(ownerId, ...ids)
118+
.all<{ id: string; kind: string }>();
119+
const found = new Map(result.results.map((agent) => [agent.id, agent.kind]));
120+
for (const id of ids) {
121+
const kind = found.get(id);
122+
if (!kind) throw new HTTPException(400, { message: `Subagent "${id}" is not registered` });
123+
if (kind !== "worker") throw new HTTPException(400, { message: `Subagent "${id}" must be a worker agent` });
124+
}
125+
}
126+
127+
async function assertAgentNotReferencedAsSubagent(db: Env["DB"], ownerId: string, agentId: string): Promise<void> {
128+
const row = await db
129+
.prepare(`
130+
SELECT a.name
131+
FROM agents a, json_each(a.subagents) ref
132+
WHERE a.owner_id = ? AND ref.value = ?
133+
LIMIT 1
134+
`)
135+
.bind(ownerId, agentId)
136+
.first<{ name: string }>();
137+
if (row) throw new HTTPException(409, { message: `Agent is referenced as a subagent by "${row.name}"` });
138+
}
139+
74140
function assertValidMachineRuntimes(runtimes: unknown): void {
75141
if (!Array.isArray(runtimes)) {
76142
throw new HTTPException(400, { message: "runtimes must be an array" });
@@ -439,18 +505,21 @@ api.post("/api/agents", async (c) => {
439505
runtime: string;
440506
model?: string;
441507
skills?: string[];
508+
subagents?: string[];
442509
}>();
510+
assertJsonObject(body, "agent");
443511
if (!body.username) throw new HTTPException(400, { message: "username is required" });
444512
if (!body.runtime) throw new HTTPException(400, { message: "runtime is required" });
445513
if (!isValidUsername(body.username)) throw new HTTPException(400, { message: `Invalid username "${body.username}"` });
446-
if (!AGENT_RUNTIMES.includes(body.runtime as any)) {
447-
throw new HTTPException(400, { message: `Invalid runtime "${body.runtime}". Must be one of: ${AGENT_RUNTIMES.join(", ")}` });
448-
}
514+
assertValidAgentRuntime(body.runtime);
449515
if (body.role && RESERVED_ROLES.has(body.role)) {
450516
throw new HTTPException(403, { message: `Role "${body.role}" is reserved for built-in agents` });
451517
}
452518
assertValidSkillRefs(body.skills);
519+
assertSubagentList(body.subagents);
520+
assertSubagentRuntime(body.runtime, body.subagents);
453521
const ownerId = c.get("ownerId");
522+
await assertRegisteredWorkerSubagents(c.env.DB, ownerId, body.subagents);
454523

455524
// Validate username uniqueness before GPG mutation
456525
const taken = await c.env.DB.prepare("SELECT 1 FROM agents WHERE username = ?").bind(body.username).first();
@@ -501,17 +570,25 @@ api.patch("/api/agents/:id", async (c) => {
501570
if (!existing) throw new HTTPException(404, { message: "Agent not found" });
502571
if (existing.builtin) throw new HTTPException(403, { message: "Built-in agents cannot be modified" });
503572
const body = await c.req.json();
504-
assertValidSkillRefs(body.skills);
505-
const agent = await updateAgent(c.env.DB, c.req.param("id"), body);
573+
assertJsonObject(body, "agent update");
574+
const updates = body as Partial<CreateAgentInput>;
575+
assertValidAgentRuntime(updates.runtime);
576+
assertValidSkillRefs(updates.skills);
577+
assertSubagentList(updates.subagents);
578+
assertSubagentRuntime(updates.runtime ?? existing.runtime, updates.subagents ?? existing.subagents);
579+
await assertRegisteredWorkerSubagents(c.env.DB, ownerId, updates.subagents ?? existing.subagents, existing.id);
580+
const agent = await updateAgent(c.env.DB, c.req.param("id"), updates);
506581
return c.json(agent);
507582
});
508583

509584
api.delete("/api/agents/:id", async (c) => {
510-
const agent = await c.env.DB.prepare("SELECT id, username, builtin FROM agents WHERE id = ?")
511-
.bind(c.req.param("id"))
585+
const ownerId = c.get("ownerId");
586+
const agent = await c.env.DB.prepare("SELECT id, username, builtin FROM agents WHERE id = ? AND owner_id = ?")
587+
.bind(c.req.param("id"), ownerId)
512588
.first<{ id: string; username: string; builtin: number }>();
513589
if (!agent) throw new HTTPException(404, { message: "Agent not found" });
514590
if (agent.builtin) throw new HTTPException(403, { message: "Built-in agents cannot be deleted" });
591+
await assertAgentNotReferencedAsSubagent(c.env.DB, ownerId, agent.id);
515592
const email = agentEmail(agent.username);
516593
await deleteAgent(c.env.DB, agent.id);
517594
if (c.env.MAILS_ADMIN_TOKEN) {

packages/cli/src/agent/systemPrompt.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,22 @@ import type { AgentRuntime, BoardType } from "@agent-kanban/shared";
66
export interface AgentInfo {
77
id: string;
88
name: string;
9+
username: string;
10+
bio: string | null;
911
role: string | null;
1012
soul: string | null;
1113
handoff_to: string[] | null;
1214
skills: string[] | null;
15+
subagents: string[] | null;
1316
runtime: AgentRuntime;
1417
model: string | null;
1518
}
1619

17-
export function generateSystemPrompt(agent: AgentInfo, boardType: BoardType): string {
20+
export function generateSystemPrompt(agent: AgentInfo, boardType: BoardType, subagents: AgentInfo[] = []): string {
1821
const lifecycle = boardType === "dev" ? DEV_LIFECYCLE : OPS_LIFECYCLE;
1922
const environment = boardType === "dev" ? DEV_ENVIRONMENT : OPS_ENVIRONMENT;
2023
const rules = boardType === "dev" ? DEV_RULES : OPS_RULES;
24+
const subagentSection = buildSubagentSection(subagents);
2125
const handoffSection = buildHandoffSection(agent, boardType);
2226

2327
return `# Agent Work Protocol
@@ -37,6 +41,7 @@ ${environment}
3741
## Rules
3842
3943
${rules}
44+
${subagentSection}
4045
${handoffSection}
4146
# Your Identity
4247
@@ -88,6 +93,18 @@ const OPS_RULES = `\
8893
- Log progress frequently — humans monitor the board.
8994
- If a task is too large, break it into subtasks via \`ak create task --parent <task-id>\`.`;
9095

96+
function buildSubagentSection(subagents: AgentInfo[]): string {
97+
if (subagents.length === 0) return "";
98+
99+
const mentions = subagents.map((agent) => `@${agent.username}`).join(", ");
100+
101+
return `
102+
## Available Subagents
103+
104+
The following registered worker agents are installed as task-local subagents: ${mentions}
105+
`;
106+
}
107+
91108
function buildHandoffSection(agent: AgentInfo, boardType: BoardType): string {
92109
const handoffRoles = agent.handoff_to ?? [];
93110
if (handoffRoles.length === 0) return "";

packages/cli/src/commands/create.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export function registerCreateCommand(program: Command) {
109109
.option("--kind <kind>", "Agent kind: worker, leader")
110110
.option("--handoff-to <ids>", "Comma-separated agent IDs for handoff")
111111
.option("--skills <skills>", "Comma-separated skill slugs")
112+
.option("--subagents <ids>", "Comma-separated worker agent IDs to install as task-local subagents")
112113
.option("-o, --output <format>", "Output format (json, yaml, text)")
113114
.action(async (opts) => {
114115
const client = await createClient();
@@ -137,6 +138,7 @@ export function registerCreateCommand(program: Command) {
137138
runtime,
138139
model: opts.model || template.model,
139140
skills: opts.skills ? opts.skills.split(",").map((s: string) => s.trim()) : template.skills,
141+
subagents: opts.subagents ? opts.subagents.split(",").map((s: string) => s.trim()) : undefined,
140142
};
141143
} else {
142144
if (!opts.username) {
@@ -152,6 +154,7 @@ export function registerCreateCommand(program: Command) {
152154
if (opts.handoffTo) body.handoff_to = opts.handoffTo.split(",").map((s: string) => s.trim());
153155
if (opts.model) body.model = opts.model;
154156
if (opts.skills) body.skills = opts.skills.split(",").map((s: string) => s.trim());
157+
if (opts.subagents) body.subagents = opts.subagents.split(",").map((s: string) => s.trim());
155158
}
156159

157160
const agent = await client.createAgent(body as any);

packages/cli/src/commands/update.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export function registerUpdateCommand(program: Command) {
102102
.option("--kind <kind>", "Agent kind: worker, leader")
103103
.option("--handoff-to <ids>", "Comma-separated agent IDs for handoff")
104104
.option("--skills <skills>", "Comma-separated skill slugs")
105+
.option("--subagents <ids>", "Comma-separated worker agent IDs to install as task-local subagents")
105106
.option("-o, --output <format>", "Output format (json, yaml, text)")
106107
.action(async (id: string, opts) => {
107108
const client = await createClient();
@@ -115,8 +116,9 @@ export function registerUpdateCommand(program: Command) {
115116
if (opts.kind) body.kind = opts.kind;
116117
if (opts.handoffTo) body.handoff_to = opts.handoffTo.split(",").map((s: string) => s.trim());
117118
if (opts.skills) body.skills = opts.skills.split(",").map((s: string) => s.trim());
119+
if (opts.subagents) body.subagents = opts.subagents.split(",").map((s: string) => s.trim());
118120
if (Object.keys(body).length === 0) {
119-
console.error("Nothing to update. Provide at least one option (--name, --bio, --role, --runtime, --model, --kind, --skills).");
121+
console.error("Nothing to update. Provide at least one option (--name, --bio, --role, --runtime, --model, --kind, --skills, --subagents).");
120122
process.exit(1);
121123
}
122124
const agent = await client.updateAgent(id, body);

packages/cli/src/daemon/dispatcher.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { createLogger } from "../logger.js";
1919
import { getAvailableProviders, getProvider, normalizeRuntime } from "../providers/registry.js";
2020
import { getSessionManager } from "../session/manager.js";
2121
import type { SessionFile } from "../session/types.js";
22+
import { ensureSubagents } from "../workspace/agents.js";
2223
import { ensureCloned, prepareRepo, repoDir } from "../workspace/repoOps.js";
2324
import { ensureSkills } from "../workspace/skills.js";
2425
import { createRepoWorkspace, createTempWorkspace } from "../workspace/workspace.js";
@@ -29,6 +30,16 @@ import type { RuntimePool } from "./runtimePool.js";
2930

3031
const logger = createLogger("dispatcher");
3132

33+
async function getSubagentDetails(client: ApiClient, subagentIds: string[]): Promise<AgentInfo[] | null> {
34+
const subagents: AgentInfo[] = [];
35+
for (const id of subagentIds) {
36+
const agent = (await apiCallOptional("getSubagent", () => client.getAgent(id))) as AgentInfo | null;
37+
if (!agent) return null;
38+
subagents.push(agent);
39+
}
40+
return subagents;
41+
}
42+
3243
// ---- Agent environment / GPG helpers ----
3344

3445
export interface BuildEnvOpts {
@@ -246,6 +257,15 @@ async function dispatchOne(task: any, repoDir: string | null, boardType: BoardTy
246257
return false;
247258
}
248259

260+
const subagents = await getSubagentDetails(client, agentDetails.subagents ?? []);
261+
if (!subagents || !(await ensureSubagents(workspace.cwd, providerName, subagents))) {
262+
logger.error(`Subagent install failed for task ${task.id}, releasing task`);
263+
workspace.cleanup();
264+
cleanupGnupgHome(gnupgHome);
265+
await abort();
266+
return false;
267+
}
268+
249269
const apiUrl = getCredentials().apiUrl;
250270
const agentClient = new AgentClient(apiUrl, agentId, sessionId, privateKey);
251271
const agentEnv = buildAgentEnv({
@@ -257,7 +277,7 @@ async function dispatchOne(task: any, repoDir: string | null, boardType: BoardTy
257277
gpgSubkeyId,
258278
gnupgHome,
259279
});
260-
const systemPromptFile = writePromptFile(sessionId, generateSystemPrompt(agentDetails, boardType));
280+
const systemPromptFile = writePromptFile(sessionId, generateSystemPrompt(agentDetails, boardType, subagents));
261281

262282
const repos = await client.listRepositories();
263283
const taskRepo = repos.find((r: any) => r.id === task.repository_id);

0 commit comments

Comments
 (0)