Subagents v2 in the CLI (harness spec 3.4.0): the main agent's tree, modes and requests, findings, finishing and running out of context; progress phases
ci / check (push) Waiting to run
ci / check (push) Waiting to run
team/team.ts: ids, positions, parents; only the main agent adds, assigns, replaces, stops and decides; sub-managers; slots; modes inherited and never widened, request options and grants; findings; team_finish; pause and out_of_context; ended subagents kept; the tree's data. Seven new tools. A subagent's call is decided by team/delegation.ts (itself, the main agent through a request, or the person). Its engine does not compact: at pause_at it is paused and gets one final turn. Every engine says its progress as phase events (session/progress.ts). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
15 files changed
+951
-77
No files matched your search
+35
-6
@@ -28,7 +28,7 @@ import { compactAsk, noteFor, offeredFor, toldAt, unavailable, type Now, type Re
|
||||
import { envLines, promptText } from "../src/prompt/assemble.ts"
|
||||
import { absPath, type ToolContext } from "../src/tool/tool.ts"
|
||||
import { LOOP, TOOL_SPECS } from "../src/harness.ts"
|
||||
import { Team, TEAM_TOOLS, type Capacity } from "../src/team/team.ts"
|
||||
import { LEAD_TOOLS, memberTools, Team, TEAM_TOOLS, type Capacity } from "../src/team/team.ts"
|
||||
import { noteBlock } from "../src/session/prefix.ts"
|
||||
import { offer, subagentModel, type ModelInfo } from "../src/team/subagents.ts"
|
||||
import { progressLine, progressText, type Progress } from "../src/session/progress.ts"
|
||||
@@ -260,9 +260,12 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
||||
// Teams (team.md): one lead session's team, step by step, through the CLI's own rules
|
||||
// (team/team.ts); the app drives the same class from real turns.
|
||||
team: (c) => {
|
||||
const start = c.start as { min: number; max: number; subagents?: boolean; capacity: Capacity }
|
||||
const start = c.start as { min: number; max: number; subagents?: boolean; capacity: Capacity; mode?: Mode }
|
||||
let capacity = start.capacity
|
||||
const team = new Team({ min: start.min, max: start.max, subagents: start.subagents, model: "m", capacity: () => capacity })
|
||||
const team = new Team({ min: start.min, max: start.max, subagents: start.subagents, model: "m", capacity: () => capacity, mode: start.mode ?? "edit", steps: { main: 200, sub: 50 } })
|
||||
// What each agent's turn was started with (a brief, or the messages that woke it).
|
||||
const started: Record<string, string> = {}
|
||||
team.effects = { start: (n, t) => void (started[n] = t), deliver: () => {}, stop: () => {} }
|
||||
const delivered = () => {
|
||||
const out: Record<string, string[]> = {}
|
||||
for (const d of team.delivered.splice(0)) (out[d.name] ??= []).push(d.text)
|
||||
@@ -273,8 +276,8 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
||||
switch (st.op) {
|
||||
case "offered": {
|
||||
const a = team.get(by)!
|
||||
const tools = !team.offered ? [] : a.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"]
|
||||
return { tools: tools.sort(), setting: team.offered }
|
||||
const tools = !team.offered ? [] : by === "lead" ? [...LEAD_TOOLS] : memberTools(a)
|
||||
return { tools: [...tools].sort(), setting: team.offered }
|
||||
}
|
||||
case "call": {
|
||||
const args = st.args as Record<string, any>
|
||||
@@ -284,9 +287,18 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
||||
const room = no ? [] : team.readRoom(by, args.since ?? 0).messages.map((m) => ({ n: m.n, from: m.from, to: m.to.join(", ") }))
|
||||
return { room, delivered: delivered() }
|
||||
}
|
||||
if (st.tool === "team_add") result = team.add(by, args as any).text
|
||||
const first = (TEAM_TOOLS as readonly string[]).includes(st.tool) ? team.refusal(by, st.tool, args) : undefined
|
||||
if (first) result = first
|
||||
else if (st.tool === "team_add") result = team.add(by, args as any).text
|
||||
else if (st.tool === "team_send") result = team.send(by, args as any)
|
||||
else if (st.tool === "team_stop") result = team.stopCmd(by, args.name)
|
||||
else if (st.tool === "team_assign") result = team.assign(by, args as any)
|
||||
else if (st.tool === "team_replace") result = team.replace(by, args as any).text
|
||||
else if (st.tool === "team_finish") result = team.finishTask(by, args.report)
|
||||
else if (st.tool === "team_whoami") result = team.whoami(by)
|
||||
else if (st.tool === "team_findings") result = args.action === "write" ? team.noteFinding(by, args.text ?? "") : team.readFindings(by, args.of)
|
||||
else if (st.tool === "team_request") result = team.request(by, args as any, TOOL_SPECS[args.tool]?.risk as any)
|
||||
else if (st.tool === "team_decide") result = team.decide(by, args as any)
|
||||
else {
|
||||
result = team.refusal(by, st.tool, args) ?? null
|
||||
if (st.tool === "task" && result === null && team.offered) team.taskStart(by)
|
||||
@@ -313,6 +325,23 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
||||
}
|
||||
case "state":
|
||||
return team.state()
|
||||
// Spec 3.4.0.
|
||||
case "context":
|
||||
team.setContext(by, st.used, st.window)
|
||||
return null
|
||||
case "context_full":
|
||||
team.pause(by)
|
||||
return { delivered: delivered() }
|
||||
case "started":
|
||||
return { text: started[by] ?? null }
|
||||
case "tree": {
|
||||
const t = team.info()
|
||||
const row = (a: (typeof t.agents)[number]) => ({ id: a.id, name: a.name, parent: a.parent, state: a.state, mode: a.mode, request: a.request })
|
||||
return { size: t.size, open_slots: t.open_slots, agents: t.agents.map(row), ended: t.ended.map((a) => ({ id: a.id, name: a.name, state: a.state })) }
|
||||
}
|
||||
case "main_mode":
|
||||
team.setMainMode(st.mode)
|
||||
return null
|
||||
}
|
||||
throw new Error(`team: unknown op ${String(st.op)}`)
|
||||
})
|
||||
|
||||
@@ -153,7 +153,7 @@ test("spec 3.0.0: every tool says where it is offered; the CLI's are the agent's
|
||||
// plan_write's former name rather than a tool.
|
||||
expect(TOOL_SPECS.todo).toBeUndefined()
|
||||
expect(TOOL_SPECS.plan_write!.aliases.cli).toEqual(["todo"])
|
||||
const shared = ["ask_user", "edit", "glob", "grep", "job_list", "job_output", "job_send", "job_stop", "list", "memory", "plan_submit", "plan_update", "plan_write", "read", "recall", "report", "session_search", "show", "skill_manage", "skill_view", "skills_list", "task", "team_add", "team_read", "team_send", "team_stop", "web_fetch", "web_search", "write"]
|
||||
const shared = ["ask_user", "edit", "glob", "grep", "job_list", "job_output", "job_send", "job_stop", "list", "memory", "plan_submit", "plan_update", "plan_write", "read", "recall", "report", "session_search", "show", "skill_manage", "skill_view", "skills_list", "task", "team_add", "team_assign", "team_decide", "team_findings", "team_finish", "team_read", "team_replace", "team_request", "team_send", "team_stop", "team_whoami", "web_fetch", "web_search", "write"]
|
||||
expect(Object.values(TOOL_SPECS).filter((t) => t.scope === "shared").map((t) => t.name).sort()).toEqual(shared.sort())
|
||||
})
|
||||
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ const yes = { ask: async (): Promise<AskReply> => ({ kind: "once" }) }
|
||||
const say = (text: string): Scripted => ({ chunks: [delta({ content: text }, "stop")] })
|
||||
const call = (id: string, name: string, args: unknown): Scripted => ({ chunks: [toolCall(0, id, name, JSON.stringify(args)), delta({}, "tool_calls")] })
|
||||
const system = (body: any): string => body.messages[0].content
|
||||
const isMember = (body: any, name: string) => system(body).includes(`You are "${name}", a member of a team`)
|
||||
const isMember = (body: any, name: string) => new RegExp(`You are "${name}" \\(s\\d+\\), a subagent`).test(system(body))
|
||||
const lastUser = (body: any): string => {
|
||||
const u = [...body.messages].reverse().find((m: any) => m.role === "user")
|
||||
return typeof u.content === "string" ? u.content : u.content.map((p: any) => p.text ?? "").join("")
|
||||
@@ -95,7 +95,7 @@ describe("a team at work (team.md)", () => {
|
||||
expect(t.get("parser")!.state).toBe("finished")
|
||||
expect(t.isManager("lead")).toBe(true)
|
||||
expect(teamLabel(t)).toBe("agents 2/4")
|
||||
expect(teamText(app, "")).toContain("- parser — the parser · finished")
|
||||
expect(teamText(app, "")).toContain("- parser (s1) — the parser · finished")
|
||||
expect(await Bun.file(join(dir, "x.txt")).exists()).toBe(false)
|
||||
// A member is not a job to the model.
|
||||
expect(app.jobs().every((j) => j.team === "parser")).toBe(true)
|
||||
|
||||
Reference in new issue
Block a user