Skip to content

Commit 4dab3bc

Browse files
committed
feat(agent): add agent filtering and specialist guidance
1 parent 957327e commit 4dab3bc

23 files changed

Lines changed: 603 additions & 105 deletions

apps/web/server/agentRepo.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ import { runtimeReadyPredicateSql } from "./machineRepo";
66

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

9+
export type AgentListFilters = {
10+
kind?: "worker" | "leader";
11+
role?: string;
12+
runtime?: AgentRuntime;
13+
available?: boolean;
14+
};
15+
916
async function shortHash(value: string): Promise<string> {
1017
const bytes = new TextEncoder().encode(value);
1118
const hash = await crypto.subtle.digest("SHA-1", bytes);
@@ -157,10 +164,9 @@ export async function seedBuiltinAgents(db: D1, ownerId: string): Promise<void>
157164
}
158165
}
159166

160-
export async function listAgents(db: D1, ownerId: string): Promise<AgentWithActivity[]> {
167+
export async function listAgents(db: D1, ownerId: string, filters: AgentListFilters = {}): Promise<AgentWithActivity[]> {
161168
const runtimeCutoff = new Date(Date.now() - MACHINE_STALE_TIMEOUT_MS).toISOString();
162-
const result = await db
163-
.prepare(`
169+
let query = `
164170
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,
165171
a.version,
166172
a.public_key, a.fingerprint, a.builtin, a.created_at, a.updated_at,
@@ -183,11 +189,32 @@ export async function listAgents(db: D1, ownerId: string): Promise<AgentWithActi
183189
COALESCE((SELECT SUM(s.cost_micro_usd) FROM agent_sessions s WHERE s.agent_id = a.id), 0) as cost_micro_usd
184190
FROM agents a
185191
WHERE a.owner_id = ?
186-
ORDER BY a.created_at DESC
187-
`)
188-
.bind(runtimeCutoff, ownerId)
192+
`;
193+
const binds: unknown[] = [runtimeCutoff, ownerId];
194+
if (filters.kind) {
195+
query += " AND a.kind = ?";
196+
binds.push(filters.kind);
197+
}
198+
if (filters.role) {
199+
query += " AND a.role = ?";
200+
binds.push(filters.role);
201+
}
202+
if (filters.runtime) {
203+
query += " AND a.runtime = ?";
204+
binds.push(filters.runtime);
205+
}
206+
query += " ORDER BY a.created_at DESC";
207+
const result = await db
208+
.prepare(query)
209+
.bind(...binds)
189210
.all<AgentWithActivity>();
190-
return result.results.map((r) => ({ ...parseAgent(r), runtime_available: !!r.runtime_available, email: `${r.username}@mails.agent-kanban.dev` }));
211+
const agents = result.results.map((r) => ({
212+
...parseAgent(r),
213+
runtime_available: !!r.runtime_available,
214+
email: `${r.username}@mails.agent-kanban.dev`,
215+
}));
216+
if (filters.available === undefined) return agents;
217+
return agents.filter((agent) => agent.runtime_available === filters.available);
191218
}
192219

193220
export async function getAgent(db: D1, agentId: string, ownerId: string): Promise<AgentWithActivity | null> {

apps/web/server/routes.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import {
22
AGENT_RUNTIMES,
3+
type AgentRuntime,
34
type CreateAgentInput,
45
findInvalidSkillRef,
56
isBoardType,
7+
isValidAgentRole,
68
isValidUsername,
79
type MachineRuntime,
810
parseScheduledAt,
@@ -85,6 +87,20 @@ function assertSubagentList(subagents: unknown) {
8587
}
8688
}
8789

90+
function assertValidAgentRole(role: unknown): void {
91+
if (role === undefined || role === null) return;
92+
if (typeof role !== "string" || !isValidAgentRole(role)) {
93+
throw new HTTPException(400, { message: "role must be kebab-case: lowercase letters, numbers, and single hyphens; start with a letter" });
94+
}
95+
}
96+
97+
function assertValidHandoffRoles(roles: unknown): void {
98+
if (roles === undefined || roles === null) return;
99+
if (!Array.isArray(roles) || roles.some((role) => typeof role !== "string" || !isValidAgentRole(role))) {
100+
throw new HTTPException(400, { message: "handoff_to must be an array of kebab-case agent roles" });
101+
}
102+
}
103+
88104
function assertSubagentRuntime(runtime: string, subagents: string[] | null | undefined) {
89105
if (!subagents || subagents.length === 0) return;
90106
if (!SUBAGENT_RUNTIMES.has(runtime)) {
@@ -99,6 +115,19 @@ function assertValidAgentRuntime(runtime: string | undefined): void {
99115
}
100116
}
101117

118+
function parseOptionalBoolean(value: string | undefined, name: string): boolean | undefined {
119+
if (value === undefined) return undefined;
120+
if (value === "true") return true;
121+
if (value === "false") return false;
122+
throw new HTTPException(400, { message: `${name} must be true or false` });
123+
}
124+
125+
function parseOptionalAgentKind(value: string | undefined): "worker" | "leader" | undefined {
126+
if (value === undefined) return undefined;
127+
if (value === "worker" || value === "leader") return value;
128+
throw new HTTPException(400, { message: "kind must be worker or leader" });
129+
}
130+
102131
async function assertRegisteredWorkerSubagents(
103132
db: Env["DB"],
104133
ownerId: string,
@@ -490,7 +519,16 @@ api.delete("/api/machines/:id", async (c) => {
490519
// ─── Agents ───
491520

492521
api.get("/api/agents", async (c) => {
493-
const agents = await listAgents(c.env.DB, c.get("ownerId"));
522+
const role = c.req.query("role");
523+
const runtime = c.req.query("runtime") as AgentRuntime | undefined;
524+
assertValidAgentRole(role);
525+
assertValidAgentRuntime(runtime);
526+
const agents = await listAgents(c.env.DB, c.get("ownerId"), {
527+
kind: parseOptionalAgentKind(c.req.query("kind")),
528+
role,
529+
runtime,
530+
available: parseOptionalBoolean(c.req.query("available"), "available"),
531+
});
494532
return c.json(agents);
495533
});
496534

@@ -519,6 +557,8 @@ api.post("/api/agents", async (c) => {
519557
if (!body.username) throw new HTTPException(400, { message: "username is required" });
520558
if (!body.runtime) throw new HTTPException(400, { message: "runtime is required" });
521559
if (!isValidUsername(body.username)) throw new HTTPException(400, { message: `Invalid username "${body.username}"` });
560+
assertValidAgentRole(body.role);
561+
assertValidHandoffRoles(body.handoff_to);
522562
assertValidAgentRuntime(body.runtime);
523563
if (body.role && RESERVED_ROLES.has(body.role)) {
524564
throw new HTTPException(403, { message: `Role "${body.role}" is reserved for built-in agents` });
@@ -597,6 +637,8 @@ api.patch("/api/agents/:id", async (c) => {
597637
const body = await c.req.json();
598638
assertJsonObject(body, "agent update");
599639
const updates = body as Partial<CreateAgentInput>;
640+
assertValidAgentRole(updates.role);
641+
assertValidHandoffRoles(updates.handoff_to);
600642
assertValidAgentRuntime(updates.runtime);
601643
assertValidSkillRefs(updates.skills);
602644
assertSubagentList(updates.subagents);

packages/cli/src/agent/systemPrompt.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ After delivering your own work, if it reveals NEW independent work (not review o
114114
To hand off:
115115
1. Run \`ak get agent -o json\` to find agents by role. Only assign to agents with \`runtime_available: true\`.
116116
2. If the matching role only exists on an unavailable runtime, create a new worker with the same role on an available runtime.
117-
3. Create a task: \`ak create task --title "..." --assign-to <agent-id>${repoFlag} --parent <current-task-id>\`
117+
3. Create a task: \`ak create task --board <current-board-id> --title "..." --assign-to <agent-id>${repoFlag} --parent <current-task-id>\`
118118
4. Log the handoff: \`ak create note --task <current-task-id> "Handed off to <agent-name> for <reason>"\`
119119
120120
Do NOT create handoff tasks for reviewing your PR — review is handled by the platform after you submit \`task review\`.

packages/cli/src/apply/kinds.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ function agentBody(spec: Record<string, unknown>, metadata: Record<string, unkno
3636
process.exit(1);
3737
}
3838
const annotations = metadata?.annotations as Record<string, unknown> | undefined;
39-
const name = annotations?.["agent-kanban.dev/display-name"];
39+
const name = annotations?.["agent-kanban.dev/nickname"];
4040
return {
4141
...spec,
4242
username,

packages/cli/src/client/base.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,9 @@ export abstract class ApiClient {
141141
reopenSession(agentId: string, sessionId: string) {
142142
return this.request("POST", `/api/agents/${agentId}/sessions/${sessionId}/reopen`);
143143
}
144-
listAgents() {
145-
return this.request("GET", "/api/agents");
144+
listAgents(params?: Record<string, string>) {
145+
const qs = params ? `?${new URLSearchParams(params).toString()}` : "";
146+
return this.request("GET", `/api/agents${qs}`);
146147
}
147148
listSessions(agentId: string) {
148149
return this.request<any[]>("GET", `/api/agents/${agentId}/sessions`);

packages/cli/src/commands/create.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ export function registerCreateCommand(program: Command) {
105105
.option("--role <role>", "Agent role")
106106
.option("--runtime <runtime>", "Agent runtime")
107107
.option("--model <model>", "Model to use")
108-
.option("--handoff-to <ids>", "Comma-separated agent IDs for handoff")
108+
.option("--handoff-to <roles>", "Comma-separated agent roles this agent may hand off to")
109109
.option("--skills <skills>", "Comma-separated installable skill refs (<source>@<skill>)")
110110
.option("--subagents <ids>", "Comma-separated worker agent IDs to install as task-local subagents")
111111
.option("-o, --output <format>", "Output format (json, yaml, text)")

packages/cli/src/commands/get.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ type AgentRef = {
2020
username: string;
2121
version: string;
2222
name: string;
23+
kind?: string;
24+
role?: string | null;
25+
runtime?: string;
26+
runtime_available?: boolean;
2327
created_at?: string;
2428
};
2529

@@ -113,15 +117,21 @@ export function registerGetCommand(program: Command) {
113117
.command("agent [id]")
114118
.description("Get an agent or list agents")
115119
.option("-o, --output <format>", "Output format (json, yaml, text)")
120+
.option("--role <role>", "Filter by agent role")
121+
.option("--runtime <runtime>", "Filter by runtime")
122+
.option("--available", "Only show agents whose runtime is available")
116123
.action(async (id: string | undefined, opts) => {
117124
const client = await createClient();
118125
const fmt = getOutputFormat(opts.output);
119126
if (id) {
120127
const { value, formatter } = await getAgentOrVersions(client, id);
121128
output(value, fmt, formatter, { kind: "agent" });
122129
} else {
123-
const all = (await client.listAgents()) as any[];
124-
const agents = all.filter((a: any) => a.kind !== "leader");
130+
const params: Record<string, string> = { kind: "worker" };
131+
if (opts.role) params.role = opts.role;
132+
if (opts.runtime) params.runtime = opts.runtime;
133+
if (opts.available) params.available = "true";
134+
const agents = (await client.listAgents(params)) as AgentRef[];
125135
output(agents, fmt, formatAgentList, { kind: "agent" });
126136
}
127137
});

packages/cli/src/commands/update.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export function registerUpdateCommand(program: Command) {
9999
.option("--role <role>", "Agent role")
100100
.option("--runtime <runtime>", "Agent runtime")
101101
.option("--model <model>", "Agent model")
102-
.option("--handoff-to <ids>", "Comma-separated agent IDs for handoff")
102+
.option("--handoff-to <roles>", "Comma-separated agent roles this agent may hand off to")
103103
.option("--skills <skills>", "Comma-separated installable skill refs (<source>@<skill>)")
104104
.option("--subagents <ids>", "Comma-separated worker agent IDs to install as task-local subagents")
105105
.option("-o, --output <format>", "Output format (json, yaml, text)")
@@ -116,7 +116,9 @@ export function registerUpdateCommand(program: Command) {
116116
if (opts.skills) body.skills = opts.skills.split(",").map((s: string) => s.trim());
117117
if (opts.subagents) body.subagents = opts.subagents.split(",").map((s: string) => s.trim());
118118
if (Object.keys(body).length === 0) {
119-
console.error("Nothing to update. Provide at least one option (--name, --bio, --role, --runtime, --model, --skills, --subagents).");
119+
console.error(
120+
"Nothing to update. Provide at least one option (--name, --bio, --role, --runtime, --model, --handoff-to, --skills, --subagents).",
121+
);
120122
process.exit(1);
121123
}
122124
const agent = await client.updateAgent(id, body);

packages/cli/tests/client.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -769,6 +769,17 @@ describe("ApiClient method stubs", () => {
769769
expect(url).toContain("status=todo");
770770
});
771771

772+
it("listAgents appends query string when params provided", async () => {
773+
const c = await makeAgentClient();
774+
stubOk([]);
775+
await c.listAgents({ kind: "worker", role: "test-specialist", available: "true" });
776+
const [url] = lastCall();
777+
expect(url).toContain("/api/agents?");
778+
expect(url).toContain("kind=worker");
779+
expect(url).toContain("role=test-specialist");
780+
expect(url).toContain("available=true");
781+
});
782+
772783
it("getTask calls GET /api/tasks/:id", async () => {
773784
const c = await makeAgentClient();
774785
stubOk({});

packages/cli/tests/get-agent.test.ts

Lines changed: 40 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
/**
33
* Tests for `get agent` command handler in commands/get.ts.
44
*
5-
* Covers the leader-filtering added to list mode and agent lookup:
6-
* - `ak get agent` (no id) → calls listAgents, filters out kind === "leader"
5+
* Covers list mode and agent lookup:
6+
* - `ak get agent` (no id) → calls listAgents with worker filter
77
* - `ak get agent <id>` → calls getAgent directly, no filtering
88
* - `ak get agent <username>` → lists versions for that username when no id exists
99
*/
@@ -68,29 +68,16 @@ afterEach(() => {
6868

6969
// ── Tests: list mode ──────────────────────────────────────────────────────────
7070

71-
describe("get agent — list mode leader filtering", () => {
72-
it("calls listAgents once", async () => {
71+
describe("get agent — list mode filtering", () => {
72+
it("calls listAgents once with worker kind filter", async () => {
7373
mockListAgents.mockResolvedValue([]);
7474
await makeProgram().parseAsync(["get", "agent"], { from: "user" });
75-
expect(mockListAgents).toHaveBeenCalledOnce();
76-
});
77-
78-
it("filters out the agent where kind === 'leader'", async () => {
79-
mockListAgents.mockResolvedValue([
80-
{ id: "a1", kind: "worker", name: "Alice" },
81-
{ id: "a2", kind: "leader", name: "LeaderBot" },
82-
{ id: "a3", kind: "worker", name: "Bob" },
83-
]);
84-
await makeProgram().parseAsync(["get", "agent"], { from: "user" });
85-
// output(agents, fmt, formatAgentList, ...) — first arg is the agents array
86-
const passed = vi.mocked(outputModule.output).mock.calls[0][0] as any[];
87-
expect(passed.find((a: any) => a.kind === "leader")).toBeUndefined();
75+
expect(mockListAgents).toHaveBeenCalledWith({ kind: "worker" });
8876
});
8977

90-
it("keeps all non-leader agents and preserves their order", async () => {
78+
it("passes through agents returned by the API", async () => {
9179
mockListAgents.mockResolvedValue([
9280
{ id: "a1", kind: "worker", name: "Alice" },
93-
{ id: "a2", kind: "leader", name: "LeaderBot" },
9481
{ id: "a3", kind: "worker", name: "Bob" },
9582
]);
9683
await makeProgram().parseAsync(["get", "agent"], { from: "user" });
@@ -99,26 +86,13 @@ describe("get agent — list mode leader filtering", () => {
9986
expect(passed.map((a: any) => a.id)).toEqual(["a1", "a3"]);
10087
});
10188

102-
it("passes an empty array when all agents are leaders", async () => {
103-
mockListAgents.mockResolvedValue([
104-
{ id: "l1", kind: "leader", name: "Alpha" },
105-
{ id: "l2", kind: "leader", name: "Beta" },
106-
]);
89+
it("passes an empty array when the API returns no workers", async () => {
90+
mockListAgents.mockResolvedValue([]);
10791
await makeProgram().parseAsync(["get", "agent"], { from: "user" });
10892
const passed = vi.mocked(outputModule.output).mock.calls[0][0] as any[];
10993
expect(passed).toHaveLength(0);
11094
});
11195

112-
it("passes all agents when none are leaders", async () => {
113-
mockListAgents.mockResolvedValue([
114-
{ id: "w1", kind: "worker", name: "Worker1" },
115-
{ id: "w2", kind: "worker", name: "Worker2" },
116-
]);
117-
await makeProgram().parseAsync(["get", "agent"], { from: "user" });
118-
const passed = vi.mocked(outputModule.output).mock.calls[0][0] as any[];
119-
expect(passed).toHaveLength(2);
120-
});
121-
12296
it("does not call getAgent in list mode", async () => {
12397
mockListAgents.mockResolvedValue([]);
12498
await makeProgram().parseAsync(["get", "agent"], { from: "user" });
@@ -130,6 +104,38 @@ describe("get agent — list mode leader filtering", () => {
130104
await makeProgram().parseAsync(["get", "agent"], { from: "user" });
131105
expect(exitSpy).not.toHaveBeenCalled();
132106
});
107+
108+
it("filters workers by role", async () => {
109+
mockListAgents.mockResolvedValue([{ id: "a1", kind: "worker", name: "Alice", role: "qa" }]);
110+
await makeProgram().parseAsync(["get", "agent", "--role", "qa"], { from: "user" });
111+
expect(mockListAgents).toHaveBeenCalledWith({ kind: "worker", role: "qa" });
112+
const passed = vi.mocked(outputModule.output).mock.calls[0][0] as any[];
113+
expect(passed.map((a: any) => a.id)).toEqual(["a1"]);
114+
});
115+
116+
it("filters workers by runtime", async () => {
117+
mockListAgents.mockResolvedValue([{ id: "a1", kind: "worker", name: "Alice", runtime: "codex" }]);
118+
await makeProgram().parseAsync(["get", "agent", "--runtime", "copilot"], { from: "user" });
119+
expect(mockListAgents).toHaveBeenCalledWith({ kind: "worker", runtime: "copilot" });
120+
const passed = vi.mocked(outputModule.output).mock.calls[0][0] as any[];
121+
expect(passed.map((a: any) => a.id)).toEqual(["a1"]);
122+
});
123+
124+
it("filters workers by runtime availability", async () => {
125+
mockListAgents.mockResolvedValue([{ id: "a1", kind: "worker", name: "Alice", runtime_available: true }]);
126+
await makeProgram().parseAsync(["get", "agent", "--available"], { from: "user" });
127+
expect(mockListAgents).toHaveBeenCalledWith({ kind: "worker", available: "true" });
128+
const passed = vi.mocked(outputModule.output).mock.calls[0][0] as any[];
129+
expect(passed.map((a: any) => a.id)).toEqual(["a1"]);
130+
});
131+
132+
it("combines role, runtime, and availability filters", async () => {
133+
mockListAgents.mockResolvedValue([{ id: "a1", kind: "worker", name: "Alice", role: "qa", runtime: "codex", runtime_available: true }]);
134+
await makeProgram().parseAsync(["get", "agent", "--role", "qa", "--runtime", "codex", "--available"], { from: "user" });
135+
expect(mockListAgents).toHaveBeenCalledWith({ kind: "worker", role: "qa", runtime: "codex", available: "true" });
136+
const passed = vi.mocked(outputModule.output).mock.calls[0][0] as any[];
137+
expect(passed.map((a: any) => a.id)).toEqual(["a1"]);
138+
});
133139
});
134140

135141
// ── Tests: single-agent fetch ─────────────────────────────────────────────────

0 commit comments

Comments
 (0)