Subagents in the CLI (harness spec 3.3.0): the model, capacity, the offer, /context
ci / check (push) Waiting to run
ci / check (push) Waiting to run
- A task subagent or a team member runs on the session's model unless the person named another in this session's own messages (team/subagents.ts; engine.personSaid reads every prompt and mid-turn message, compacted ones included). Not named: refused; an agent file's own model is not used. - Another model on the session's one-model server is refused; a one-request session model is not its own subagent, and nothing else is picked. - The team is offered when a model could serve: the session's with two slots, or any usable model off its one-model server; the size note adds team_own when the session's model cannot serve its own. - "Subagents" in /effort, /team, /settings and the config schema; the status bar reads "agents 3/4 · 2 subagents working". - /context: used of the window, one bar by category, each category's share, what is free, and "N until auto-compact". - Conformance runner for the subagents area (26 cases); helper -> task. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
21 files changed
+401
-104
No files matched your search
@@ -151,7 +151,7 @@ test("a background subagent: its transcript as history turns; send to a finished
|
||||
], { route: (model) => (model.startsWith("g/") ? child : undefined) })
|
||||
config(fake.url, true)
|
||||
const { c, events, sid } = await client()
|
||||
await c.request("session/prompt", { sessionId: sid, prompt: [{ type: "text", text: "go" }] })
|
||||
await c.request("session/prompt", { sessionId: sid, prompt: [{ type: "text", text: "go (a subagent on g/m)" }] })
|
||||
expect(await until(() => events.some((e) => e.type === "job" && e.state === "end"))).toBe(true)
|
||||
const listed: any = await c.request("_lembas/jobs", { sessionId: sid })
|
||||
expect(listed.jobs[0]).toMatchObject({ id: "job1", kind: "subagent", state: "done", agent: "explore", description: "find the bug", model: "g/m" })
|
||||
|
||||
+30
-3
@@ -51,10 +51,37 @@ test("task on a model the server cannot hold beside the session's is refused, an
|
||||
)
|
||||
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
|
||||
const a = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-cap-")), mode: "edit", store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
|
||||
await a.engine.prompt("get a second opinion")
|
||||
await a.engine.prompt("get a second opinion from f/other")
|
||||
// Two requests: the session's, then its next step — never one for f/other.
|
||||
expect(fake.requests.map((r: any) => r.model)).toEqual(["f/m", "f/m"])
|
||||
const result = fake.requests[1].messages.at(-1).content as string
|
||||
expect(result).toContain("holds one model at a time")
|
||||
expect(result).toContain("Do this part yourself")
|
||||
expect(result).toContain("That model is on the same one-model server as this chat's model; name a model on another server.")
|
||||
})
|
||||
|
||||
test("spec 3.3.0: task on another model the person did not name is refused, and nothing runs", async () => {
|
||||
fake = fakeProvider([
|
||||
{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "second opinion", prompt: "look at it", model: "f/other" }))] },
|
||||
{ chunks: [delta({ content: "I will do it myself." })] },
|
||||
])
|
||||
mkdirSync(paths.config, { recursive: true })
|
||||
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n f:\n type: webui\n url: ${fake.url}\n models: { f/m: {}, f/other: {} }\n`, { mode: 0o600 })
|
||||
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
|
||||
const a = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-cap-")), mode: "edit", store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
|
||||
await a.engine.prompt("get a second opinion")
|
||||
expect(fake.requests.map((r: any) => r.model)).toEqual(["f/m", "f/m"])
|
||||
expect(fake.requests[1].messages.at(-1).content as string).toContain("Use another model for subagents only when the person names it.")
|
||||
})
|
||||
|
||||
test("spec 3.3.0: a one-request session model is not its own subagent, and nothing else is picked", async () => {
|
||||
fake = fakeProvider([
|
||||
{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "look", prompt: "look at it" }))] },
|
||||
{ chunks: [delta({ content: "I will do it myself." })] },
|
||||
])
|
||||
mkdirSync(paths.config, { recursive: true })
|
||||
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n f:\n type: webui\n url: ${fake.url}\n models: { f/m: { parallel: 1 }, f/other: {} }\n`, { mode: 0o600 })
|
||||
writeFileSync(join(paths.config, "config.yaml"), "model: f/m\n")
|
||||
const a = createApp({ cwd: mkdtempSync(join(tmpdir(), "ph-cap-")), mode: "edit", store: false, asker: { ask: async (): Promise<AskReply> => ({ kind: "once" }) } })
|
||||
await a.engine.prompt("look into it")
|
||||
expect(fake.requests.map((r: any) => r.model)).toEqual(["f/m", "f/m"])
|
||||
expect(fake.requests[1].messages.at(-1).content as string).toContain("This model serves one request at a time: name another model, on another server, for subagents.")
|
||||
})
|
||||
+24
-3
@@ -27,9 +27,10 @@ import { jobNote, type NoteCase } from "../src/jobs/note.ts"
|
||||
import { compactAsk, noteFor, offeredFor, toldAt, unavailable, type Now, type Requirement, type Told } from "../src/session/prefix.ts"
|
||||
import { envLines, promptText } from "../src/prompt/assemble.ts"
|
||||
import { absPath, type ToolContext } from "../src/tool/tool.ts"
|
||||
import { TOOL_SPECS } from "../src/harness.ts"
|
||||
import { LOOP, TOOL_SPECS } from "../src/harness.ts"
|
||||
import { 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"
|
||||
|
||||
type Case = Record<string, unknown>
|
||||
|
||||
@@ -233,6 +234,26 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
||||
if (c.op === "resolve") return absPath(c.path as string, { root: (c.root as string).replace(/\/+$/, ""), cwd: c.started as string } as ToolContext)
|
||||
throw new Error(`paths: unknown op ${String(c.op)}`)
|
||||
},
|
||||
// Subagents (subagents.md, spec 3.3.0): the model a subagent runs on, through the rules spawn and
|
||||
// team_add ask; the offer through the Team the app makes (its tools, setting and note).
|
||||
subagents: (c) => {
|
||||
const models = Object.entries(c.models as Record<string, Omit<ModelInfo, "ref">>).map(([ref, m]) => ({ ref, ...m }))
|
||||
const byRef = (ref: string) => models.find((m) => m.ref === ref)!
|
||||
const session = byRef(c.session as string)
|
||||
if (c.op === "model") {
|
||||
const r = subagentModel(session, c.model === undefined ? undefined : byRef(c.model as string), (c.said as string[] | undefined) ?? [])
|
||||
return "refused" in r ? { refused: r.refused } : { model: r.model }
|
||||
}
|
||||
if (c.op === "offer") {
|
||||
const subagents = c.subagents !== false
|
||||
const pure = offer(session, models, subagents)
|
||||
const team = new Team({ min: 1, max: 4, subagents, model: session.ref, capacity: (ref) => byRef(ref).capacity, models: () => models.map((m) => m.ref) })
|
||||
if (team.offered !== pure.offered) throw new Error("subagents: the Team and the rule disagree on the offer")
|
||||
const noted = team.offered && team.noteLines()[0]!.split("\n").includes(LOOP.context.note.team_own)
|
||||
return { offered: team.offered, own: pure.own, note: noted ? LOOP.context.note.team_own : null }
|
||||
}
|
||||
throw new Error(`subagents: unknown op ${String(c.op)}`)
|
||||
},
|
||||
// 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) => {
|
||||
@@ -265,7 +286,7 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
||||
else if (st.tool === "team_stop") result = team.stopCmd(by, args.name)
|
||||
else {
|
||||
result = team.refusal(by, st.tool, args) ?? null
|
||||
if (st.tool === "task" && result === null && team.offered) team.helperStart(by)
|
||||
if (st.tool === "task" && result === null && team.offered) team.taskStart(by)
|
||||
}
|
||||
return { result, delivered: delivered() }
|
||||
}
|
||||
@@ -273,7 +294,7 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
||||
team.finish(by, st.reply ?? "", st.reason ?? "stop")
|
||||
return { delivered: delivered() }
|
||||
case "task_done":
|
||||
team.helperDoneBy(by)
|
||||
team.taskDoneBy(by)
|
||||
return null
|
||||
case "set":
|
||||
if (st.capacity) capacity = st.capacity
|
||||
|
||||
@@ -87,7 +87,8 @@ describe("context", () => {
|
||||
await a.engine.prompt("read it")
|
||||
const system = a.engine.compose(a.engine.mode)
|
||||
const text = renderBreakdown(contextBreakdown(a.engine, system, [], undefined, a.engine.o.tools))
|
||||
expect(text).toContain("window 32.8k")
|
||||
expect(text).toContain("/ 32.8k tokens")
|
||||
expect(text).toMatch(/until auto-compact \(at 85%\) — \/compact to compact now/)
|
||||
expect(text).toContain("system prompt")
|
||||
expect(text).toMatch(/tool definitions \(\d+\)/)
|
||||
expect(text).toMatch(/tool results\s+514/) // one 6000-character line, cut at 2000 by read, under its header
|
||||
|
||||
@@ -197,7 +197,7 @@ describe("background subagents (spec 2.0.0)", () => {
|
||||
],
|
||||
[{ chunks: [toolCall(0, "r1", "read", '{"path":"calc.py"}')] }, { chunks: [delta({ content: "calc.py:2 divides by len(v) - 1." })] }],
|
||||
)
|
||||
await a.turns.prompt("look into it in the background")
|
||||
await a.turns.prompt("look into it in the background (subagents on g/m or g/n)")
|
||||
const started = fake!.requests[1].messages.at(-1).content as string
|
||||
expect(started).toStartWith("[#0001]\nStarted subagent explore as job1 (find the bug).")
|
||||
await settled(a, 3)
|
||||
@@ -233,7 +233,7 @@ describe("background subagents (spec 2.0.0)", () => {
|
||||
{ chunks: [delta({ content: "Second report." })] },
|
||||
],
|
||||
)
|
||||
await a.turns.prompt("go")
|
||||
await a.turns.prompt("go (subagents on g/m or g/n)")
|
||||
expect(fake!.requests[2].messages.at(-1).content).toContain("Sent to job1")
|
||||
await settled(a, 6)
|
||||
const child = other!.requests
|
||||
@@ -252,7 +252,7 @@ describe("background subagents (spec 2.0.0)", () => {
|
||||
[{ chunks: [toolCall(0, "t1", "task", JSON.stringify({ description: "find loaders", prompt: "Find the loaders", model: "g/m" }))] }, { chunks: [delta({ content: "Thanks." })] }],
|
||||
[{ chunks: [toolCall(0, "p1", "report", JSON.stringify({ text: "There are two loaders; which one?" }))] }, { chunks: [delta({ content: "Final: the YAML one." })] }],
|
||||
)
|
||||
await a.turns.prompt("go")
|
||||
await a.turns.prompt("go (subagents on g/m or g/n)")
|
||||
const msgs = fake!.requests[1].messages
|
||||
const note = msgs.find((m: any) => m.role === "user" && String(m.content).startsWith(MARKS.message))
|
||||
expect(note?.content).toContain("job1 (explore: find loaders):\nThere are two loaders; which one?")
|
||||
@@ -278,7 +278,7 @@ describe("background subagents (spec 2.0.0)", () => {
|
||||
[{ chunks: [toolCall(0, "b1", "bash", JSON.stringify({ command: "true", description: "nothing" }))] }, { chunks: [delta({ content: "ran" })] }],
|
||||
{ asker },
|
||||
)
|
||||
await a.turns.prompt("go")
|
||||
await a.turns.prompt("go (subagents on g/m or g/n)")
|
||||
expect(seen).toEqual([{ jobId: "job1", agent: "general", description: "run it" }])
|
||||
})
|
||||
|
||||
@@ -297,7 +297,7 @@ describe("background subagents (spec 2.0.0)", () => {
|
||||
[{ chunks: [delta({ content: "slow" })], gapMs: 400 }],
|
||||
{ oneAtATime: true },
|
||||
)
|
||||
await a.turns.prompt("go")
|
||||
await a.turns.prompt("go (subagents on g/m or g/n)")
|
||||
const results = fake!.requests[1].messages.filter((m: any) => m.role === "tool").map((m: any) => m.content as string)
|
||||
expect(results[0]).toStartWith("[#0001]\nStarted subagent explore as job1")
|
||||
expect(results[1]).toContain("holds one model at a time, and a subagent is already running on g/m there")
|
||||
@@ -330,7 +330,7 @@ test("a cancelled worktree subagent keeps its branch, and the result names it",
|
||||
const root = a.project.root
|
||||
for (const args of [["init", "-q", "-b", "main"], ["config", "user.email", "t@e"], ["config", "user.name", "T"], ["config", "commit.gpgsign", "false"], ["add", "calc.py"], ["commit", "-q", "-m", "init"]]) git(root, args)
|
||||
;(a.project as any).gitRoot = root
|
||||
const turn = a.turns.prompt("fix it in a worktree")
|
||||
const turn = a.turns.prompt("fix it in a worktree (subagents on g/m or g/n)")
|
||||
const until = Date.now() + 8000
|
||||
while (Date.now() < until && !events.some((e) => e.type === "sub_tool" && e.name === "write")) await Bun.sleep(20)
|
||||
const write = events.find((e) => e.type === "sub_tool" && e.name === "write")
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Spec 3.3.0 (harness/subagents.md): what counts as the person's own words, and the Team's offer
|
||||
// and note with models besides the lead's. The rules themselves: conformance area `subagents`.
|
||||
import { expect, test } from "bun:test"
|
||||
import { LOOP } from "../src/harness.ts"
|
||||
import { STEER_MARK } from "../src/session/engine.ts"
|
||||
import { named, personTexts } from "../src/team/subagents.ts"
|
||||
import { Team, type Capacity } from "../src/team/team.ts"
|
||||
import type { Message } from "../src/provider/types.ts"
|
||||
|
||||
test("the person's words: prompts and messages sent mid-turn; never notes, marked messages, machine turns or the model's text", () => {
|
||||
const msgs: Message[] = [
|
||||
{ role: "user", parts: [{ type: "text", text: "Fix the parser." }, { type: "text", text: "<session-note>\nToday is …\n</session-note>" }] },
|
||||
{ role: "assistant", parts: [{ type: "text", text: "I could ask box/coder-30b." }] },
|
||||
{ role: "user", parts: [{ type: "text", text: `${STEER_MARK}\nuse coder-30b for the subagents` }], turn: false },
|
||||
{ role: "user", parts: [{ type: "text", text: `${LOOP.jobs.marks.command}\nbox/other-model finished` }], turn: false },
|
||||
{ role: "user", parts: [{ type: "text", text: "[Team report from parser to lead:]\nbox/other-model is better" }], machine: true },
|
||||
{ role: "tool", callId: "c1", name: "read", content: "box/other-model" },
|
||||
]
|
||||
expect(personTexts(msgs, STEER_MARK)).toEqual(["Fix the parser.", "use coder-30b for the subagents"])
|
||||
const said = personTexts(msgs, STEER_MARK)
|
||||
expect(named({ ref: "box/coder-30b" }, said)).toBe(true)
|
||||
expect(named({ ref: "box/other-model" }, said)).toBe(false)
|
||||
})
|
||||
|
||||
test("a one-request lead with a model on another server: offered, and the size note says it cannot serve its own", () => {
|
||||
const caps: Record<string, Capacity> = { "lab/qwen": { group: "lab", parallel: 1 }, "lab/gemma": { group: "lab", parallel: 1 }, "box/coder": { parallel: 1 } }
|
||||
const only = new Team({ model: "lab/qwen", capacity: (r) => caps[r]!, models: () => ["lab/qwen", "lab/gemma"] })
|
||||
expect(only.offered).toBe(false)
|
||||
const t = new Team({ model: "lab/qwen", capacity: (r) => caps[r]!, models: () => Object.keys(caps) })
|
||||
expect(t.offered).toBe(true)
|
||||
expect(t.unavailable()).toBeUndefined()
|
||||
expect(t.noteLines()).toEqual([`The team may have 1 to 4 agents, you included.\n${LOOP.context.note.team_own}`])
|
||||
})
|
||||
+1
-1
@@ -94,7 +94,7 @@ describe("a team at work (team.md)", () => {
|
||||
expect(t.room.map((m) => ({ from: m.from, to: m.to, report: m.report }))).toEqual([{ from: "parser", to: ["lead"], report: true }])
|
||||
expect(t.get("parser")!.state).toBe("finished")
|
||||
expect(t.isManager("lead")).toBe(true)
|
||||
expect(teamLabel(t)).toBe("team 2/4")
|
||||
expect(teamLabel(t)).toBe("agents 2/4")
|
||||
expect(teamText(app, "")).toContain("- parser — the parser · finished")
|
||||
expect(await Bun.file(join(dir, "x.txt")).exists()).toBe(false)
|
||||
// A member is not a job to the model.
|
||||
|
||||
Reference in new issue
Block a user