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
+26
-1
@@ -6,7 +6,32 @@ All notable changes to LLeMbas CLI. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [2.0.0-beta.3] — 2026-11-09
|
||||||
|
|
||||||
|
|||||||
@@ -894,17 +894,17 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"agents": {
|
"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",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"min": {
|
"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",
|
"type": "integer",
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 9007199254740991
|
"maximum": 9007199254740991
|
||||||
},
|
},
|
||||||
"max": {
|
"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",
|
"type": "integer",
|
||||||
"minimum": 1,
|
"minimum": 1,
|
||||||
"maximum": 9007199254740991
|
"maximum": 9007199254740991
|
||||||
|
|||||||
@@ -210,11 +210,11 @@
|
|||||||
"maxLength": 300
|
"maxLength": 300
|
||||||
},
|
},
|
||||||
"single_session": {
|
"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"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"parallel": {
|
"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",
|
"type": "integer",
|
||||||
"exclusiveMinimum": 0,
|
"exclusiveMinimum": 0,
|
||||||
"maximum": 9007199254740991
|
"maximum": 9007199254740991
|
||||||
|
|||||||
+28
-7
@@ -16,6 +16,7 @@ import type { Mode } from "./config/schema.ts"
|
|||||||
import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts"
|
import { DEFAULT_RULES, toRules } from "./permission/evaluate.ts"
|
||||||
import { hardlineRules } from "./permission/hardline.ts"
|
import { hardlineRules } from "./permission/hardline.ts"
|
||||||
import { clientFor, familyOf, resolveModel } from "./provider/index.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 { assembleSystem, instructionFiles, knowledgeScope, modeText } from "./prompt/assemble.ts"
|
||||||
import type { Changing } from "./session/prefix.ts"
|
import type { Changing } from "./session/prefix.ts"
|
||||||
import { describeRepo, repoState } from "./git/repo.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. */
|
/** 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))
|
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
|
// its server can take it beside the session's and beside every subagent already running
|
||||||
// (src/session/capacity.ts), before anything is made.
|
// (src/session/capacity.ts), before anything is made.
|
||||||
const member = opts.member
|
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).
|
// A team's member is not refused for capacity: the team gave it its slot (team.md §8).
|
||||||
if (!member) {
|
if (!member) {
|
||||||
const running = registry.running(jobsSessionId()).filter((j) => j.kind === "subagent" && j.subagent && !j.team)
|
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 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)
|
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).
|
// A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation).
|
||||||
let wt: Worktree | undefined
|
let wt: Worktree | undefined
|
||||||
@@ -509,7 +524,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
: perm
|
: perm
|
||||||
// `report` is every subagent's: its way of speaking to the main agent before its final reply.
|
// `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
|
// 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 memberTools = member ? (member.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"]) : []
|
||||||
const childTools = [
|
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))),
|
...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 } : {}) })
|
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 } : {}) }
|
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).
|
// (team.md §3, §8).
|
||||||
const counted = (p: Promise<SubagentResult>) => {
|
const counted = (p: Promise<SubagentResult>) => {
|
||||||
if (member || !teamRt.team.offered) return p
|
if (member || !teamRt.team.offered) return p
|
||||||
const id = teamRt.team.helperStart(TEAM.names.lead, m.ref)
|
const id = teamRt.team.taskStart(TEAM.names.lead, m.ref)
|
||||||
return p.finally(() => teamRt.team.helperDone(id))
|
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 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 = {
|
const handle: SubagentHandle = {
|
||||||
@@ -683,6 +698,8 @@ export function createApp(o: AppOptions): App {
|
|||||||
return {}
|
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.
|
// The size as set while the process runs (any scope): a new or resumed session keeps it.
|
||||||
const teamSize: { min?: number; max?: number } = {}
|
const teamSize: { min?: number; max?: number } = {}
|
||||||
const newTeam = () =>
|
const newTeam = () =>
|
||||||
@@ -693,6 +710,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
subagents: loaded.config.subagents !== false,
|
subagents: loaded.config.subagents !== false,
|
||||||
model: engine.model.ref,
|
model: engine.model.ref,
|
||||||
capacity: capacityOf,
|
capacity: capacityOf,
|
||||||
|
models: usableModels,
|
||||||
leadState: "idle",
|
leadState: "idle",
|
||||||
readingBash: (command) => readingBashByDefault(command, project.root),
|
readingBash: (command) => readingBashByDefault(command, project.root),
|
||||||
})
|
})
|
||||||
@@ -711,7 +729,10 @@ export function createApp(o: AppOptions): App {
|
|||||||
resolve: (agent, model) => {
|
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(", ")}.` }
|
if (!agentList.some((a) => a.name === agent)) return { error: `There is no agent "${agent}". The agents are: ${agentList.map((a) => a.name).join(", ")}.` }
|
||||||
try {
|
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) {
|
} catch (e) {
|
||||||
return { error: (e as Error).message }
|
return { error: (e as Error).message }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."),
|
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."),
|
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."),
|
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."),
|
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). A team is offered only on a model that serves at least 2."),
|
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."),
|
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<typeof ModelSpec>
|
export type ModelSpec = z.infer<typeof ModelSpec>
|
||||||
@@ -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."),
|
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
|
agents: z
|
||||||
.strictObject({
|
.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."),
|
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("The most agents a team may have, the lead included (default 4). 1: one agent, no team and no helper."),
|
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)."),
|
cap: z.number().int().min(1).optional().describe("Global config only. The highest max a session may set (default 32)."),
|
||||||
})
|
})
|
||||||
.optional()
|
.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."),
|
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."),
|
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."),
|
update: Update.optional().describe("Global config only. Updating LLeMbas CLI itself."),
|
||||||
|
|||||||
@@ -118,6 +118,13 @@ export const LOOP: {
|
|||||||
running_max: { default: number }
|
running_max: { default: number }
|
||||||
subagent_steps: { 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. */
|
/** Teams (spec 3.2.0, harness/team.md): the size setting, names, marks, texts. */
|
||||||
team: {
|
team: {
|
||||||
agents: { min: number; max: number; cap: number }
|
agents: { min: number; max: number; cap: number }
|
||||||
@@ -165,6 +172,8 @@ export const LOOP: {
|
|||||||
team_lead: string
|
team_lead: string
|
||||||
team_one: string
|
team_one: string
|
||||||
team_stopped: 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. */
|
/** How long a session waits for its configured MCP servers before its first request. */
|
||||||
mcp_wait_ms: number
|
mcp_wait_ms: number
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface AgentSpec {
|
|||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
instructions: 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
|
model?: string
|
||||||
/** A fixed permission mode; absent = the parent's. */
|
/** A fixed permission mode; absent = the parent's. */
|
||||||
mode?: Mode
|
mode?: Mode
|
||||||
|
|||||||
+15
-15
@@ -20,21 +20,21 @@ export function parallelOf(m: Pick<ResolvedModel, "spec">): number {
|
|||||||
return m.spec.single_session ? 1 : Infinity
|
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). */
|
* (subagents running on it). */
|
||||||
export function capacityRefusal(main: ResolvedModel, helper: ResolvedModel, busy = 0): string {
|
export function capacityRefusal(main: ResolvedModel, sub: ResolvedModel, busy = 0): string {
|
||||||
if (helper.ref === main.ref) {
|
if (sub.ref === main.ref) {
|
||||||
const n = parallelOf(helper)
|
const n = parallelOf(sub)
|
||||||
if (n === 1)
|
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)
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
const shared = groupOf(main)
|
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})`
|
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 ""
|
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
|
/** 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
|
* take the only slot of, a subagent already running (the spec's "beside the model of a subagent
|
||||||
* already running"). */
|
* already running"). */
|
||||||
export function capacityBeside(running: ResolvedModel, helper: ResolvedModel, busy = 1): string {
|
export function capacityBeside(running: ResolvedModel, sub: ResolvedModel, busy = 1): string {
|
||||||
if (helper.ref === running.ref) {
|
if (sub.ref === running.ref) {
|
||||||
const n = parallelOf(helper)
|
const n = parallelOf(sub)
|
||||||
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 (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 `${helper.ref} serves ${n} requests at once, and as many subagents already run on it; another would wait, or push one 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 ""
|
return ""
|
||||||
}
|
}
|
||||||
const shared = groupOf(running)
|
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})`
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-7
@@ -17,6 +17,7 @@ import { fill, promptText } from "../prompt/assemble.ts"
|
|||||||
import { skillMessage, slug, type Skill } from "../skill/index.ts"
|
import { skillMessage, slug, type Skill } from "../skill/index.ts"
|
||||||
import { CONTINUE_PROMPT } from "./engine.ts"
|
import { CONTINUE_PROMPT } from "./engine.ts"
|
||||||
import { TEAM, Team } from "../team/team.ts"
|
import { TEAM, Team } from "../team/team.ts"
|
||||||
|
import { SUBAGENTS } from "../team/subagents.ts"
|
||||||
|
|
||||||
export type CommandKind = "custom" | "skill" | "builtin"
|
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: "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: "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: "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 <min> <max> | stop <name>" },
|
{ name: "team", description: "subagents: the team and the room; how many; stop one", hint: "size <fewest> <most> | stop <name>" },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** The status bar's word for a team with members: its size of the most it may have, and how many
|
/** 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 {
|
export function teamLabel(team: Team): string {
|
||||||
if (!team.hasMembers()) return ""
|
if (!team.hasMembers()) return ""
|
||||||
const n = team.active()
|
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 <min> <max> | stop <name>]`: the team's tree and room, as the lead sees them (the
|
/** `/team [size <min> <max> | stop <name>]`: 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)
|
const [verb, ...rest] = arg.trim().split(/\s+/).filter(Boolean)
|
||||||
if (verb === "size") {
|
if (verb === "size") {
|
||||||
const [min, max] = rest.map(Number)
|
const [min, max] = rest.map(Number)
|
||||||
if (min === undefined || max === undefined || Number.isNaN(min) || Number.isNaN(max)) return `/team size <min> <max> — now ${t.min} to ${t.max} (at most ${t.cap})`
|
if (min === undefined || max === undefined || Number.isNaN(min) || Number.isNaN(max)) return `/team size <fewest> <most> — now ${t.min} to ${t.max} (at most ${t.cap})`
|
||||||
// Checked whole before anything is set: a refused pair changes nothing (team.md §2).
|
// Checked whole before anything is set: a refused pair changes nothing (team.md §2).
|
||||||
const no = Team.check(min, max, t.cap)
|
const no = Team.check(min, max, t.cap)
|
||||||
if (no) return no
|
if (no) return no
|
||||||
@@ -79,7 +80,7 @@ export function teamText(app: App, arg: string): string {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return (e as Error).message
|
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") {
|
if (verb === "stop") {
|
||||||
const name = rest[0]
|
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.`
|
return gone.length ? `Stopped ${gone.join(", ")}; the lead is told with your next message.` : `No member called ${name} on the team.`
|
||||||
}
|
}
|
||||||
if (!t.offered)
|
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)
|
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 <min> <max>); /team stop <name> 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 <fewest> <most>); /team stop <name> stops a member.${own}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and
|
/** `/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]) =>
|
const describe = (a: (typeof list)[number]) =>
|
||||||
[
|
[
|
||||||
`${a.name} (${a.source}) — ${a.description}`,
|
`${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")
|
].join("\n")
|
||||||
const name = arg.trim().split(/\s+/)[0]
|
const name = arg.trim().split(/\s+/)[0]
|
||||||
if (name) {
|
if (name) {
|
||||||
|
|||||||
+39
-13
@@ -41,23 +41,49 @@ export function contextBreakdown(engine: Engine, system: string, instructions: {
|
|||||||
if (imageCount) rows.push({ label: `images (${imageCount}, rough)`, tokens: by.images })
|
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 estimated = rows.filter((r) => !r.indent).reduce((n, r) => n + r.tokens, 0)
|
||||||
const reported = engine.usage ? engine.contextUsed() : undefined
|
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<typeof contextBreakdown>): string {
|
export function renderBreakdown(b: ReturnType<typeof contextBreakdown>): string {
|
||||||
const width = 24
|
const width = 40
|
||||||
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 used = b.reported ?? b.estimated
|
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)}%` : ""}`)
|
const total = b.window ?? Math.max(b.estimated, used)
|
||||||
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 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
|
// 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.
|
// little of it cached was a re-read.
|
||||||
const last = b.last?.usage
|
const last = b.last?.usage
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { estimateTokens, estimateValue } from "../provider/tokens.ts"
|
|||||||
import { duration } from "../duration.ts"
|
import { duration } from "../duration.ts"
|
||||||
import { memoryResults, presentIds, ResultContext } from "./results.ts"
|
import { memoryResults, presentIds, ResultContext } from "./results.ts"
|
||||||
import { render } from "../plan/plan.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"
|
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 {
|
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)
|
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.
|
/** `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. */
|
* `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"> {
|
async prompt(text: string, extra: (string | ImagePart)[] = [], shown = text, meta: PromptMeta = {}): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> {
|
||||||
|
|||||||
@@ -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<ModelInfo, "ref" | "id" | "name">): 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<ModelInfo, "ref" | "id" | "name">, 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 }
|
||||||
|
}
|
||||||
+36
-23
@@ -86,6 +86,9 @@ export interface TeamOptions {
|
|||||||
/** The lead's model (the chat's), and every model's capacity. */
|
/** The lead's model (the chat's), and every model's capacity. */
|
||||||
model: string
|
model: string
|
||||||
capacity: (model: string) => Capacity
|
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
|
effects?: TeamEffects
|
||||||
/** A bash command a manager may still run: one the default rules alone allow in manual mode. */
|
/** A bash command a manager may still run: one the default rules alone allow in manual mode. */
|
||||||
readingBash?: (command: string) => boolean
|
readingBash?: (command: string) => boolean
|
||||||
@@ -111,10 +114,10 @@ export class Team {
|
|||||||
model: string
|
model: string
|
||||||
private agents = new Map<string, TeamAgent>()
|
private agents = new Map<string, TeamAgent>()
|
||||||
room: RoomMessage[] = []
|
room: RoomMessage[] = []
|
||||||
/** `task` helpers running in the team: who started each, and on which model. */
|
/** `task` subagents running in the team: who started each, and on which model. */
|
||||||
private helpers: { id: number; by: string; model: string }[] = []
|
private tasks: { id: number; by: string; model: string }[] = []
|
||||||
private seq = 0
|
private seq = 0
|
||||||
private helperSeq = 0
|
private taskSeq = 0
|
||||||
/** Whether the team is offered, fixed as the prompt is made (§2). */
|
/** Whether the team is offered, fixed as the prompt is made (§2). */
|
||||||
offered = false
|
offered = false
|
||||||
/** The setting as the lead was last told it. */
|
/** The setting as the lead was last told it. */
|
||||||
@@ -165,13 +168,22 @@ export class Team {
|
|||||||
return parallelFrom(this.o.capacity(this.model))
|
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
|
/** Spec 3.3.0 (subagents.md §4): whether a model could serve as a subagent now — the lead's own,
|
||||||
* prompt is made (and made again on a model switch); `offered` is a kept prompt's decision. */
|
* 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 {
|
fix(model = this.model, offered?: boolean): boolean {
|
||||||
this.model = model
|
this.model = model
|
||||||
const lead = this.agents.get(LEAD)
|
const lead = this.agents.get(LEAD)
|
||||||
if (lead) lead.model = model
|
if (lead) lead.model = model
|
||||||
this.offered = offered ?? (this.subagents && this.parallelNow() >= 2)
|
this.offered = offered ?? (this.subagents && this.couldServe().any)
|
||||||
return this.offered
|
return this.offered
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,9 +219,9 @@ export class Team {
|
|||||||
return this.agents.size > 1
|
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 {
|
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. */
|
/** §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. */
|
/** §2: the team tools' requirement `team`; the text a call answers while it is not met. */
|
||||||
unavailable(): string | undefined {
|
unavailable(): string | undefined {
|
||||||
if ((this.max >= 2 && this.parallelNow() >= 2) || this.hasMembers()) return undefined
|
const could = this.couldServe().any
|
||||||
return this.parallelNow() < 2 ? TEAM.unavailable.one_request : TEAM.unavailable.one_agent
|
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). */
|
/** §3: what `task` answers in a team, or undefined when it may run (and then counts). */
|
||||||
@@ -259,14 +272,14 @@ export class Team {
|
|||||||
// ── slots ───────────────────────────────────────────────────────────────────
|
// ── slots ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private busyOn(model: string): number {
|
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 {
|
private free(model: string): boolean {
|
||||||
const group = this.o.capacity(model).group
|
const group = this.o.capacity(model).group
|
||||||
if (group !== undefined) {
|
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 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))
|
return this.busyOn(model) < parallelFrom(this.o.capacity(model))
|
||||||
}
|
}
|
||||||
@@ -299,22 +312,22 @@ export class Team {
|
|||||||
return lead.state === "working"
|
return lead.state === "working"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A `task` helper started by `by` (it counts and holds a slot until helperDone). */
|
/** A `task` subagent started by `by` (it counts and holds a slot until taskDone). */
|
||||||
helperStart(by: string, model = this.model): number {
|
taskStart(by: string, model = this.model): number {
|
||||||
const id = ++this.helperSeq
|
const id = ++this.taskSeq
|
||||||
this.helpers.push({ id, by, model })
|
this.tasks.push({ id, by, model })
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
helperDone(id: number) {
|
taskDone(id: number) {
|
||||||
this.helpers = this.helpers.filter((h) => h.id !== id)
|
this.tasks = this.tasks.filter((h) => h.id !== id)
|
||||||
this.fillSlots()
|
this.fillSlots()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The first helper `by` started, done (the conformance area's `helper_done`). */
|
/** The first task subagent `by` started, done (the conformance area's `task_done`). */
|
||||||
helperDoneBy(by: string) {
|
taskDoneBy(by: string) {
|
||||||
const h = this.helpers.find((x) => x.by === by)
|
const h = this.tasks.find((x) => x.by === by)
|
||||||
if (h) this.helperDone(h.id)
|
if (h) this.taskDone(h.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── delivery ────────────────────────────────────────────────────────────────
|
// ── delivery ────────────────────────────────────────────────────────────────
|
||||||
@@ -545,7 +558,7 @@ export class Team {
|
|||||||
if (!this.told || this.told.min !== this.min || this.told.max !== this.max) {
|
if (!this.told || this.told.min !== this.min || this.told.max !== this.max) {
|
||||||
this.told = { min: this.min, max: this.max }
|
this.told = { min: this.min, max: this.max }
|
||||||
if (this.max === 1) out.push(N.team_one)
|
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(", ") }))
|
if (this.stoppedByPerson.length) out.push(fill(N.team_stopped, { names: this.stoppedByPerson.splice(0).join(", ") }))
|
||||||
return out
|
return out
|
||||||
|
|||||||
+20
-13
@@ -11,6 +11,7 @@ import { doRelease, planRelease } from "../git/release.ts"
|
|||||||
import { instructionFiles } from "../prompt/assemble.ts"
|
import { instructionFiles } from "../prompt/assemble.ts"
|
||||||
import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts"
|
import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts"
|
||||||
import { contextBreakdown, renderBreakdown } from "../session/context.ts"
|
import { contextBreakdown, renderBreakdown } from "../session/context.ts"
|
||||||
|
import { SUBAGENTS } from "../team/subagents.ts"
|
||||||
import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/commands.ts"
|
import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/commands.ts"
|
||||||
import { git } from "../git/run.ts"
|
import { git } from "../git/run.ts"
|
||||||
import { branches, deleteBranch, switchBranch } from "../git/branch.ts"
|
import { branches, deleteBranch, switchBranch } from "../git/branch.ts"
|
||||||
@@ -85,7 +86,6 @@ const pendingCodes = new Map<string, (text: string) => void>()
|
|||||||
|
|
||||||
const AGENT_TEMPLATE = (name: string) => `---
|
const AGENT_TEMPLATE = (name: string) => `---
|
||||||
description: what ${name} is for — the model reads this when it picks a subagent
|
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
|
# 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
|
# 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
|
# isolation: worktree # work in a git checkout and branch of its own
|
||||||
@@ -100,19 +100,22 @@ const TEAM_ROW = "\u0000team"
|
|||||||
function openTeamSize(c: CommandCtx) {
|
function openTeamSize(c: CommandCtx) {
|
||||||
const t = c.app.team.team
|
const t = c.app.team.team
|
||||||
const pairs = [
|
const pairs = [
|
||||||
[1, 1, "one agent: no team, no helper"],
|
[1, 1, "one agent: no subagents"],
|
||||||
[1, 4, "you decide; up to three members"],
|
[1, 4, "the agent decides; up to three subagents"],
|
||||||
[2, 4, "a team from the start"],
|
[2, 4, "subagents from the start"],
|
||||||
[1, 8, "you decide; up to seven members"],
|
[1, 8, "the agent decides; up to seven subagents"],
|
||||||
[3, 8, "a team of at least three"],
|
[3, 8, "at least two subagents"],
|
||||||
[1, Math.min(16, t.cap), "a bigger team, with sub-managers"],
|
[1, Math.min(16, t.cap), "many subagents, with sub-managers"],
|
||||||
] as const
|
] as const
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const options = pairs
|
const options = pairs
|
||||||
.filter(([min, max]) => max <= t.cap && !seen.has(`${min} ${max}`) && seen.add(`${min} ${max}`))
|
.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 }))
|
.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())
|
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 <fewest> <most>", onSubmit: apply } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export const COMMANDS: Command[] = [
|
export const COMMANDS: Command[] = [
|
||||||
@@ -146,7 +149,7 @@ export const COMMANDS: Command[] = [
|
|||||||
const describe = (a: (typeof list)[number]) =>
|
const describe = (a: (typeof list)[number]) =>
|
||||||
[
|
[
|
||||||
`${a.name} (${a.source}) — ${a.description}`,
|
`${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")
|
].join("\n")
|
||||||
if (named) return c.view.info(describe(named))
|
if (named) return c.view.info(describe(named))
|
||||||
c.view.info(
|
c.view.info(
|
||||||
@@ -215,9 +218,13 @@ export const COMMANDS: Command[] = [
|
|||||||
c.setEffort(v as Level)
|
c.setEffort(v as Level)
|
||||||
c.view.info(control === "switch" ? `thinking ${v === "off" ? "off" : `on (${v})`}` : `effort: ${v}`)
|
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 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))
|
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 (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)
|
if (arg) return set(arg)
|
||||||
@@ -241,8 +248,8 @@ export const COMMANDS: Command[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "team",
|
name: "team",
|
||||||
args: "[size [<min> <max>] | stop [<name>]]",
|
args: "[size [<fewest> <most>] | stop [<name>]]",
|
||||||
description: "the team: its agents and the room; its size; stop a member",
|
description: "subagents: the team and the room; how many; stop one",
|
||||||
run(c, arg) {
|
run(c, arg) {
|
||||||
// The tree and the room as the lead sees them — the person sees everything (team.md §5).
|
// 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)
|
const words = arg.trim().split(/\s+/).filter(Boolean)
|
||||||
|
|||||||
@@ -151,7 +151,7 @@ test("a background subagent: its transcript as history turns; send to a finished
|
|||||||
], { route: (model) => (model.startsWith("g/") ? child : undefined) })
|
], { route: (model) => (model.startsWith("g/") ? child : undefined) })
|
||||||
config(fake.url, true)
|
config(fake.url, true)
|
||||||
const { c, events, sid } = await client()
|
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)
|
expect(await until(() => events.some((e) => e.type === "job" && e.state === "end"))).toBe(true)
|
||||||
const listed: any = await c.request("_lembas/jobs", { sessionId: sid })
|
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" })
|
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")
|
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" }) } })
|
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.
|
// 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"])
|
expect(fake.requests.map((r: any) => r.model)).toEqual(["f/m", "f/m"])
|
||||||
const result = fake.requests[1].messages.at(-1).content as string
|
const result = fake.requests[1].messages.at(-1).content as string
|
||||||
expect(result).toContain("holds one model at a time")
|
expect(result).toContain("That model is on the same one-model server as this chat's model; name a model on another server.")
|
||||||
expect(result).toContain("Do this part yourself")
|
})
|
||||||
|
|
||||||
|
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 { 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 { envLines, promptText } from "../src/prompt/assemble.ts"
|
||||||
import { absPath, type ToolContext } from "../src/tool/tool.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 { Team, TEAM_TOOLS, type Capacity } from "../src/team/team.ts"
|
||||||
import { noteBlock } from "../src/session/prefix.ts"
|
import { noteBlock } from "../src/session/prefix.ts"
|
||||||
|
import { offer, subagentModel, type ModelInfo } from "../src/team/subagents.ts"
|
||||||
|
|
||||||
type Case = Record<string, unknown>
|
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)
|
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)}`)
|
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
|
// 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/team.ts); the app drives the same class from real turns.
|
||||||
team: (c) => {
|
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 if (st.tool === "team_stop") result = team.stopCmd(by, args.name)
|
||||||
else {
|
else {
|
||||||
result = team.refusal(by, st.tool, args) ?? null
|
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() }
|
return { result, delivered: delivered() }
|
||||||
}
|
}
|
||||||
@@ -273,7 +294,7 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
|||||||
team.finish(by, st.reply ?? "", st.reason ?? "stop")
|
team.finish(by, st.reply ?? "", st.reason ?? "stop")
|
||||||
return { delivered: delivered() }
|
return { delivered: delivered() }
|
||||||
case "task_done":
|
case "task_done":
|
||||||
team.helperDoneBy(by)
|
team.taskDoneBy(by)
|
||||||
return null
|
return null
|
||||||
case "set":
|
case "set":
|
||||||
if (st.capacity) capacity = st.capacity
|
if (st.capacity) capacity = st.capacity
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ describe("context", () => {
|
|||||||
await a.engine.prompt("read it")
|
await a.engine.prompt("read it")
|
||||||
const system = a.engine.compose(a.engine.mode)
|
const system = a.engine.compose(a.engine.mode)
|
||||||
const text = renderBreakdown(contextBreakdown(a.engine, system, [], undefined, a.engine.o.tools))
|
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).toContain("system prompt")
|
||||||
expect(text).toMatch(/tool definitions \(\d+\)/)
|
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
|
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." })] }],
|
[{ 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
|
const started = fake!.requests[1].messages.at(-1).content as string
|
||||||
expect(started).toStartWith("[#0001]\nStarted subagent explore as job1 (find the bug).")
|
expect(started).toStartWith("[#0001]\nStarted subagent explore as job1 (find the bug).")
|
||||||
await settled(a, 3)
|
await settled(a, 3)
|
||||||
@@ -233,7 +233,7 @@ describe("background subagents (spec 2.0.0)", () => {
|
|||||||
{ chunks: [delta({ content: "Second report." })] },
|
{ 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")
|
expect(fake!.requests[2].messages.at(-1).content).toContain("Sent to job1")
|
||||||
await settled(a, 6)
|
await settled(a, 6)
|
||||||
const child = other!.requests
|
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, "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." })] }],
|
[{ 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 msgs = fake!.requests[1].messages
|
||||||
const note = msgs.find((m: any) => m.role === "user" && String(m.content).startsWith(MARKS.message))
|
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?")
|
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" })] }],
|
[{ chunks: [toolCall(0, "b1", "bash", JSON.stringify({ command: "true", description: "nothing" }))] }, { chunks: [delta({ content: "ran" })] }],
|
||||||
{ asker },
|
{ 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" }])
|
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 }],
|
[{ chunks: [delta({ content: "slow" })], gapMs: 400 }],
|
||||||
{ oneAtATime: true },
|
{ 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)
|
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[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")
|
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
|
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)
|
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
|
;(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
|
const until = Date.now() + 8000
|
||||||
while (Date.now() < until && !events.some((e) => e.type === "sub_tool" && e.name === "write")) await Bun.sleep(20)
|
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")
|
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.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.get("parser")!.state).toBe("finished")
|
||||||
expect(t.isManager("lead")).toBe(true)
|
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(teamText(app, "")).toContain("- parser — the parser · finished")
|
||||||
expect(await Bun.file(join(dir, "x.txt")).exists()).toBe(false)
|
expect(await Bun.file(join(dir, "x.txt")).exists()).toBe(false)
|
||||||
// A member is not a job to the model.
|
// A member is not a job to the model.
|
||||||
|
|||||||
Reference in new issue
Block a user