diff --git a/CHANGELOG.md b/CHANGELOG.md index 5097df6..85300ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,32 @@ All notable changes to LLeMbas CLI. The format follows ## [Unreleased] -Harness spec 3.3.0 (spec commit 02c7282): subagents. +Harness spec 3.3.0 (spec commit 02c7282): subagents run on your model unless you name another, +and `/context` shows where the window goes the way the side panel of the other apps does. + +### Changed + +- **Subagents run on the session's model**, unless you named another model in this session's own + messages — its id, the part after its provider, or its display name, as a whole word, in any + letter case. A `task` or `team_add` that asks for any other model is refused ("Use another + model for subagents only when the person names it."), and an agent file's own `model:` is no + longer used. Nothing in the settings chooses a subagent's model. +- **Only the logical capacity limits remain.** A named model on the same one-model server as the + session's model is refused ("That model is on the same one-model server as this chat's model; + name a model on another server."). On a model that serves one request at a time, a subagent on + that same model is refused ("This model serves one request at a time: name another model, on + another server, for subagents.") and nothing else is picked instead. +- **Subagents are offered** — the size setting, the team tools — when subagents are on and some + model could serve one: the session's, when it serves two requests at once, or any model of the + server that is not on its one-model server. When the session's model cannot serve its own + subagents, the agent is told so in its first note, and `/effort`, `/team size` and `/team` say it + beside the setting. +- **"Subagents" is the word** for the setting (fewest and most agents at work, the lead included): + the row beside the effort in `/effort`, `/team size`, `/settings`. "Helper" is gone. The status + bar reads `agents 3/4 · 2 subagents working` while there is a team. +- **`/context`** shows the window as used tokens of the window and a percentage, one bar split by + category, every category with its tokens and its share of the window, what is free, and how far + it is to automatic compaction (`N until auto-compact`, with `/compact` to do it now). ## [2.0.0-beta.3] — 2026-11-09 diff --git a/schema/config.schema.json b/schema/config.schema.json index 0feaee9..8e26977 100644 --- a/schema/config.schema.json +++ b/schema/config.schema.json @@ -894,17 +894,17 @@ ] }, "agents": { - "description": "Teams (harness team.md): how many agents a session's team may have. Offered only where subagents are on and the model serves two requests at once. /agents changes it for the session.", + "description": "Subagents (harness team.md, subagents.md): how many agents a session may have at work. Offered where subagents are on and a model could serve one: the session's, when it serves two requests at once, or another model off its one-model server. /team size and /effort change it for the session.", "type": "object", "properties": { "min": { - "description": "The fewest agents a team works with, the lead included (default 1). 2 or more: the lead works through a team from the start.", + "description": "Subagents, fewest: the fewest agents at work, the lead included (default 1). 2 or more: the lead works through subagents from the start.", "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "max": { - "description": "The most agents a team may have, the lead included (default 4). 1: one agent, no team and no helper.", + "description": "Subagents, most: the most agents at work, the lead included (default 4). 1: one agent, no subagents.", "type": "integer", "minimum": 1, "maximum": 9007199254740991 diff --git a/schema/connections.schema.json b/schema/connections.schema.json index 8e3c6e1..fb2fd99 100644 --- a/schema/connections.schema.json +++ b/schema/connections.schema.json @@ -210,11 +210,11 @@ "maxLength": 300 }, "single_session": { - "description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused. The same as parallel: 1.", + "description": "The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused (name a model on another server instead). The same as parallel: 1.", "type": "boolean" }, "parallel": { - "description": "How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). A team is offered only on a model that serves at least 2.", + "description": "How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). The session's model is its own subagents' model only when it serves at least 2.", "type": "integer", "exclusiveMinimum": 0, "maximum": 9007199254740991 diff --git a/src/app.ts b/src/app.ts index f3ca5cb..f58a4fc 100644 --- a/src/app.ts +++ b/src/app.ts @@ -16,6 +16,7 @@ import type { Mode } from "./config/schema.ts" import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts" import { hardlineRules } from "./permission/hardline.ts" import { clientFor, familyOf, resolveModel } from "./provider/index.ts" +import { subagentModel, type ModelInfo } from "./team/subagents.ts" import { assembleSystem, instructionFiles, knowledgeScope, modeText } from "./prompt/assemble.ts" import type { Changing } from "./session/prefix.ts" import { describeRepo, repoState } from "./git/repo.ts" @@ -202,6 +203,11 @@ function openInBrowser(url: string): boolean { } } +/** A model as the subagent rules see it (subagents.md): its names and what its server can do at once. */ +function modelInfo(m: ResolvedModel): ModelInfo { + return { ref: m.ref, id: m.id, name: m.spec.name, capacity: { parallel: m.spec.parallel, single_session: m.spec.single_session, group: m.spec.group } } +} + /** Tools the spec offers to subagents only (`offered: subagent`): never a session's own model's. */ export const SUBAGENT_ONLY = new Set(Object.values(TOOL_SPECS).filter((t) => t.offered === "subagent").map((t) => t.name)) @@ -473,13 +479,22 @@ export function createApp(o: AppOptions): App { // its server can take it beside the session's and beside every subagent already running // (src/session/capacity.ts), before anything is made. const member = opts.member - const m = opts.model ? resolveModel(loaded, opts.model) : spec.model ? resolveModel(loaded, spec.model) : engine.model + // Spec 3.3.0 (subagents.md): the session's model, unless the person named another; an agent + // file's own `model` is not used. A member's model was judged by team_add. + let m: ResolvedModel + if (member) m = opts.model ? resolveModel(loaded, opts.model) : engine.model + else { + const wanted = opts.model ? resolveModel(loaded, opts.model) : undefined + const r = subagentModel(modelInfo(engine.model), wanted && modelInfo(wanted), engine.personSaid()) + if ("refused" in r) throw new ToolError(r.refused) + m = wanted && wanted.ref === r.model ? wanted : engine.model + } // A team's member is not refused for capacity: the team gave it its slot (team.md §8). if (!member) { const running = registry.running(jobsSessionId()).filter((j) => j.kind === "subagent" && j.subagent && !j.team) const onIt = running.filter((j) => j.subagent!.engine.model.ref === m.ref).length const refused = capacityRefusal(engine.model, m, onIt) || running.map((j) => capacityBeside(j.subagent!.engine.model, m, onIt)).find(Boolean) - if (refused) throw new ToolError(`${refused} Do this part yourself, or hand it to a model on another connection.`) + if (refused) throw new ToolError(`${refused} Wait for one to finish, or do this part yourself.`) } // A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation). let wt: Worktree | undefined @@ -509,7 +524,7 @@ export function createApp(o: AppOptions): App { : perm // `report` is every subagent's: its way of speaking to the main agent before its final reply. // A team member also has team_send and team_read, and one that may lead team_add and team_stop - // (team.md §4); a task helper has none of them. + // (team.md §4); a task subagent has none of them. const memberTools = member ? (member.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"]) : [] const childTools = [ ...tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && (!(TEAM_TOOLS as readonly string[]).includes(t.name) || memberTools.includes(t.name)) && (!spec.tools || spec.tools.includes(t.name) || memberTools.includes(t.name))), @@ -597,12 +612,12 @@ export function createApp(o: AppOptions): App { const end = registry.finishSubagent(job, { reason, report: text, ...(error ? { error } : {}), ...(worktree ? { worktree } : {}) }) return { text: worktree ? `${text}\n\n[worktree] ${worktree}` : text, steps: job.steps ?? 0, tools: job.tools ?? 0, reason, ...(error ? { error } : {}), ...(end.stopped ? { stopped: end.stopped } : {}) } } - // A task helper started inside a team counts toward its size and holds a slot while it runs + // A task subagent started inside a team counts toward its size and holds a slot while it runs // (team.md §3, §8). const counted = (p: Promise) => { if (member || !teamRt.team.offered) return p - const id = teamRt.team.helperStart(TEAM.names.lead, m.ref) - return p.finally(() => teamRt.team.helperDone(id)) + const id = teamRt.team.taskStart(TEAM.names.lead, m.ref) + return p.finally(() => teamRt.team.taskDone(id)) } const run = (text: string, machine = false) => counted(child.prompt(text, [], text, machine ? { machine: true } : {}).then(finish, (e) => (job.lastError = (e as Error).message, finish("error")))) const handle: SubagentHandle = { @@ -683,6 +698,8 @@ export function createApp(o: AppOptions): App { return {} } } + // Every model the person may use: the server's, as the login read them (subagents.md §4). + const usableModels = () => loaded.refs.map((r) => r.ref) // The size as set while the process runs (any scope): a new or resumed session keeps it. const teamSize: { min?: number; max?: number } = {} const newTeam = () => @@ -693,6 +710,7 @@ export function createApp(o: AppOptions): App { subagents: loaded.config.subagents !== false, model: engine.model.ref, capacity: capacityOf, + models: usableModels, leadState: "idle", readingBash: (command) => readingBashByDefault(command, project.root), }) @@ -711,7 +729,10 @@ export function createApp(o: AppOptions): App { resolve: (agent, model) => { if (!agentList.some((a) => a.name === agent)) return { error: `There is no agent "${agent}". The agents are: ${agentList.map((a) => a.name).join(", ")}.` } try { - return { model: model ? resolveModel(loaded, model).ref : engine.model.ref } + // Spec 3.3.0 (subagents.md): the session's model, unless the person named another. + const wanted = model ? resolveModel(loaded, model) : undefined + const r = subagentModel(modelInfo(engine.model), wanted && modelInfo(wanted), engine.personSaid()) + return "refused" in r ? { error: r.refused } : { model: r.model } } catch (e) { return { error: (e as Error).message } } diff --git a/src/config/schema.ts b/src/config/schema.ts index b2648a4..4118320 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -60,8 +60,8 @@ export const ModelSpec = z.strictObject({ headers: z.record(z.string(), z.string()).optional().describe("Extra HTTP headers for this model's requests."), body: z.record(z.string(), z.unknown()).optional().describe("Merged into every request body for this model."), notes: z.string().max(300).optional().describe("What this model is good at, in a line: the model in use is told which others there are, for handing work over (task) or switching."), - single_session: z.boolean().optional().describe("The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused. The same as parallel: 1."), - parallel: z.number().int().positive().optional().describe("How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). A team is offered only on a model that serves at least 2."), + single_session: z.boolean().optional().describe("The server serves one request at a time: a subagent on this same model would take its only slot and push the session's cached prompt out, so it is refused (name a model on another server instead). The same as parallel: 1."), + parallel: z.number().int().positive().optional().describe("How many requests the model's server serves at once (capacity.parallel); wins over single_session. Absent with no single_session: no limit (a hosted API). The session's model is its own subagents' model only when it serves at least 2."), group: z.string().optional().describe("Models sharing one GPU server that holds one model at a time (llama-swap), as the LLeMbas server says (capacity.group): a subagent on another model of the same group would unload this one, so it is refused."), }) export type ModelSpec = z.infer @@ -298,12 +298,12 @@ export const Config = z.strictObject({ effort: z.union([z.enum(EFFORTS), z.literal("off"), z.literal("default")]).optional().describe("The effort level a session starts with, when the model offers it: an effort, off (thinking switched off, on a model with the switch) or default; otherwise the model's own start level."), agents: z .strictObject({ - min: z.number().int().min(1).optional().describe("The fewest agents a team works with, the lead included (default 1). 2 or more: the lead works through a team from the start."), - max: z.number().int().min(1).optional().describe("The most agents a team may have, the lead included (default 4). 1: one agent, no team and no helper."), + min: z.number().int().min(1).optional().describe("Subagents, fewest: the fewest agents at work, the lead included (default 1). 2 or more: the lead works through subagents from the start."), + max: z.number().int().min(1).optional().describe("Subagents, most: the most agents at work, the lead included (default 4). 1: one agent, no subagents."), cap: z.number().int().min(1).optional().describe("Global config only. The highest max a session may set (default 32)."), }) .optional() - .describe("Teams (harness team.md): how many agents a session's team may have. Offered only where subagents are on and the model serves two requests at once. /agents changes it for the session."), + .describe("Subagents (harness team.md, subagents.md): how many agents a session may have at work. Offered where subagents are on and a model could serve one: the session's, when it serves two requests at once, or another model off its one-model server. /team size and /effort change it for the session."), subagents: z.boolean().optional().describe("false: no subagents — no task tool and no team."), settings_tool: z.enum(["ask", "allow", "off"]).optional().describe("Global config only. The agent's settings tool: ask (default) — each change is approved like any tool call; allow — changes go through without asking (a less strict mode still asks); off — no settings tool."), update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."), diff --git a/src/harness.ts b/src/harness.ts index 1f62a58..170f521 100644 --- a/src/harness.ts +++ b/src/harness.ts @@ -118,6 +118,13 @@ export const LOOP: { running_max: { default: number } subagent_steps: { default: number } } + /** Subagents (spec 3.3.0, harness/subagents.md): the model refusals, what names a model, and how + * the size setting is shown. */ + subagents: { + texts: { not_named: string; same_group: string; one_request: string } + named: { before: string; after: string } + setting: { label: string; min: string; max: string; own: string } + } /** Teams (spec 3.2.0, harness/team.md): the size setting, names, marks, texts. */ team: { agents: { min: number; max: number; cap: number } @@ -165,6 +172,8 @@ export const LOOP: { team_lead: string team_one: string team_stopped: string + /** Spec 3.3.0 (subagents.md §4): the session's model cannot serve subagents itself. */ + team_own: string } /** How long a session waits for its configured MCP servers before its first request. */ mcp_wait_ms: number diff --git a/src/project/agents.ts b/src/project/agents.ts index 245a5bf..f209900 100644 --- a/src/project/agents.ts +++ b/src/project/agents.ts @@ -13,6 +13,7 @@ export interface AgentSpec { name: string description: string instructions: string + /** Read, and not used for a subagent (spec 3.3.0, subagents.md §2): shown so a file that sets it says so. */ model?: string /** A fixed permission mode; absent = the parent's. */ mode?: Mode diff --git a/src/session/capacity.ts b/src/session/capacity.ts index ad6935d..c2e385e 100644 --- a/src/session/capacity.ts +++ b/src/session/capacity.ts @@ -20,21 +20,21 @@ export function parallelOf(m: Pick): number { return m.spec.single_session ? 1 : Infinity } -/** `busy`: this session's requests the helper's model already serves besides the main prompt's +/** `busy`: this session's requests the sub's model already serves besides the main prompt's * (subagents running on it). */ -export function capacityRefusal(main: ResolvedModel, helper: ResolvedModel, busy = 0): string { - if (helper.ref === main.ref) { - const n = parallelOf(helper) +export function capacityRefusal(main: ResolvedModel, sub: ResolvedModel, busy = 0): string { + if (sub.ref === main.ref) { + const n = parallelOf(sub) if (n === 1) - return `${helper.ref} serves one request at a time, so it cannot be its own subagent: the subagent would take the server's only slot and push this session's cached prompt out of it.` + return `${sub.ref} serves one request at a time, so it cannot be its own subagent: the subagent would take the server's only slot and push this session's cached prompt out of it.` if (1 + busy >= n) - return `${helper.ref} serves ${n} requests at once, and this session already has ${n} on it; another subagent would wait, or push this session's cached prompt out.` + return `${sub.ref} serves ${n} requests at once, and this session already has ${n} on it; another subagent would wait, or push this session's cached prompt out.` return "" } const shared = groupOf(main) - if (shared !== undefined && shared === groupOf(helper)) { + if (shared !== undefined && shared === groupOf(sub)) { const where = `${main.ref}'s server (group ${main.spec.group})` - return `${where} holds one model at a time, so a subagent on ${helper.ref} would unload ${main.ref}, and this session's cached prompt with it, while its turn waits.` + return `${where} holds one model at a time, so a subagent on ${sub.ref} would unload ${main.ref}, and this session's cached prompt with it, while its turn waits.` } return "" } @@ -42,17 +42,17 @@ export function capacityRefusal(main: ResolvedModel, helper: ResolvedModel, busy /** The same rule between two subagents: a new one is not started where its model would unload, or * take the only slot of, a subagent already running (the spec's "beside the model of a subagent * already running"). */ -export function capacityBeside(running: ResolvedModel, helper: ResolvedModel, busy = 1): string { - if (helper.ref === running.ref) { - const n = parallelOf(helper) - if (n === 1) return `${helper.ref} serves one request at a time, and a subagent is already running on it; a second would wait for the first, or push it out.` - if (busy >= n) return `${helper.ref} serves ${n} requests at once, and as many subagents already run on it; another would wait, or push one out.` +export function capacityBeside(running: ResolvedModel, sub: ResolvedModel, busy = 1): string { + if (sub.ref === running.ref) { + const n = parallelOf(sub) + if (n === 1) return `${sub.ref} serves one request at a time, and a subagent is already running on it; a second would wait for the first, or push it out.` + if (busy >= n) return `${sub.ref} serves ${n} requests at once, and as many subagents already run on it; another would wait, or push one out.` return "" } const shared = groupOf(running) - if (shared !== undefined && shared === groupOf(helper)) { + if (shared !== undefined && shared === groupOf(sub)) { const where = `${running.ref}'s server (group ${running.spec.group})` - return `${where} holds one model at a time, and a subagent is already running on ${running.ref} there; one on ${helper.ref} would unload it.` + return `${where} holds one model at a time, and a subagent is already running on ${running.ref} there; one on ${sub.ref} would unload it.` } return "" } diff --git a/src/session/commands.ts b/src/session/commands.ts index 9728dec..c7ee9f5 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -17,6 +17,7 @@ import { fill, promptText } from "../prompt/assemble.ts" import { skillMessage, slug, type Skill } from "../skill/index.ts" import { CONTINUE_PROMPT } from "./engine.ts" import { TEAM, Team } from "../team/team.ts" +import { SUBAGENTS } from "../team/subagents.ts" export type CommandKind = "custom" | "skill" | "builtin" @@ -48,7 +49,7 @@ export const SHARED_BUILTINS: { name: string; description: string; hint?: string { name: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" }, { name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" }, { name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" }, - { name: "team", description: "the team: its agents and the room; its size; stop a member", hint: "size | stop " }, + { name: "team", description: "subagents: the team and the room; how many; stop one", hint: "size | stop " }, ] /** The status bar's word for a team with members: its size of the most it may have, and how many @@ -56,7 +57,7 @@ export const SHARED_BUILTINS: { name: string; description: string; hint?: string export function teamLabel(team: Team): string { if (!team.hasMembers()) return "" const n = team.active() - return `team ${team.size()}/${team.max}${n ? ` · ${n} working` : ""}` + return `agents ${team.size()}/${team.max}${n ? ` · ${n} subagent${n === 1 ? "" : "s"} working` : ""}` } /** `/team [size | stop ]`: the team's tree and room, as the lead sees them (the @@ -68,7 +69,7 @@ export function teamText(app: App, arg: string): string { const [verb, ...rest] = arg.trim().split(/\s+/).filter(Boolean) if (verb === "size") { const [min, max] = rest.map(Number) - if (min === undefined || max === undefined || Number.isNaN(min) || Number.isNaN(max)) return `/team size — now ${t.min} to ${t.max} (at most ${t.cap})` + if (min === undefined || max === undefined || Number.isNaN(min) || Number.isNaN(max)) return `/team size — now ${t.min} to ${t.max} (at most ${t.cap})` // Checked whole before anything is set: a refused pair changes nothing (team.md §2). const no = Team.check(min, max, t.cap) if (no) return no @@ -79,7 +80,7 @@ export function teamText(app: App, arg: string): string { } catch (e) { return (e as Error).message } - return `The team may have ${t.min} to ${t.max} agents for this session; the lead is told with your next message.` + return `Subagents: ${t.min} to ${t.max} agents at work for this session, the lead included; it is told with your next message.` } if (verb === "stop") { const name = rest[0] @@ -88,9 +89,12 @@ export function teamText(app: App, arg: string): string { return gone.length ? `Stopped ${gone.join(", ")}; the lead is told with your next message.` : `No member called ${name} on the team.` } if (!t.offered) - return t.subagents ? `No team in this session: ${app.engine.model.ref} serves one request at a time, and a team needs two at once.` : "No team in this session: subagents are switched off (subagents: false)." + return t.subagents + ? `No subagents in this session: ${app.engine.model.ref} serves one request at a time, and every other model is on its one-model server.` + : "No subagents in this session: they are switched off (subagents: false)." const since = Math.max(0, t.room.length - TEAM.read_max) - return `${t.read("lead", since)}\n\nSize ${t.min} to ${t.max} agents (/team size ); /team stop stops a member.` + const own = t.couldServe().own ? "" : `\n${SUBAGENTS.setting.own}` + return `${t.read("lead", since)}\n\nSubagents: ${SUBAGENTS.setting.min} ${t.min}, ${SUBAGENTS.setting.max} ${t.max} agents, the lead included (/team size ); /team stop stops a member.${own}` } /** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and @@ -100,7 +104,7 @@ export function agentsText(app: App, arg: string): string { const describe = (a: (typeof list)[number]) => [ `${a.name} (${a.source}) — ${a.description}`, - ` model ${a.model ?? "the session's"} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`, + ` model the session's, or one you name${a.model ? ` (its own model: ${a.model} is not used)` : ""} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`, ].join("\n") const name = arg.trim().split(/\s+/)[0] if (name) { diff --git a/src/session/context.ts b/src/session/context.ts index 8e0dd6c..0f6c6e6 100644 --- a/src/session/context.ts +++ b/src/session/context.ts @@ -41,23 +41,49 @@ export function contextBreakdown(engine: Engine, system: string, instructions: { if (imageCount) rows.push({ label: `images (${imageCount}, rough)`, tokens: by.images }) const estimated = rows.filter((r) => !r.indent).reduce((n, r) => n + r.tokens, 0) const reported = engine.usage ? engine.contextUsed() : undefined - return { rows, estimated, reported, window: contextWindow(engine.model), model: engine.model.ref, last: engine.lastRequest } + // Where automatic compaction starts (EngineOptions compaction.autoAt): the reference's "N until + // auto-compact". None without a window, or when it is switched off (1 or more). + const autoAt = engine.o.compaction?.autoAt ?? 0.85 + return { rows, estimated, reported, window: contextWindow(engine.model), autoAt: autoAt < 1 ? autoAt : undefined, model: engine.model.ref, last: engine.lastRequest } } -/** Plain text, for the transcript. */ +/** One glyph per category in the segmented bar, in the order the categories are listed. */ +const SEGMENTS = ["█", "▓", "▒", "░", "▞", "▚", "▙", "▟"] + +/** Plain text, for the transcript: what is used of the window and how much, one segmented bar of + * every category, each category with its tokens and its share of the window, and how far it is to + * automatic compaction (/compact compacts now). */ export function renderBreakdown(b: ReturnType): string { - const width = 24 - const total = b.window ?? Math.max(b.estimated, b.reported ?? 0) - const bar = (n: number) => { - const cells = total ? Math.max(n > 0 ? 1 : 0, Math.round((n / total) * width)) : 0 - return "█".repeat(Math.min(width, cells)).padEnd(width, "·") - } - const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)) - const lines = [`context — ${b.model}${b.window ? `, window ${fmt(b.window)}` : ", window unknown"}`] - for (const r of b.rows) lines.push(`${r.indent ? " ↳ " : " "}${r.label.padEnd(r.indent ? 30 : 33)} ${fmt(r.tokens).padStart(7)} ${r.indent ? "" : bar(r.tokens)}`) + const width = 40 const used = b.reported ?? b.estimated - lines.push(` ${"in use".padEnd(33)} ${fmt(used).padStart(7)} ${bar(used)}${b.window ? ` ${Math.round((used / b.window) * 100)}%` : ""}`) - lines.push(b.reported !== undefined ? " (the server's own count from the last reply, plus an estimate since)" : " (estimated: ≈4 characters a token — no reply yet to count from)") + const total = b.window ?? Math.max(b.estimated, used) + const fmt = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)) + const pct = (n: number) => (b.window ? `${Math.round((n / b.window) * 100)}%` : "") + const cats = b.rows.filter((r) => !r.indent) + // The categories' shares of the bar, scaled to what is in use (the server's count when it gave one). + const scale = b.estimated ? used / b.estimated : 1 + let bar = "" + cats.forEach((r, i) => { + const cells = total && r.tokens > 0 ? Math.max(1, Math.round(((r.tokens * scale) / total) * width)) : 0 + bar += SEGMENTS[i % SEGMENTS.length]!.repeat(cells) + }) + bar = bar.slice(0, width).padEnd(width, "·") + const lines = [ + `context — ${b.model}: ${fmt(used)}${b.window ? ` / ${fmt(b.window)} tokens · ${pct(used)} used` : " tokens, window unknown"}`, + ` ${bar}`, + ] + let i = 0 + for (const r of b.rows) { + const mark = r.indent ? " ↳" : ` ${SEGMENTS[i++ % SEGMENTS.length]} ` + lines.push(`${mark} ${r.label.padEnd(r.indent ? 30 : 31)} ${fmt(r.tokens).padStart(7)} ${r.indent ? "" : pct(r.tokens * scale).padStart(4)}`.trimEnd()) + } + if (b.window) lines.push(` · ${"free".padEnd(31)} ${fmt(Math.max(0, b.window - used)).padStart(7)} ${pct(Math.max(0, b.window - used)).padStart(4)}`) + lines.push(` ${"in use".padEnd(33)} ${fmt(used).padStart(7)} ${pct(used).padStart(4)}`.trimEnd()) + if (b.window && b.autoAt !== undefined) { + const until = Math.round(b.window * b.autoAt) - used + lines.push(until > 0 ? ` ${fmt(until)} until auto-compact (at ${Math.round(b.autoAt * 100)}%) — /compact to compact now` : ` past auto-compact (${Math.round(b.autoAt * 100)}%): the next step compacts — /compact to compact now`) + } else lines.push(b.window ? " automatic compaction is off — /compact to compact now" : " no automatic compaction without a window — /compact to compact now") + lines.push(b.reported !== undefined ? " (the server's own count from the last reply, plus an estimate since; the categories are estimates scaled to it)" : " (estimated: ≈4 characters a token — no reply yet to count from)") // What the server reused of the last request (harness context.md §10 rule 6): a slow reply with // little of it cached was a re-read. const last = b.last?.usage diff --git a/src/session/engine.ts b/src/session/engine.ts index 502f2e2..a1f9097 100644 --- a/src/session/engine.ts +++ b/src/session/engine.ts @@ -22,6 +22,7 @@ import { estimateTokens, estimateValue } from "../provider/tokens.ts" import { duration } from "../duration.ts" import { memoryResults, presentIds, ResultContext } from "./results.ts" import { render } from "../plan/plan.ts" +import { personTexts } from "../team/subagents.ts" import { compactAsk, isNote, NO_CHANGING, noteFor, offeredFor, PREFIX_META, summaryOf, today, toldAt, unavailable, type Changing, type Kept, type Now, type Requirement, type Told } from "./prefix.ts" export interface EngineOptions { @@ -611,6 +612,13 @@ export class Engine { if (this.o.store && this.sessionId) this.lastRowId = this.o.store.append(this.sessionId, m) } + /** What the person typed in this session, oldest first (subagents.md §2) — compacted messages + * included, as the store keeps them; never a note, a marked message or a machine turn. */ + personSaid(): string[] { + const all = this.o.store && this.sessionId ? this.o.store.messages(this.sessionId) : this.messages + return personTexts(all, this.o.steerMark ?? STEER_MARK) + } + /** `extra`: attachments (@files, images) and the like — sent to the model after the text, never shown as typed. * `meta`: the turn's id and what was attached, said on the `prompt` event. */ async prompt(text: string, extra: (string | ImagePart)[] = [], shown = text, meta: PromptMeta = {}): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> { diff --git a/src/team/subagents.ts b/src/team/subagents.ts new file mode 100644 index 0000000..88dd7b2 --- /dev/null +++ b/src/team/subagents.ts @@ -0,0 +1,101 @@ +// Subagents (harness spec 3.3.0, harness/subagents.md): which model a subagent — a task subagent or +// a team's member — runs on, and whether subagents are offered at all. The rules alone, without an +// engine: the conformance area `subagents` drives them, and the app (spawn, team_add) asks them. +import { LOOP } from "../harness.ts" +import type { Message } from "../provider/types.ts" +import { parallelFrom, type Capacity } from "./team.ts" + +export const SUBAGENTS = LOOP.subagents +const TEXTS = SUBAGENTS.texts + +/** A model the person may use: its served id (the ref), its id at its connection, its display name. */ +export interface ModelInfo { + ref: string + id?: string + name?: string + capacity: Capacity +} + +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") +const BEFORE = new RegExp(`${SUBAGENTS.named.before}$`, "u") +const AFTER = new RegExp(`^(?:${SUBAGENTS.named.after})`, "u") + +/** The names a model goes by: the served id, the part after its provider, the display name. */ +export function namesOf(m: Pick): string[] { + const out = [m.ref] + if (m.id) out.push(m.id) + const slash = m.ref.lastIndexOf("/") + if (slash >= 0) out.push(m.ref.slice(slash + 1)) + if (m.name?.trim()) out.push(m.name.trim()) + return [...new Set(out.filter(Boolean))] +} + +/** Whether `name` occurs in `text` as a whole word, without case (subagents.md §2). White space in + * the name matches any run of white space. */ +export function occursAsWord(text: string, name: string): boolean { + const pattern = name + .trim() + .split(/\s+/) + .map(escape) + .join("\\s+") + if (!pattern) return false + const re = new RegExp(pattern, "giu") + for (let m = re.exec(text); m; m = re.exec(text)) { + const before = text.slice(0, m.index) + const after = text.slice(m.index + m[0].length) + if (!BEFORE.test(before) && !AFTER.test(after)) return true + re.lastIndex = m.index + 1 + } + return false +} + +/** Whether the person named the model in any of the messages they typed. */ +export function named(m: Pick, said: readonly string[]): boolean { + const names = namesOf(m) + return said.some((t) => names.some((n) => occursAsWord(t, n))) +} + +/** What the person typed in a session, oldest first: their prompts and what they sent mid-turn + * (after `steerMark`) — never a note, a job's or a team's marked message, or a machine turn. */ +export function personTexts(messages: readonly Message[], steerMark: string): string[] { + const out: string[] = [] + for (const m of messages) { + if (m.role !== "user" || m.machine) continue + const first = m.parts[0] + if (!first || first.type !== "text") continue + if (m.turn === false) { + if (first.text.startsWith(steerMark)) out.push(first.text.slice(steerMark.length).trim()) + continue + } + out.push(first.text) + } + return out +} + +/** Whether a model can be its own session's subagent: it serves two requests at once, or has no limit. */ +export function servesOwn(c: Capacity): boolean { + return parallelFrom(c) >= 2 +} + +/** subagents.md §4: whether subagents are offered for a session on `session`, given the models the + * person may use, and whether the session's model can serve its own. */ +export function offer(session: ModelInfo, models: readonly ModelInfo[], subagents = true): { offered: boolean; own: boolean } { + const own = servesOwn(session.capacity) + const group = session.capacity.group + const other = models.some((m) => m.ref !== session.ref && (group === undefined || m.capacity.group !== group)) + return { offered: subagents && (own || other), own } +} + +/** subagents.md §2, §3: the model a subagent runs on — the session's, or `wanted` (already resolved + * to a model the person may use) — or the refusal, word for word. */ +export function subagentModel(session: ModelInfo, wanted: ModelInfo | undefined, said: readonly string[]): { model: string } | { refused: string } { + const m = wanted ?? session + if (m.ref !== session.ref) { + if (!named(m, said)) return { refused: TEXTS.not_named } + const group = session.capacity.group + if (group !== undefined && m.capacity.group === group) return { refused: TEXTS.same_group } + return { model: m.ref } + } + if (!servesOwn(session.capacity)) return { refused: TEXTS.one_request } + return { model: session.ref } +} diff --git a/src/team/team.ts b/src/team/team.ts index 04b70bd..8ca90a1 100644 --- a/src/team/team.ts +++ b/src/team/team.ts @@ -86,6 +86,9 @@ export interface TeamOptions { /** The lead's model (the chat's), and every model's capacity. */ model: string capacity: (model: string) => Capacity + /** Spec 3.3.0 (subagents.md §4): every model the person may use, by ref — a model off the lead's + * one-model server can serve as a subagent where the lead's model cannot. None: only the lead's. */ + models?: () => readonly string[] effects?: TeamEffects /** A bash command a manager may still run: one the default rules alone allow in manual mode. */ readingBash?: (command: string) => boolean @@ -111,10 +114,10 @@ export class Team { model: string private agents = new Map() room: RoomMessage[] = [] - /** `task` helpers running in the team: who started each, and on which model. */ - private helpers: { id: number; by: string; model: string }[] = [] + /** `task` subagents running in the team: who started each, and on which model. */ + private tasks: { id: number; by: string; model: string }[] = [] private seq = 0 - private helperSeq = 0 + private taskSeq = 0 /** Whether the team is offered, fixed as the prompt is made (§2). */ offered = false /** The setting as the lead was last told it. */ @@ -165,13 +168,22 @@ export class Team { return parallelFrom(this.o.capacity(this.model)) } - /** §2: offered when subagents are on and the chat's model serves two at once — fixed as the - * prompt is made (and made again on a model switch); `offered` is a kept prompt's decision. */ + /** Spec 3.3.0 (subagents.md §4): whether a model could serve as a subagent now — the lead's own, + * with two slots or more, or another model off its one-model server — and whether it is the own. */ + couldServe(): { any: boolean; own: boolean } { + const own = this.parallelNow() >= 2 + const group = this.o.capacity(this.model).group + const other = (this.o.models?.() ?? []).some((r) => r !== this.model && (group === undefined || this.o.capacity(r).group !== group)) + return { any: own || other, own } + } + + /** §2: offered when subagents are on and a model could serve as a subagent — fixed as the prompt + * is made (and made again on a model switch); `offered` is a kept prompt's decision. */ fix(model = this.model, offered?: boolean): boolean { this.model = model const lead = this.agents.get(LEAD) if (lead) lead.model = model - this.offered = offered ?? (this.subagents && this.parallelNow() >= 2) + this.offered = offered ?? (this.subagents && this.couldServe().any) return this.offered } @@ -207,9 +219,9 @@ export class Team { return this.agents.size > 1 } - /** §3: the lead, every member, and every running `task` helper. */ + /** §3: the lead, every member, and every running `task` subagent. */ size(): number { - return this.agents.size + this.helpers.length + return this.agents.size + this.tasks.length } /** §6: an agent with a member, or the lead from the start when min ≥ 2 and the team is offered. */ @@ -222,8 +234,9 @@ export class Team { /** §2: the team tools' requirement `team`; the text a call answers while it is not met. */ unavailable(): string | undefined { - if ((this.max >= 2 && this.parallelNow() >= 2) || this.hasMembers()) return undefined - return this.parallelNow() < 2 ? TEAM.unavailable.one_request : TEAM.unavailable.one_agent + const could = this.couldServe().any + if ((this.max >= 2 && could) || this.hasMembers()) return undefined + return !could ? TEAM.unavailable.one_request : TEAM.unavailable.one_agent } /** §3: what `task` answers in a team, or undefined when it may run (and then counts). */ @@ -259,14 +272,14 @@ export class Team { // ── slots ─────────────────────────────────────────────────────────────────── private busyOn(model: string): number { - return [...this.agents.values()].filter((a) => a.state === "working" && a.model === model).length + this.helpers.filter((h) => h.model === model).length + return [...this.agents.values()].filter((a) => a.state === "working" && a.model === model).length + this.tasks.filter((h) => h.model === model).length } private free(model: string): boolean { const group = this.o.capacity(model).group if (group !== undefined) { for (const a of this.agents.values()) if (a.state === "working" && a.model !== model && this.o.capacity(a.model).group === group) return false - for (const h of this.helpers) if (h.model !== model && this.o.capacity(h.model).group === group) return false + for (const h of this.tasks) if (h.model !== model && this.o.capacity(h.model).group === group) return false } return this.busyOn(model) < parallelFrom(this.o.capacity(model)) } @@ -299,22 +312,22 @@ export class Team { return lead.state === "working" } - /** A `task` helper started by `by` (it counts and holds a slot until helperDone). */ - helperStart(by: string, model = this.model): number { - const id = ++this.helperSeq - this.helpers.push({ id, by, model }) + /** A `task` subagent started by `by` (it counts and holds a slot until taskDone). */ + taskStart(by: string, model = this.model): number { + const id = ++this.taskSeq + this.tasks.push({ id, by, model }) return id } - helperDone(id: number) { - this.helpers = this.helpers.filter((h) => h.id !== id) + taskDone(id: number) { + this.tasks = this.tasks.filter((h) => h.id !== id) this.fillSlots() } - /** The first helper `by` started, done (the conformance area's `helper_done`). */ - helperDoneBy(by: string) { - const h = this.helpers.find((x) => x.by === by) - if (h) this.helperDone(h.id) + /** The first task subagent `by` started, done (the conformance area's `task_done`). */ + taskDoneBy(by: string) { + const h = this.tasks.find((x) => x.by === by) + if (h) this.taskDone(h.id) } // ── delivery ──────────────────────────────────────────────────────────────── @@ -545,7 +558,7 @@ export class Team { if (!this.told || this.told.min !== this.min || this.told.max !== this.max) { this.told = { min: this.min, max: this.max } if (this.max === 1) out.push(N.team_one) - else out.push([fill(N.team, { min: this.min, max: this.max }), ...(this.min >= 2 ? [fill(N.team_lead, { members: this.min - 1 })] : [])].join("\n")) + else out.push([fill(N.team, { min: this.min, max: this.max }), ...(this.min >= 2 ? [fill(N.team_lead, { members: this.min - 1 })] : []), ...(this.couldServe().own ? [] : [N.team_own])].join("\n")) } if (this.stoppedByPerson.length) out.push(fill(N.team_stopped, { names: this.stoppedByPerson.splice(0).join(", ") })) return out diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 14fc0f3..c19cf7c 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -11,6 +11,7 @@ import { doRelease, planRelease } from "../git/release.ts" import { instructionFiles } from "../prompt/assemble.ts" import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts" import { contextBreakdown, renderBreakdown } from "../session/context.ts" +import { SUBAGENTS } from "../team/subagents.ts" import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/commands.ts" import { git } from "../git/run.ts" import { branches, deleteBranch, switchBranch } from "../git/branch.ts" @@ -85,7 +86,6 @@ const pendingCodes = new Map void>() const AGENT_TEMPLATE = (name: string) => `--- description: what ${name} is for — the model reads this when it picks a subagent -# model: provider/model # run it on another of the server's models (default: the session's) # mode: plan # plan: read-only; edit; manual (asks you) — never looser than yours # tools: [read, glob, grep, list, bash, web_search, web_fetch] # default: every tool a subagent may have # isolation: worktree # work in a git checkout and branch of its own @@ -100,19 +100,22 @@ const TEAM_ROW = "\u0000team" function openTeamSize(c: CommandCtx) { const t = c.app.team.team const pairs = [ - [1, 1, "one agent: no team, no helper"], - [1, 4, "you decide; up to three members"], - [2, 4, "a team from the start"], - [1, 8, "you decide; up to seven members"], - [3, 8, "a team of at least three"], - [1, Math.min(16, t.cap), "a bigger team, with sub-managers"], + [1, 1, "one agent: no subagents"], + [1, 4, "the agent decides; up to three subagents"], + [2, 4, "subagents from the start"], + [1, 8, "the agent decides; up to seven subagents"], + [3, 8, "at least two subagents"], + [1, Math.min(16, t.cap), "many subagents, with sub-managers"], ] as const const seen = new Set() const options = pairs .filter(([min, max]) => max <= t.cap && !seen.has(`${min} ${max}`) && seen.add(`${min} ${max}`)) .map(([min, max, what]) => ({ label: `${min} to ${max}`, value: `${min} ${max}`, description: what, current: t.min === min && t.max === max })) const apply = (v: string) => (c.view.info(teamText(c.app, `size ${v}`)), c.refresh()) - c.openDialog({ title: `Agents — now ${t.min} to ${t.max} (at most ${t.cap})`, options, onSelect: apply, custom: { label: "set to", onSubmit: apply } }) + const S = SUBAGENTS.setting + // The session's model cannot serve subagents itself: said where the size is set (subagents.md §4). + const own = t.couldServe().own ? "" : ` — ${S.own}` + c.openDialog({ title: `${S.label} — now ${S.min} ${t.min}, ${S.max} ${t.max} (agents at work, this one included; at most ${t.cap})${own}`, options, onSelect: apply, custom: { label: "set to ", onSubmit: apply } }) } export const COMMANDS: Command[] = [ @@ -146,7 +149,7 @@ export const COMMANDS: Command[] = [ const describe = (a: (typeof list)[number]) => [ `${a.name} (${a.source}) — ${a.description}`, - ` model ${a.model ?? "the session's"} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`, + ` model the session's, or one you name${a.model ? ` (its own model: ${a.model} is not used)` : ""} · mode ${a.mode ?? "the session's"} · tools ${a.tools?.join(", ") ?? "all a subagent may have"}${a.isolation ? " · own worktree" : ""}`, ].join("\n") if (named) return c.view.info(describe(named)) c.view.info( @@ -215,9 +218,13 @@ export const COMMANDS: Command[] = [ c.setEffort(v as Level) c.view.info(control === "switch" ? `thinking ${v === "off" ? "off" : `on (${v})`}` : `effort: ${v}`) } - // The team's size sits beside the effort (team.md §2: shown only where a team is offered). + // The Subagents setting sits beside the effort (team.md §2, subagents.md §4: shown only where + // subagents are offered). const team = c.app.team.team - const agentsRow = team.offered ? [{ label: `agents: ${team.min} to ${team.max}`, value: TEAM_ROW, description: "how many agents the team may have, you included" }] : [] + const S = SUBAGENTS.setting + const agentsRow = team.offered + ? [{ label: `${S.label}: ${S.min} ${team.min}, ${S.max} ${team.max}`, value: TEAM_ROW, description: team.couldServe().own ? "agents at work, this one included" : S.own }] + : [] const choose = (v: string) => (v === TEAM_ROW ? openTeamSize(c) : set(v)) if (control === "none" && (arg || !agentsRow.length)) return c.view.info(`${m.ref} has one level (${levels[0]}): there is nothing to choose`) if (arg) return set(arg) @@ -241,8 +248,8 @@ export const COMMANDS: Command[] = [ }, { name: "team", - args: "[size [ ] | stop []]", - description: "the team: its agents and the room; its size; stop a member", + args: "[size [ ] | stop []]", + description: "subagents: the team and the room; how many; stop one", run(c, arg) { // The tree and the room as the lead sees them — the person sees everything (team.md §5). const words = arg.trim().split(/\s+/).filter(Boolean) diff --git a/tests/acp-jobs.test.ts b/tests/acp-jobs.test.ts index ca16e2e..b10fec9 100644 --- a/tests/acp-jobs.test.ts +++ b/tests/acp-jobs.test.ts @@ -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" }) diff --git a/tests/capacity.test.ts b/tests/capacity.test.ts index f98afa6..9442564 100644 --- a/tests/capacity.test.ts +++ b/tests/capacity.test.ts @@ -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 => ({ 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 => ({ 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 => ({ 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.") }) diff --git a/tests/conformance.ts b/tests/conformance.ts index d058ce6..b42d4b0 100644 --- a/tests/conformance.ts +++ b/tests/conformance.ts @@ -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 @@ -233,6 +234,26 @@ export const AREAS: Record 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>).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 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 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 diff --git a/tests/context.test.ts b/tests/context.test.ts index f3a23c2..827a8cb 100644 --- a/tests/context.test.ts +++ b/tests/context.test.ts @@ -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 diff --git a/tests/subagent.test.ts b/tests/subagent.test.ts index 9ae6537..3486218 100644 --- a/tests/subagent.test.ts +++ b/tests/subagent.test.ts @@ -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") diff --git a/tests/subagents.test.ts b/tests/subagents.test.ts new file mode 100644 index 0000000..49a79a4 --- /dev/null +++ b/tests/subagents.test.ts @@ -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: "\nToday is …\n" }] }, + { 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 = { "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}`]) +}) diff --git a/tests/team.test.ts b/tests/team.test.ts index 7b7352e..f0d293f 100644 --- a/tests/team.test.ts +++ b/tests/team.test.ts @@ -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.