Teams in the CLI (harness spec 3.2.0, team.md)
ci / check (push) Waiting to run

The four team tools (tool/team_tools.ts) on the rules of team/team.ts,
driven from real turns by team/runtime.ts:

- Members run on the subagent job machinery (app.ts spawn with `member`):
  background, unattended, their own prompt from blocks/team_member.md
  (the whole of team_lead.md for one with lead: true), team_send and
  team_read (team_add and team_stop for one that leads). Not jobs to the
  model: job_list and the job_* tools skip them; a client over the link
  still sees them as subagent jobs named "<name>: <role>".
- The room: messages and reports with their marks, delivered at the next
  step boundary or waking a finished member or the idle lead; report
  posts to the manager.
- A manager's work calls answer the refusal before the permission check,
  by risk class (an MCP tool by its readOnlyHint); the tool list is
  unchanged. job_stop and reading bash stay.
- Slots: the lead waits for one before its turn (Esc stops the wait);
  members wait in order; task helpers in a team count toward the size
  and hold a slot; job_send restarting a helper is refused when full.
- The offer is decided with the prompt and kept with it: a resumed
  session takes it from its kept tool list.
- The size: agents.min/max settings, refused outside 1 <= min <= max <=
  cap, kept across /new and resume; said in a note on the first message,
  after a change, after a compaction and a rewind.
- TUI: /team (tree and room, size, stop a member, dialogs for both), an
  "agents" row beside the effort in /effort, "team n/max" in the status
  bar. /team works over ACP too.

tests/team.test.ts runs a lead, a member and its report through the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-09 09:08:52 +00:00
1 parent fbefe7f844
commit 6ec95fbd66
21 files changed
+834 -45

No files matched your search

+140 -11
View File
@@ -45,6 +45,8 @@ import { LIBRARY_SERVER, mcpInstructions } from "./mcp/index.ts"
import type { McpServer } from "./config/schema.ts"
import { embedderFor } from "./library/embed.ts"
import { isLevel, levelsFor, readLevel, type Level } from "./provider/effort.ts"
import { readingBashByDefault, Team, TEAM, TEAM_TOOLS, type Capacity, type TeamAgent } from "./team/team.ts"
import { TeamRuntime, type MemberHandle } from "./team/runtime.ts"
export interface AppOptions {
cwd: string
@@ -149,6 +151,8 @@ export interface App {
context: SessionContext
/** Replace it (`{}`: none), naming the instance it came from for the prompt's heading. */
setContext(ctx: SessionContext, from?: string): void
/** The session's team (spec 3.2.0, team.md): its rules and its members at work. */
team: TeamRuntime
/** The chat a session the client opened belongs to (its id): sent with every call to the server's
* memory, which answers in that chat's data group (harness/acp.md *Memory*); undefined for a
* session of this device's own. The memory for the prompt is read again in it. */
@@ -360,7 +364,7 @@ export function createApp(o: AppOptions): App {
if (src.instance) named[`Instructions from ${src.instance.name}`] = src.instance.instructions
return { memory: src.memory ?? "", personality: src.personality ?? "", files: named, mcp: src.mcp, source: src }
}
const system = (m: Mode, current: ResolvedModel, ch: Changing, subagent?: { name: string; instructions: string; report: boolean; skills: boolean; recall: boolean }, at?: { cwd: string; root: string }, plan?: Plan) => {
const system = (m: Mode, current: ResolvedModel, ch: Changing, subagent?: { name: string; instructions: string; report: boolean; skills: boolean; recall: boolean }, at?: { cwd: string; root: string }, plan?: Plan, member?: { name: string; manager: string; role: string; goal: string; lead: boolean }) => {
const src = ch.source as Source
return assembleSystem({
modelRef: current.ref,
@@ -392,6 +396,9 @@ export function createApp(o: AppOptions): App {
library: libraryBlock(),
prototypes: prototypesBlock(),
context: subagent ? subagent.recall : true,
...(member ? { member } : {}),
// The lead's prompt says how to lead whenever the team tools are offered (team.md §10).
teamLead: !subagent && teamRt.team.offered,
})
}
/** A mode's text for a note, as the prompt would have it. */
@@ -417,6 +424,7 @@ export function createApp(o: AppOptions): App {
const inWords = (sec: number) => (sec % 60 === 0 ? `${sec / 60} minute${sec === 60 ? "" : "s"}` : `${sec} seconds`)
const vars: Record<string, Record<string, string>> = {
task: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") },
team_add: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") },
bash: {
timeout: inWords(loaded.config.limits?.bash_timeout ?? 120),
timeout_max: inWords(600),
@@ -464,11 +472,15 @@ export function createApp(o: AppOptions): App {
// The model asked for (task's `model`), else the agent's own, else the session's — and whether
// 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
const running = registry.running(jobsSessionId()).filter((j) => j.kind === "subagent" && j.subagent)
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.`)
// 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.`)
}
// A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation).
let wt: Worktree | undefined
if (opts.worktree || spec.isolation === "worktree") {
@@ -496,8 +508,14 @@ export function createApp(o: AppOptions): App {
}
: perm
// `report` is every subagent's: its way of speaking to the main agent before its final reply.
const childTools = [...tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && (!spec.tools || spec.tools.includes(t.name))), ...BUILTIN_TOOLS.filter((t) => SUBAGENT_ONLY.has(t.name))]
const description = opts.description ?? prompt.split("\n")[0]!.slice(0, 60)
// 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.
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))),
...BUILTIN_TOOLS.filter((t) => SUBAGENT_ONLY.has(t.name)),
]
const description = member ? `${member.name}: ${member.role}` : (opts.description ?? prompt.split("\n")[0]!.slice(0, 60))
const ownerSession = jobsSessionId()
const childRow = store?.createSession(at?.root ?? project.root, m.ref, engine.sessionId)
if (childRow) store!.setTitle(childRow.id, `${spec.name}: ${description}`)
@@ -532,7 +550,17 @@ export function createApp(o: AppOptions): App {
...baseToolCtx(),
...(at ? { root: at.root, cwd: at.cwd, projectDir: undefined } : {}),
jobs: { registry, owner: () => ({ sessionId: childSessionId, job }) },
...(member ? { team: { runtime: teamRt, as: member.name } } : {}),
},
...(member
? {
team: {
fix: () => teamRt.team.offered,
offered: () => teamRt.team.offered,
refuse: (tool: Tool, args: unknown) => teamRt.refuse(member.name, tool, args),
},
}
: {}),
steerMark: MARKS.to_subagent,
changing: () => changing(true, at),
modeText: modeNote,
@@ -552,6 +580,7 @@ export function createApp(o: AppOptions): App {
},
at,
plan,
member ? { name: member.name, manager: member.manager ?? TEAM.names.lead, role: member.role, goal: member.goal, lead: member.leads } : undefined,
),
})
// Its own background commands wake it at its next step while it runs; once it has ended they
@@ -567,7 +596,14 @@ 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 } : {}) }
}
const run = (text: string) => child.prompt(text).then(finish, (e) => (job.lastError = (e as Error).message, finish("error")))
// A task helper started inside a team counts toward its size and holds a slot while it runs
// (team.md §3, §8).
const counted = (p: Promise<SubagentResult>) => {
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 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 = {
bus: childBus,
engine: child,
@@ -575,7 +611,23 @@ export function createApp(o: AppOptions): App {
restart: (text) => run(text).then(() => undefined),
cancel: () => child.cancel(),
}
job = registry.startSubagent({ sessionId: ownerSession, ownerCallId: opts.callId, ownerJobId: baseOwner().job?.id, agent: spec.name, model: m.ref, description, handle, childSessionId, silent: !opts.background })
job = registry.startSubagent({ sessionId: ownerSession, ownerCallId: opts.callId, ownerJobId: baseOwner().job?.id, agent: spec.name, model: m.ref, description, handle, childSessionId, silent: !opts.background || member !== undefined })
if (member) {
// A member's turn that ends is its report (team.md §5): the team, not a job note, carries it.
job.team = member.name
const memberRun = (text: string, machine: boolean) => {
if (job.state !== "running") registry.restartSubagent(job)
void run(text, machine).then((r) => {
const leftover = [...child.takeNotes(), ...child.takeInbox()]
const reason = r.reason === "error" ? `error — ${r.error ?? "unknown"}` : r.reason
if (r.reason !== "cancelled") teamRt.memberDone(member.name, r.text, reason, leftover)
})
}
const mh: MemberHandle = { engine: child, run: (text) => memberRun(text, true), cancel: () => child.cancel() }
teamRt.attach(member.name, mh)
memberRun(prompt, false)
return { id: job.id, done: Promise.resolve({ text: "", steps: 0, tools: 0, reason: "stop" }), member: mh }
}
// A synchronous subagent goes with its parent's turn (Esc stops both); a background one does not.
const onAbort = () => child.cancel()
opts.signal?.addEventListener("abort", onAbort, { once: true })
@@ -601,16 +653,72 @@ export function createApp(o: AppOptions): App {
budget: { wall_seconds: loaded.config.limits?.wall_seconds, output_bytes: loaded.config.limits?.output_bytes, completion_tokens: loaded.config.limits?.completion_tokens },
compaction,
perm,
toolCtx: { ...baseToolCtx(), jobs: { registry, owner: baseOwner } },
toolCtx: { ...baseToolCtx(), jobs: { registry, owner: baseOwner }, team: { get runtime() { return teamRt }, as: TEAM.names.lead } },
system: (m, current, plan, ch) => system(m, current, ch, undefined, undefined, plan),
team: {
fix: (ref, offered) => teamRt.team.fix(ref, offered),
offered: () => teamRt.team.offered,
refuse: (tool, args) => teamRt.refuse(TEAM.names.lead, tool, args),
note: () => teamRt.team.noteLines(),
forget: () => teamRt.team.forgetTold(),
beforeTurn: (signal) => teamRt.beforeLeadTurn(signal),
},
changing: () => changing(),
modeText: modeNote,
// The configured MCP servers, up to loop.json's wait: their tools in the first list.
beforeFirst: () => Promise.race([mcpReady.catch(() => {}), Bun.sleep(LOOP.context.mcp_wait_ms)]),
persistRule: trusted ? (rule) => persistProjectRule(project, rule) : undefined,
spawn,
// subagents: false — no task tool, and no team (team.md §2).
spawn: loaded.config.subagents === false ? undefined : spawn,
})
// The session's team (spec 3.2.0, team.md): its size from the configuration (the cap the global
// one's), the capacity of each model as the server says it.
const capacityOf = (ref: string): Capacity => {
try {
const s = ref === engine.model.ref ? engine.model.spec : resolveModel(loaded, ref).spec
return { parallel: s.parallel, single_session: s.single_session, group: s.group }
} catch {
return {}
}
}
// 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 = () =>
new Team({
min: teamSize.min ?? loaded.config.agents?.min,
max: teamSize.max ?? loaded.config.agents?.max,
cap: loaded.config.agents?.cap,
subagents: loaded.config.subagents !== false,
model: engine.model.ref,
capacity: capacityOf,
leadState: "idle",
readingBash: (command) => readingBashByDefault(command, project.root),
})
const teamRt: TeamRuntime = new TeamRuntime({
team: newTeam(),
bus,
// spawn makes the member's engine and attaches it before its first await, so a message to it
// finds it at once.
spawnMember: (member: TeamAgent, text: string) => {
spawn(member.agent, text, { member, model: member.model, background: true, description: `${member.name}: ${member.role}` }).catch((e) => {
bus.emit({ type: "notice", level: "warn", message: `team: ${member.name} could not start: ${(e as Error).message}` })
teamRt.memberDone(member.name, "", `error — ${(e as Error).message}`, [])
})
},
lead: { note: (text) => engine.note(text), wake: (text) => wake(text, shownLine(text)) },
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 }
} catch (e) {
return { error: (e as Error).message }
}
},
})
// The lead's turn ended: it is idle, and its slot frees (team.md §8).
bus.on((e) => e.type === "done" && teamRt.leadDone())
// MCP tools join the engine's list as servers connect (the engine reads it at each prompt).
let mcpNames = new Set<string>()
const syncMcp = () => {
@@ -722,6 +830,9 @@ export function createApp(o: AppOptions): App {
if (key === "model") return engine.model.ref
if (key === "effort") return engine.effort
if (key === "mode") return engine.mode
// The team's size as the session holds it (the cap applied).
if (key === "agents.min") return teamRt.team.min
if (key === "agents.max") return teamRt.team.max
return key.split(".").reduce<unknown>((x, k) => (x && typeof x === "object" ? (x as Record<string, unknown>)[k] : undefined), loaded.config)
},
check(key, v) {
@@ -729,6 +840,12 @@ export function createApp(o: AppOptions): App {
if (key === "effort" && !(levelsFor(engine.model).levels as string[]).includes(String(v)))
throw new Error(`${engine.model.ref} offers the levels ${levelsFor(engine.model).levels.join(", ")}`)
if (key === "knowledge") for (const b of v as string[]) if (!library.base(b)) throw new Error(`there is no knowledge base "${b}" — lembas kb lists them`)
// The team's size (team.md §2): refused outside 1 ≤ min ≤ max ≤ cap, with the bounds.
if (key === "agents.min" || key === "agents.max") {
const t = teamRt.team
const no = Team.check(key === "agents.min" ? Number(v) : t.min, key === "agents.max" ? Number(v) : t.max, t.cap)
if (no) throw new Error(no)
}
},
apply(key, v) {
if (key === "model") switchModel(String(v))
@@ -740,6 +857,14 @@ export function createApp(o: AppOptions): App {
else if (key === "limits.wall_seconds" || key === "limits.output_bytes" || key === "limits.completion_tokens") engine.o.budget = { ...engine.o.budget, [key.slice("limits.".length)]: Number(v) }
else if (key === "compaction.auto_at") compaction.autoAt = Number(v)
else if (key === "knowledge") libraryCtx.bases = (v as string[]).length ? (v as string[]) : undefined
else if (key === "agents.min" || key === "agents.max") {
// Said to the lead as a note on its next message (team.md §2), never in the prompt.
const t = teamRt.team
t.setSetting(key === "agents.min" ? Number(v) : t.min, key === "agents.max" ? Number(v) : t.max)
teamSize.min = t.min
teamSize.max = t.max
bus.emit({ type: "team" })
}
if (key !== "model" && key !== "effort" && key !== "mode") setIn(key, v)
bus.emit({ type: "setting", key, value: v })
},
@@ -791,6 +916,7 @@ export function createApp(o: AppOptions): App {
mcpReady,
small,
close: () => {
teamRt.stopAll()
detach()
for (const d of childDetach.splice(0)) d()
return mcp.close()
@@ -805,6 +931,7 @@ export function createApp(o: AppOptions): App {
jobsSessionId,
jobs: () => registry.list(jobsSessionId()),
wake,
team: teamRt,
get context() {
return context
},
@@ -830,6 +957,7 @@ export function createApp(o: AppOptions): App {
const s = store?.createSession(project.root, engine.model.ref)
keepCwd(s?.id)
standIn = `local_${crypto.randomUUID().slice(0, 8)}`
teamRt.reset(newTeam())
engine.newSession(s?.id)
turns.reset()
if (s) bus.emit({ type: "session", id: s.id, model: engine.model.ref, mode: engine.mode })
@@ -843,6 +971,7 @@ export function createApp(o: AppOptions): App {
const before = engine.sessionId
frozen = freeze()
void readServerMemory()
teamRt.reset(newTeam())
engine.resume(id, store.context(id))
// A session from before titles gets its first prompt's line now, and the model's after the next reply.
firstShown = ""
+3
View File
@@ -62,6 +62,9 @@ export type Event =
/** The note about that job was given to the model: inside a turn at its next step, or as the
* `machine` turn whose `prompt` follows. */
| { type: "job_note"; jobId: string; text: string }
/** The session's team changed (spec 3.2.0): a member added, started, finished or stopped, a
* message in the room. Read the team itself for what. */
| { type: "team" }
/** The permission mode changed from inside a turn (plan approved). */
| { type: "mode"; mode: Mode }
/** The session's model or effort changed, for whatever reason — /model, /effort, the settings
+3
View File
@@ -88,6 +88,9 @@ export interface Job {
/** Set when the note's wake should not happen: a synchronous subagent whose result goes back
* as the task call's output. */
silent?: boolean
/** A team member's name (spec 3.2.0): not a job to the model — the job tools do not take it —
* but a subagent job to a client over the link (team.md §4, §11). */
team?: string
}
/** A front end holding a session: where its jobs' events go, and how a note wakes it. The
+22 -1
View File
@@ -236,6 +236,12 @@ export interface PromptContext {
/** Set for a subagent: who it is and what it is for; replaces the mode's own instructions.
* `report`: it has the report tool. */
subagent?: { name: string; instructions: string; report?: boolean }
/** Spec 3.2.0 (team.md §4): a team member — blocks/team_member.md in place of blocks/subagent.md,
* with the whole of blocks/team_lead.md for one that may lead. */
member?: { name: string; manager: string; role: string; goal: string; lead: boolean }
/** Spec 3.2.0 (team.md §10): the team tools are offered to this session's agent (the lead):
* blocks/team_lead.md is in its prompt. */
teamLead?: boolean
/** The memory as it was when the prompt was made ("" when both files are empty); undefined
* when memory is off. A later change by the person goes as a note. */
memory?: string
@@ -380,7 +386,22 @@ export function assembleSystem(ctx: PromptContext): string {
if (ctx.library && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/library.md"), { knowledge_scope: ctx.library.scope }))
if (ctx.prototypes && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/prototypes.md"), { prototype_sources: ctx.prototypes.sources, prototype_surface: PROTOTYPE_SURFACE }))
if (ctx.subagent)
if (ctx.teamLead && !ctx.subagent) sections.push(promptText("blocks/team_lead.md"))
if (ctx.subagent && ctx.member)
sections.push(
fillPrompt(promptText("blocks/team_member.md"), {
name: ctx.member.name,
manager: ctx.member.manager,
role: ctx.member.role,
goal: ctx.member.goal,
instructions: ctx.subagent.instructions,
read_only: ctx.mode === "plan" ? SUBAGENT_READ_ONLY : "",
lead: ctx.member.lead ? promptText("blocks/team_lead.md") : "",
})
.replace(/\n{3,}/g, "\n\n")
.trim(),
)
else if (ctx.subagent)
sections.push(
fillPrompt(promptText("blocks/subagent.md"), {
name: ctx.subagent.name,
+47 -1
View File
@@ -16,6 +16,7 @@ import { customCommands, expandCommand } from "../project/commands.ts"
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"
export type CommandKind = "custom" | "skill" | "builtin"
@@ -34,7 +35,7 @@ export const TUI_COMMAND_NAMES = [
"agents", "continue", "help", "model", "effort", "mode", "plan", "plans", "prototype", "review", "changelog", "init", "release", "tasks", "decisions", "decide",
"jobs", "sessions", "resume", "remote", "delete", "new", "clear", "copy", "undo", "redo", "diff", "checkpoint", "checkpoints", "branch", "commit", "context",
"compact", "memory", "skills", "skill", "personality", "voice", "speak", "mcp", "login", "logout", "settings", "usage", "kb", "theme", "skin", "terminal",
"icons", "trust", "reload", "restart", "quit", "exit", "q",
"icons", "trust", "reload", "restart", "quit", "exit", "q", "team",
] as const
/** The built-ins that work without a screen of their own. Descriptions as the TUI's (a test). */
@@ -47,8 +48,51 @@ 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 <min> <max> | stop <name>" },
]
/** The status bar's word for a team with members: its size of the most it may have, and how many
* work now; "" without members. */
export function teamLabel(team: Team): string {
if (!team.hasMembers()) return ""
const n = team.active()
return `team ${team.size()}/${team.max}${n ? ` · ${n} working` : ""}`
}
/** `/team [size <min> <max> | stop <name>]`: the team's tree and room, as the lead sees them (the
* person sees everything, team.md §5); its size for this session; a member stopped by the person,
* which the lead is told in a note. */
export function teamText(app: App, arg: string): string {
const rt = app.team
const t = rt.team
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 <min> <max> — 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
try {
app.settings.set("agents.min", Math.min(min, t.max))
app.settings.set("agents.max", max)
app.settings.set("agents.min", min)
} 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.`
}
if (verb === "stop") {
const name = rest[0]
if (!name) return "/team stop <name> — a member and everything under it"
const gone = rt.personStop(name)
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)."
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.`
}
/** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and
* a client over ACP show. Making one (`new`) and handing one a task need a screen: the TUI's. */
export function agentsText(app: App, arg: string): string {
@@ -175,6 +219,8 @@ export function expandSlash(app: App, text: string): Expanded | undefined {
// List and describe anywhere; `new` and `<name> <task>` are the TUI's own.
if (/^(new)\b/.test(arg) || arg.trim().split(/\s+/).length > 1) return { kind: "message", name, text: "/agents here lists and describes the subagents; making one or handing one a task is done at the terminal." }
return { kind: "message", name, text: agentsText(app, arg) }
case "team":
return { kind: "message", name, text: teamText(app, arg) }
case "init":
// The prompt is ours; only what was typed after the name may attach files.
return { kind: "prompt", name, prompt: initPrompt(app, arg), shown: t, attachFrom: arg }
+48 -2
View File
@@ -63,6 +63,21 @@ export interface EngineOptions {
/** What a message sent mid-turn starts with: the user's words for a session, the main agent's for
* a subagent (loop.json jobs.marks to_subagent). */
steerMark?: string
/** The team this engine's agent is on (spec 3.2.0, team.md): whether it is offered (decided as
* the prompt is made, for `model`), what a call answers before its permission check (the team
* tools' requirement, task's size, a manager's work), the lead's note lines, and — the lead —
* a slot of the model before a turn runs. */
team?: {
/** Decide whether the team is offered for a new prompt on `model` — or, for a prompt the
* session kept (a resume), take what it decided then (`offered`). */
fix(model: string, offered?: boolean): boolean
offered(): boolean
refuse(tool: Tool, args: unknown): string | undefined
note?(): string[]
/** What the lead was told of the team is to be said again: a new prompt, or a rewind. */
forget?(): void
beforeTurn?(signal: AbortSignal): Promise<boolean>
}
}
/** What a prompt carries besides its text. */
@@ -168,6 +183,8 @@ export class Engine {
private keptBase: Told | undefined
/** The last request's usage, cached input tokens included, and what it was for. */
lastRequest: { usage: Usage; kind: "step" | "compaction" } | undefined
/** The prompt the team's offer was last decided for. */
private teamFixed: Kept | undefined
constructor(readonly o: EngineOptions) {
this.mode = o.mode
@@ -201,6 +218,9 @@ export class Engine {
if (this.model.spec.vision === true) could.add("vision")
if (this.o.toolCtx.projectDir !== undefined) could.add("project")
if (this.o.toolCtx.show !== undefined) could.add("display")
// The team tools' requirement: offered as the prompt is made; whether it is met is the team's
// to answer at each call (team.md §2: one_agent or one_request).
if (this.o.team?.offered()) could.add("team")
const L = this.o.toolCtx.library
const have = L?.lib.bases().filter((b) => b.documents > 0).map((b) => b.name) ?? []
if (have.some((b) => !L!.bases || L!.bases.includes(b))) could.add("knowledge")
@@ -223,7 +243,20 @@ export class Engine {
/** The session's prefix, made now if there is none yet (or it was made for another model). */
prefix(): Kept {
if (this.kept && this.kept.model === this.model.ref) return this.kept
if (this.kept && this.kept.model === this.model.ref) {
// A kept prompt (a resumed session) decided whether the team is offered when it was made: its
// tool list says so (team.md §2: fixed for as long as the prompt is).
if (this.teamFixed !== this.kept) {
this.teamFixed = this.kept
this.o.team?.fix(this.model.ref, !this.kept.left.includes("team_add"))
}
return this.kept
}
// Whether a team is offered is decided here, with the prompt and the tool list (team.md §2).
this.o.team?.fix(this.model.ref)
// After a compaction the size is said again, as the date is: neither is in the prompt. A model
// switch keeps the conversation, and what it said.
if (!this.kept) this.o.team?.forget?.()
const changing = this.o.changing?.() ?? NO_CHANGING
const now = this.now(changing)
const could = this.could()
@@ -235,6 +268,7 @@ export class Engine {
if (this.kept?.told.date) told.date = this.kept.told.date
this.keptBase = { ...structuredClone(told), date: undefined }
this.kept = { system: this.o.system(this.mode, this.model, this.plan, changing), left, model: this.model.ref, told }
this.teamFixed = this.kept
this.saveKept()
return this.kept
}
@@ -261,7 +295,7 @@ export class Engine {
* the model's from then on. */
private takeNote(): string {
const kept = this.prefix()
const { note, told } = noteFor(kept.told, this.now(), (m) => this.o.modeText?.(m) ?? fillPrompt(promptText(`modes/${m}.md`), { plan_dir: this.o.toolCtx.planDir ?? "" }))
const { note, told } = noteFor(kept.told, this.now(), (m) => this.o.modeText?.(m) ?? fillPrompt(promptText(`modes/${m}.md`), { plan_dir: this.o.toolCtx.planDir ?? "" }), this.o.team?.note?.() ?? [])
if (note) {
kept.told = told
this.saveKept()
@@ -282,6 +316,7 @@ export class Engine {
rewound() {
if (!this.kept || !this.keptBase) return
this.kept.told = structuredClone(this.keptBase)
this.o.team?.forget?.()
this.saveKept()
}
@@ -596,6 +631,11 @@ export class Engine {
// Before a session's first request: the configured MCP servers get a moment to connect, so
// their tools are in the first list (§10 rule 4).
if (!this.kept || this.kept.model !== this.model.ref) await this.o.beforeFirst?.()
// A team's lead holds a slot of its model while its turn runs (team.md §8): it may wait for one.
if (this.o.team?.beforeTurn && !(await this.o.team.beforeTurn(signal))) {
bus.emit({ type: "done", reason: "cancelled" })
return "cancelled"
}
// What changed since the prompt was made goes after the person's words, as a note (§10 rule 3).
const note = this.takeNote()
this.push({
@@ -878,6 +918,7 @@ export class Engine {
const resolved = resolveCall(call.name, raw)
tool = toolMap.get(resolved.name) ?? tool
raw = resolved.raw
const team = this.o.team
// Offered for the whole session; while what it needs is not met, the spec's sentence (§10 rule 4).
const no = unavailable(tool.name, tool.requires ? [tool.requires] : undefined, this.met())
if (no) return { result: { output: no, isError: true } }
@@ -888,10 +929,15 @@ export class Engine {
const { args: own, purpose } = splitPurpose(tool, raw)
const parsed = tool.schema.safeParse(own)
if (!parsed.success) {
const spoken = tool.invalid?.(own, { ...this.o.toolCtx, signal: new AbortController().signal })
if (spoken) return { result: { output: spoken, isError: true } }
const why = parsed.error.issues.map((i) => `${i.path.join(".") || "(arguments)"}: ${i.message}`).join("; ")
return { result: { output: `Invalid arguments for ${tool.name}: ${why}`, isError: true } }
}
let args = parsed.data
// A team's answers come before the permission check — nothing is asked (team.md §6).
const byTeam = team?.refuse(tool, args)
if (byTeam) return { result: { output: byTeam, isError: true } }
const toolCtx: ToolContext = { ...this.o.toolCtx, signal: new AbortController().signal }
let request = tool.permission(args, toolCtx)
let edited: string | undefined
+4 -2
View File
@@ -74,7 +74,7 @@ export function toldAt(now: Now): Told {
/** The note for a user message: a line for each thing that differs from what the model was told,
* in loop.json's order, inside the note's tags — "" when nothing does. `modeText` is the mode's
* text (modes/<mode>.md, filled as the prompt fills it). Returns what is told after it. */
export function noteFor(told: Told, now: Now, modeText: (mode: Mode) => string): { note: string; told: Told } {
export function noteFor(told: Told, now: Now, modeText: (mode: Mode) => string, extra: string[] = []): { note: string; told: Told } {
const lines: string[] = []
const say = (text: string, vars: Record<string, string>) => lines.push(fillPrompt(text, vars))
const or = (s: string) => (s.trim() ? s : NOTHING)
@@ -88,6 +88,8 @@ export function noteFor(told: Told, now: Now, modeText: (mode: Mode) => string):
// Only a server that has connected or changed since: one that went away takes its tools with it.
const arrived = Object.keys(now.mcp).filter((name) => told.mcp[name] !== now.mcp[name])
if (arrived.length) say(NOTE.mcp, { instructions: arrived.map((name) => mcpBlock(name, now.mcp[name]!)).join("\n") })
// Spec 3.2.0: the team's size and members the person stopped (team.md §10), last.
lines.push(...extra)
return { note: lines.length ? noteBlock(lines) : "", told: { ...toldAt(now), date: now.date } }
}
@@ -118,7 +120,7 @@ export function today(at = new Date()): string {
/** A requirement a tool can have (the spec's `requires`, and the CLI's own: vision, project,
* knowledge). */
export type Requirement = "vision" | "plan" | "project" | "knowledge" | "display" | "page" | "forge"
export type Requirement = "vision" | "plan" | "project" | "knowledge" | "display" | "page" | "forge" | "team"
/** Rule 4: the tool list is fixed for the session. A tool is offered when every requirement it has
* is one the session could meet at some point (`could`), decided once as the prompt is made; a
+197
View File
@@ -0,0 +1,197 @@
// A lead session's team at work (harness spec 3.2.0, team.md): the rules of team.ts driven by real
// turns. The lead is the session's own engine; each member is a subagent engine of its own (the job
// machinery, app.ts spawn), started when the team gives it a slot. What the rules decide happens
// through three effects: a turn starts (a member's brief, a woken agent's messages), a message reaches
// a working agent at its next step, an agent is stopped.
import type { Bus } from "../bus/index.ts"
import { TOOL_SPECS } from "../harness.ts"
import type { Engine } from "../session/engine.ts"
import type { Tool } from "../tool/tool.ts"
import { parallelFrom, Team, TEAM, type TeamAgent } from "./team.ts"
/** A member's engine, as the app made it. */
export interface MemberHandle {
engine: Engine
/** Its next turn, with this text as the prompt (its context intact). */
run(text: string): void
/** Stop it where it is; nothing more comes from it. */
cancel(): void
}
export interface RuntimeOptions {
team: Team
/** The lead's bus: where the team's news is said (`team` events). */
bus: Bus
/** Make a member's engine (attached here with `attach`) and start its first turn with `text`. */
spawnMember(member: TeamAgent, text: string): void
/** The lead: a message at its next step (`note`), or — idle — a turn of its own (`wake`). */
lead: { note(text: string): void; wake(text: string): void }
/** An unknown agent or model, answered as task answers it; undefined when both are known. The
* model's ref as the session knows it. */
resolve?(agent: string, model: string | undefined): { error: string } | { model: string }
}
export class TeamRuntime {
team: Team
private members = new Map<string, MemberHandle>()
/** The lead's turn waiting for a slot: resolved when it is given one. */
private leadWaiter: (() => void) | undefined
constructor(private o: RuntimeOptions) {
this.team = o.team
this.wire()
}
private wire() {
this.team.effects = {
start: (name, text) => this.start(name, text),
deliver: (name, text) => this.deliver(name, text),
stop: (name) => this.stopped(name),
}
}
/** Another session: its members are stopped, and the team starts again, empty. */
reset(team: Team) {
this.stopAll()
this.leadWaiter = undefined
this.team = team
this.wire()
this.changed()
}
private changed() {
this.o.bus.emit({ type: "team" })
}
/** Something the tools did changed the team: said, for whoever shows it. */
notify() {
this.changed()
}
/** team_add for `as`: an unknown agent or model is answered as task answers it, after the
* team's own refusals (the name, the goal, the size), which come first. */
add(as: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string }): { text: string; added?: TeamAgent } {
const agent = a.agent ?? "general"
const r = this.o.resolve?.(agent, a.model) ?? { model: a.model ?? this.team.get(as)?.model ?? this.team.model }
// The team's refusals first; an added member needs a known agent and model.
const dry = this.team.wouldRefuse(as, a)
if (dry) return { text: dry }
if ("error" in r) return { text: r.error }
const out = this.team.add(as, { ...a, agent, model: r.model })
this.changed()
return out
}
private start(name: string, text: string) {
if (name === TEAM.names.lead) {
const waiter = this.leadWaiter
this.leadWaiter = undefined
if (waiter) {
// The person's turn was waiting: it goes now, and what woke it meanwhile goes in at its first step.
if (text) this.o.lead.note(text)
waiter()
} else if (text) this.o.lead.wake(text)
this.changed()
return
}
const known = this.members.get(name)
if (known) known.run(text)
else {
const m = this.team.get(name)
if (m) this.o.spawnMember(m, text)
}
this.changed()
}
private deliver(name: string, text: string) {
if (name === TEAM.names.lead) this.o.lead.note(text)
else this.members.get(name)?.engine.note(text)
}
private stopped(name: string) {
const h = this.members.get(name)
this.members.delete(name)
h?.cancel()
this.changed()
}
/** The lead's turn is about to run: it holds a slot, or waits for one (the lead first). False
* when it was stopped while it waited. */
async beforeLeadTurn(signal: AbortSignal): Promise<boolean> {
const lead = this.team.get(TEAM.names.lead)!
if (lead.state === "working") return true
if (this.team.leadWants()) {
this.changed()
return true
}
this.o.bus.emit({ type: "notice", message: `waiting for a free slot: the model serves ${parallelFrom(this.team.capacityOf(this.team.model))} requests at once and the team's members hold them` })
this.changed()
return await new Promise<boolean>((resolve) => {
const onAbort = () => {
this.leadWaiter = undefined
this.team.leadIdle()
this.changed()
resolve(false)
}
signal.addEventListener("abort", onAbort, { once: true })
this.leadWaiter = () => {
signal.removeEventListener("abort", onAbort)
resolve(true)
}
})
}
/** The lead's turn ended. */
leadDone() {
this.team.finish(TEAM.names.lead)
this.changed()
}
/** A member's turn ended: its reply is its report; messages it was given too late wake it again. */
memberDone(name: string, reply: string, reason: string, leftover: string[]) {
if (!this.team.get(name)) return
this.team.finish(name, reply, reason)
if (leftover.length) this.team.undelivered(name, leftover)
this.changed()
}
/** What a call answers before its permission check, for the agent `as`. */
refuse(as: string, tool: Tool, args: unknown): string | undefined {
if (tool.name === "task" && as !== TEAM.names.lead) return undefined
// A tool the spec does not have (an MCP server's) by the class its call carries: read when it
// says readOnlyHint, interact for the server's memory, else execute.
let risk: string | undefined
if (!TOOL_SPECS[tool.name]) {
try {
risk = tool.permission(args as never, {} as never).class
} catch {
risk = "execute"
}
}
return this.team.refusal(as, tool.name, args, risk)
}
/** The person stopped a member in the UI (/team stop). */
personStop(name: string): string[] {
const gone = this.team.personStop(name)
this.changed()
return gone
}
/** Stop every member: the lead's session ends or is left. */
stopAll() {
for (const m of this.team.membersOf(TEAM.names.lead)) this.team.personStop(m.name)
this.team.forgetStopped()
for (const h of this.members.values()) h.cancel()
this.members.clear()
}
/** A member's engine, made. */
attach(name: string, h: MemberHandle) {
this.members.set(name, h)
}
handle(name: string): MemberHandle | undefined {
return this.members.get(name)
}
}
+28 -12
View File
@@ -156,18 +156,22 @@ export class Team {
return undefined
}
capacityOf(model: string): Capacity {
return this.o.capacity(model)
}
/** How many requests the lead's model serves at once now. */
parallelNow(): number {
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). */
fix(model = this.model): boolean {
* 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 = this.subagents && this.parallelNow() >= 2
this.offered = offered ?? (this.subagents && this.parallelNow() >= 2)
return this.offered
}
@@ -338,17 +342,24 @@ export class Team {
// ── the tools ───────────────────────────────────────────────────────────────
/** team_add's refusals, in the spec's order (§4), or undefined when it would add. */
wouldRefuse(by: string, a: { name: string; goal?: string }): string | undefined {
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
if (no) return no
if (a.name === "all") return TEXTS.all_name
if (!NAME.test(a.name)) return fill(TEXTS.bad_name, { name: a.name })
if (this.agents.has(a.name)) return fill(TEXTS.taken, { name: a.name })
if (by === LEAD && !this.agents.get(LEAD)!.goal && !a.goal?.trim()) return TEXTS.no_goal
if (this.size() + 1 > this.max) return fill(TEXTS.full, { max: this.max })
return undefined
}
/** team_add (§4). `brief` is what the member's first turn is given. */
add(by: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string }): { text: string; added?: TeamAgent } {
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
const no = this.wouldRefuse(by, a)
if (no) return { text: no }
if (a.name === "all") return { text: TEXTS.all_name }
if (!NAME.test(a.name)) return { text: fill(TEXTS.bad_name, { name: a.name }) }
if (this.agents.has(a.name)) return { text: fill(TEXTS.taken, { name: a.name }) }
const lead = this.agents.get(LEAD)!
const goal = a.goal?.trim()
if (by === LEAD && !lead.goal && !goal) return { text: TEXTS.no_goal }
if (this.size() + 1 > this.max) return { text: fill(TEXTS.full, { max: this.max }) }
const caller = this.agents.get(by) ?? lead
if (by === LEAD && goal) lead.goal = goal
const member: TeamAgent = {
@@ -511,13 +522,18 @@ export class Team {
this.fillSlots()
}
/** The lead's turn is starting without having asked (the engine's own): it holds a slot. */
leadWorking() {
/** The lead stopped waiting (the person pressed Esc): idle, out of the queue. */
leadIdle() {
const lead = this.agents.get(LEAD)!
lead.state = "working"
if (lead.state === "waiting") lead.state = "idle"
lead.waitSince = undefined
}
/** Nothing of the stopped members is said: the session they were in is gone. */
forgetStopped() {
this.stoppedByPerson = []
}
// ── what the lead is told ───────────────────────────────────────────────────
/** §10: the note's entries for the lead's next user message — the size when it was not said yet
+12 -2
View File
@@ -17,7 +17,8 @@ function jobsOf(ctx: ToolContext) {
function find(ctx: ToolContext, id: string): Job {
const { registry, owner } = jobsOf(ctx)
const job = registry.get(owner().sessionId, id.trim())
if (!job) throw new ToolError(`No background job ${id}. job_list shows this session's jobs.`)
// A team member is not a job to the model: the team tools take it (team.md §4).
if (!job || job.team) throw new ToolError(`No background job ${id}. job_list shows this session's jobs.`)
return job
}
@@ -42,7 +43,7 @@ export const jobListTool = defineTool({
permission: () => ({ permission: "job_list", class: "read", patterns: ["*"] }),
async run(_a, ctx) {
const { registry, owner } = jobsOf(ctx)
const jobs = registry.list(owner().sessionId)
const jobs = registry.list(owner().sessionId).filter((j) => !j.team)
if (!jobs.length) return { output: "No background jobs.", title: "0 jobs" }
const now = Date.now()
const lines = jobs.map((j) => {
@@ -101,6 +102,9 @@ export const jobSendTool = defineTool({
job.subagent.send(a.text)
return { output: `Sent to ${job.id} (${whatWords(job)}); it is given to it at its next step.`, title: `${job.id} · message sent` }
}
// Starting a finished helper again counts toward a team's size as task does (team.md §3).
const no = ctx.team?.runtime.team.taskRefusal()
if (no) throw new ToolError(no)
registry.restartSubagent(job)
void job.subagent.restart(a.text)
return { output: `${job.id} (${whatWords(job)}) is running again with your message as its new prompt, its context intact. Its report comes to you when it ends.`, title: `${job.id} · started again` }
@@ -114,6 +118,12 @@ export const reportTool = defineTool({
schema: z.object({ text: z.string().min(1) }),
permission: () => ({ permission: "report", class: "interact", patterns: ["*"] }),
async run(a, ctx) {
// A team member's report goes through the room, to its manager (team.md §4).
if (ctx.team && ctx.team.as !== "lead") {
const sent = ctx.team.runtime.team.message(ctx.team.as, a.text)
ctx.team.runtime.notify()
if (sent) return { output: `${sent} An answer, if one comes, arrives as a message to you; carry on meanwhile.`, title: `message to ${ctx.team.runtime.team.get(ctx.team.as)?.manager ?? "your manager"}` }
}
const { registry, owner } = jobsOf(ctx)
const job = owner().job
if (!job) throw new ToolError("report is for a subagent: you are the main agent, so write to the user directly.")
+2
View File
@@ -32,6 +32,8 @@ export function resolveCall(name: string, raw: unknown): { name: string; raw: un
const plan = fromTodos(obj.todos)
args = isPlan(plan) ? { title: plan.title, phases: plan.phases.map((p) => ({ title: p.title, status: p.status, tasks: p.tasks.map((t) => ({ text: t.text, status: t.status })) })) } : { title: "Work", phases: [] }
}
// team_send's `to` as one name: read as a list of one (team.md §5).
if (to === "team_send" && obj && typeof obj.to === "string") args = { ...obj, to: [obj.to] }
const renamed = ARGUMENTS_RENAMED[to]
if (renamed && args && typeof args === "object" && !Array.isArray(args)) {
const a = { ...(args as Record<string, unknown>) }
+6
View File
@@ -11,6 +11,7 @@ import { planSubmitTool } from "./plan_submit.ts"
import { planUpdateTool } from "./plan_update.ts"
import { planWriteTool } from "./plan_write.ts"
import { taskTool } from "./task.ts"
import { teamAddTool, teamReadTool, teamSendTool, teamStopTool } from "./team_tools.ts"
import { decisionsTool, tasksTool } from "./project_tools.ts"
import { webFetchTool, webSearchTool } from "./web.ts"
import { memoryTool } from "./memory.ts"
@@ -40,6 +41,11 @@ export const BUILTIN_TOOLS: Tool[] = [
webSearchTool,
webFetchTool,
taskTool,
// Spec 3.2.0: a team (team.md), offered where the model serves two requests at once.
teamAddTool,
teamSendTool,
teamReadTool,
teamStopTool,
planWriteTool,
planUpdateTool,
tasksTool,
+94
View File
@@ -0,0 +1,94 @@
// The team tools (harness spec 3.2.0, team.md; tools/team_*.json): add a member, send through the
// room, read the team and the room, stop a member. The rules are team/team.ts; these are its calls
// for the agent the context names (`ctx.team.as`).
import { z } from "zod"
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
import { described, TOOL_SPECS } from "../harness.ts"
import { TEAM } from "../team/team.ts"
import { fillPrompt } from "../prompt/fill.ts"
function teamOf(ctx: ToolContext) {
if (!ctx.team) throw new ToolError(TOOL_SPECS.team_add!.unavailable ?? TEAM.unavailable.one_agent)
return ctx.team
}
/** The spec's own answer when a name does not parse (team.md §4's order: the requirement, `all`,
* then the pattern), before the generic one for invalid arguments. */
function badName(raw: unknown, ctx: ToolContext): string | undefined {
const name = (raw as { name?: unknown } | undefined)?.name
if (typeof name !== "string") return undefined
const no = ctx.team ? (ctx.team.runtime.team.offered ? ctx.team.runtime.team.unavailable() : TEAM.unavailable.one_agent) : TEAM.unavailable.one_agent
if (no) return no
if (name === "all") return TEAM.texts.all_name
if (!new RegExp(TEAM.names.pattern).test(name)) return fillPrompt(TEAM.texts.bad_name, { name })
return undefined
}
export const teamAddTool = defineTool({
...described("team_add"),
purpose: false,
access: "team_add",
requires: "team",
schema: z.object({
name: z.string().regex(new RegExp(TEAM.names.pattern)),
role: z.string().min(1),
task: z.string().min(1),
goal: z.string().optional(),
lead: z.boolean().optional(),
agent: z.string().optional(),
model: z.string().optional(),
}),
invalid: badName,
permission: (a) => ({ permission: "team_add", class: "interact", patterns: [a.agent ?? "general"] }),
async run(a, ctx) {
const t = teamOf(ctx)
const r = t.runtime.add(t.as, a)
return { output: r.text, title: r.added ? `${a.name}: ${a.role}` : "not added", isError: !r.added }
},
})
export const teamSendTool = defineTool({
...described("team_send"),
purpose: false,
access: "team_send",
requires: "team",
schema: z.object({ to: z.array(z.string()).min(1), message: z.string().min(1) }),
permission: () => ({ permission: "team_send", class: "interact", patterns: ["*"] }),
async run(a, ctx) {
const t = teamOf(ctx)
const before = t.runtime.team.room.length
const output = t.runtime.team.send(t.as, a)
t.runtime.notify()
const sent = t.runtime.team.room.length > before
return { output, title: sent ? `to ${a.to.includes("all") ? "all" : [...new Set(a.to)].join(", ")}` : "not sent", isError: !sent }
},
})
export const teamReadTool = defineTool({
...described("team_read"),
purpose: false,
access: "team_read",
requires: "team",
schema: z.object({ since: z.number().int().min(0).optional() }),
permission: () => ({ permission: "team_read", class: "read", patterns: ["*"] }),
async run(a, ctx) {
const t = teamOf(ctx)
return { output: t.runtime.team.read(t.as, a.since ?? 0), title: `team${a.since ? ` since #${a.since}` : ""}` }
},
})
export const teamStopTool = defineTool({
...described("team_stop"),
purpose: false,
access: "team_stop",
requires: "team",
schema: z.object({ name: z.string().min(1) }),
permission: (a) => ({ permission: "team_stop", class: "interact", patterns: [a.name] }),
async run(a, ctx) {
const t = teamOf(ctx)
const size = t.runtime.team.size()
const output = t.runtime.team.stopCmd(t.as, a.name)
t.runtime.notify()
return { output, title: t.runtime.team.size() < size ? `stopped ${a.name}` : "not stopped", isError: t.runtime.team.size() >= size }
},
})
+11 -1
View File
@@ -36,6 +36,9 @@ export interface ToolContext {
* (the TUI's ctrl+b): bash hands it to the registry as it would at its timeout. Set by the
* engine for the step's calls. */
backgroundNow?: AbortSignal
/** The team this agent is on (spec 3.2.0, team/runtime.ts) and its name there (`lead`, or a
* member's). Absent where no team is offered. */
team?: { runtime: import("../team/runtime.ts").TeamRuntime; as: string }
/** The id of the call being run (set by the engine per call). */
callId?: string
/** The project directory (.agent), when the project has one. */
@@ -91,12 +94,16 @@ export interface SpawnOptions {
description?: string
model?: string
background?: boolean
/** Spec 3.2.0: a team member (team.md §4) — run as one, not as a task helper. */
member?: import("../team/team.ts").TeamAgent
}
/** What the task tool gets back from spawn: the job's id and the report when it ends. */
export interface SubagentHandleInfo {
id: string
done: Promise<import("./task.ts").SubagentResult>
/** A team member's engine, for the team's runtime to drive. */
member?: import("../team/runtime.ts").MemberHandle
}
export interface ToolResult {
@@ -124,7 +131,7 @@ export interface Tool<S extends z.ZodType = z.ZodType> {
* display`). The tool list is the session's (harness context.md §10): one the session could
* never meet leaves the tool out; one that comes and goes (plan mode, a display) keeps it in, and
* a call while it is unmet answers the spec's `unavailable` text. */
requires?: "vision" | "plan" | "project" | "knowledge" | "display"
requires?: "vision" | "plan" | "project" | "knowledge" | "display" | "team"
description: string
schema: S
/** The JSON Schema sent to the model, when it does not come from `schema` (MCP tools). */
@@ -136,6 +143,9 @@ export interface Tool<S extends z.ZodType = z.ZodType> {
* `purpose` argument added to every tool (the default), a field of the tool's own (bash's
* `description`), or false for a tool that never asks. */
purpose?: string | false
/** What a call answers when its arguments do not parse, where the spec has its own words for it
* (team_add's name); undefined: the generic answer. */
invalid?(raw: unknown, ctx: ToolContext): string | undefined
permission(args: z.infer<S>, ctx: ToolContext): PermissionRequest
/** What the call would change, as a unified diff, shown when the user is asked. Writes nothing. */
preview?(args: z.infer<S>, ctx: ToolContext): Promise<string>
+5 -2
View File
@@ -5,6 +5,7 @@ import { stricterMode, type App as Runtime } from "../app.ts"
import { isLocal } from "../config/paths.ts"
import type { Mode } from "../config/schema.ts"
import { levelLabel, type Level } from "../provider/effort.ts"
import { teamLabel } from "../session/commands.ts"
import { COMMANDS, findCommand, type CommandCtx } from "./commands.ts"
import { Dialog, type DialogSpec } from "./components/dialog.tsx"
import { Messages } from "./components/messages.tsx"
@@ -60,6 +61,8 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
const mode = () => (rev(), app.engine.mode)
const model = () => (rev(), app.engine.model.ref)
const effort = () => (rev(), levelLabel(app.engine.model, app.engine.effort))
// "team 3/4 · 2 working" while the lead has members (team.md, the TUI's status).
const teamWord = () => (rev(), teamLabel(app.team.team))
const busy = () => view.state.phase !== "idle"
const branch = () => (rev(), app.project.gitRoot ? currentBranch(app.project.gitRoot) : undefined)
// A word in the status bar that goes by itself (a copy): it says something happened without
@@ -74,7 +77,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
onCleanup(() => clearTimeout(flashTimer))
// A mode changed inside a turn (a plan approved) must reach the status bar; so must a branch the
// model switched with git.
app.bus.on((e) => (e.type === "mode" || e.type === "done") && refresh())
app.bus.on((e) => (e.type === "mode" || e.type === "done" || e.type === "team") && refresh())
app.bus.on((e) => e.type === "flash" && flash(e.text, 6000))
// A setting changed by the agent, /settings or a command: show it. Theme and icons are drawn here.
app.bus.on((e) => {
@@ -642,7 +645,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
</Show>
<Queued />
<Prompt focused={() => !dialog() && !jobPanel() && !view.state.pending && !view.state.question && !view.state.plan} busy={busy} commands={palette} files={files} onSubmit={submit} ref={(r) => (input = r)} queue={queueAccess} onWheel={(dir, lines) => scroll?.scrollBy(dir * lines)} onBackground={backgroundNow} />
<StatusBar mode={mode} model={model} effort={effort} branch={branch()} voice={voiceStatus} flash={flashText} remote={() => (rev(), share.label())} spend={spend} />
<StatusBar mode={mode} model={model} effort={effort} branch={branch()} voice={voiceStatus} flash={flashText} remote={() => (rev(), share.label())} spend={spend} team={teamWord} />
</box>
<Show when={dialog()}>
<Dialog spec={dialog()!} close={() => setDialog(undefined)} />
+53 -5
View File
@@ -11,7 +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 { changelogPrompt, initPrompt, reviewPrompt } from "../session/commands.ts"
import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/commands.ts"
import { git } from "../git/run.ts"
import { branches, deleteBranch, switchBranch } from "../git/branch.ts"
import type { Ctx } from "./context.ts"
@@ -94,6 +94,27 @@ You are the ${name} subagent. Say what to do, how to check it, and what to repor
report is all the session's model sees of your work.
`
const TEAM_ROW = "\u0000team"
/** The team's size for this session: a few usual pairs, or any typed as `<min> <max>`. */
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"],
] as const
const seen = new Set<string>()
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 } })
}
export const COMMANDS: Command[] = [
{
name: "agents",
@@ -194,24 +215,51 @@ export const COMMANDS: Command[] = [
c.setEffort(v as Level)
c.view.info(control === "switch" ? `thinking ${v === "off" ? "off" : `on (${v})`}` : `effort: ${v}`)
}
if (control === "none") return c.view.info(`${m.ref} has one level (${levels[0]}): there is nothing to choose`)
// The team's size sits beside the effort (team.md §2: shown only where a team is 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 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)
if (control === "none") return c.openDialog({ title: `Chat settings — ${m.ref}`, options: agentsRow, onSelect: choose })
if (control === "switch")
return c.openDialog({
title: `Thinking — ${m.ref}`,
options: [
{ label: "on", value: on, description: on === "default" ? "the model's own" : on, current: c.app.engine.effort !== "off" },
{ label: "off", value: "off", current: c.app.engine.effort === "off" },
...agentsRow,
],
onSelect: set,
onSelect: choose,
})
c.openDialog({
title: `Effort — ${m.ref}`,
options: levels.map((e) => ({ label: e === "off" ? "off (no thinking)" : e, value: e, current: c.app.engine.effort === e })),
onSelect: set,
options: [...levels.map((e) => ({ label: e === "off" ? "off (no thinking)" : e, value: e, current: c.app.engine.effort === e })), ...agentsRow],
onSelect: choose,
})
},
},
{
name: "team",
args: "[size [<min> <max>] | stop [<name>]]",
description: "the team: its agents and the room; its size; stop a member",
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)
if (words[0] === "size" && words.length === 1) return openTeamSize(c)
if (words[0] === "stop" && words.length === 1) {
const members = c.app.team.team.tree().filter((a) => a.manager !== null)
if (!members.length) return c.view.info("the team has no members")
return c.openDialog({
title: "Stop a member, and everything under it",
options: members.map((a) => ({ label: a.name, value: a.name, description: `${a.role} · ${a.state}` })),
onSelect: (name) => (c.view.info(teamText(c.app, `stop ${name}`)), c.refresh()),
})
}
c.view.info(teamText(c.app, arg))
c.refresh()
},
},
{
name: "mode",
args: `[${MODES.join("|")}]`,
+2 -1
View File
@@ -22,7 +22,7 @@ export function jobsWord(jobs: { state: string; unread: number; unseen?: boolean
* for a moment, a flash (a copy) in their place. The jobs word sits on the right, which is never
* truncated: at 80 columns a long project name cuts the left short, and a running job is the one
* thing that must stay visible. */
export function StatusBar(p: { mode: () => string; model: () => string; effort: () => string; branch?: string; voice?: () => string; flash?: () => string | undefined; remote?: () => string; spend?: () => string }) {
export function StatusBar(p: { mode: () => string; model: () => string; effort: () => string; branch?: string; voice?: () => string; flash?: () => string | undefined; remote?: () => string; spend?: () => string; team?: () => string }) {
const { theme, view, app } = useTui()
const c = () => theme().colors
const modeColor = () => ({ manual: c().statusText, edit: c().statusGood, auto: c().statusCritical, plan: c().info })[p.mode() as keyof typeof MODE_LABEL] ?? c().statusText
@@ -50,6 +50,7 @@ export function StatusBar(p: { mode: () => string; model: () => string; effort:
when={p.flash?.()}
fallback={
<text wrapMode="none">
<span style={{ fg: c().info }}>{p.team?.() ? `${p.team()} · ` : ""}</span>
<span style={{ fg: jobsWord(view.state.jobs).endsWith("!") ? c().statusWarn : c().statusText }}>{jobsWord(view.state.jobs) ? `${jobsWord(view.state.jobs)} · ` : ""}</span>
<span style={{ fg: c().statusDim }}>{view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""}</span>
<span style={{ fg: c().statusText }}>{p.spend?.() ? `${p.spend()} · ` : ""}</span>
+1 -1
View File
@@ -54,7 +54,7 @@ describe("end to end", () => {
expect(fake!.requests[0].messages[0].content).toContain("Permission mode: edit")
// knowledge_search/get are offered once any knowledge base has documents, and the suite shares
// one LEMBAS_HOME: another file's base must not decide this list (found in the full run).
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "job_send", "web_search", "web_fetch", "task", "plan_write", "plan_update", "ask_user", "plan_submit", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings", "recall"])
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "job_send", "web_search", "web_fetch", "task", "team_add", "team_send", "team_read", "team_stop", "plan_write", "plan_update", "ask_user", "plan_submit", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings", "recall"])
const ends = events.filter((e) => e.type === "tool_end")
expect(ends.map((e) => e.type === "tool_end" && e.result.title)).toEqual(["hello.txt · 1–1 of 1", "hello.txt +1 −1"])
const sid = store.sessions(1)[0]!.id
+4 -2
View File
@@ -16,7 +16,9 @@ export interface Fake {
/** `served`: what GET /v1/models lists, as a LLeMbas server lists its models (default two
* placeholders). Any other GET — the instance's own description, its personalization — is a 404,
* which a client reads as "this server does not have it". */
export function fakeProvider(script: Scripted[], opts: { route?: (model: string) => Fake | undefined; served?: Record<string, unknown>[] } = {}): Fake {
/** `script` may be a function of the request (and its number), for requests that race: a team's
* agents ask at once, and each is answered by who is asking. */
export function fakeProvider(script: Scripted[] | ((body: any, n: number) => Scripted | undefined), opts: { route?: (model: string) => Fake | undefined; served?: Record<string, unknown>[] } = {}): Fake {
const requests: any[] = []
const calls: { path: string; headers: Record<string, string> }[] = []
let i = 0
@@ -34,7 +36,7 @@ export function fakeProvider(script: Scripted[], opts: { route?: (model: string)
if (to) return fetch(to.url.replace(/\/v1$/, "") + url.pathname + url.search, { method: "POST", headers: req.headers, body: JSON.stringify(body) })
requests.push(body)
calls.push({ path: url.pathname + url.search, headers: Object.fromEntries(req.headers.entries()) })
const r = script[i++]
const r = typeof script === "function" ? script(body, i++) : script[i++]
if (!r) return new Response("script exhausted", { status: 500 })
if ("status" in r) return new Response(r.body, { status: r.status })
// gapMs: a pause between chunks, to look at the screen mid-stream.
+4 -2
View File
@@ -52,7 +52,8 @@ test("no clock in the prompt: two requests across a date change send the same sy
expect(system(1)).toBe(system(0))
expect(system(2)).toBe(system(0))
expect(fake!.requests[2].tools).toEqual(fake!.requests[0].tools)
expect(lastUser(0)).toBe(`first\n${NOTE.open}\nToday is 2026-11-04.\n${NOTE.close}`)
// A model with no capacity said (a hosted API) offers a team: its size is said once, last (spec 3.2.0).
expect(lastUser(0)).toBe(`first\n${NOTE.open}\nToday is 2026-11-04.\n\nThe team may have 1 to 4 agents, you included.\n${NOTE.close}`)
expect(lastUser(1)).toBe("second")
expect(lastUser(2)).toBe(`third\n${NOTE.open}\nToday is 2026-11-05.\n${NOTE.close}`)
// A note is part of its message from then on: the next request sends the first one as it was.
@@ -140,7 +141,8 @@ test("compaction: the same system and tools, every message, then the compaction
expect(store.requests(sid).map((r) => r.kind)).toEqual(["step", "compaction"])
// After it the prompt is made again, and the next user message says the date again.
await app.engine.prompt("go on")
expect(lastUser(2)).toMatch(/^go on\n<session-note>\nToday is \d{4}-\d{2}-\d{2}\.\n<\/session-note>$/)
// The team's size too (spec 3.2.0): like the date, it is not in the prompt.
expect(lastUser(2)).toMatch(/^go on\n<session-note>\nToday is \d{4}-\d{2}-\d{2}\.\n\nThe team may have 1 to 4 agents, you included\.\n<\/session-note>$/)
})
test("a resumed session sends the prompt it kept, byte for byte; what changed meanwhile is a note", async () => {
+148
View File
@@ -0,0 +1,148 @@
// Teams (harness spec 3.2.0, team.md) through the whole app: the lead adds a member that runs on the
// subagent machinery, its tools that change things answer the manager's refusal before anything is
// asked, the member's final reply comes back as a marked report that wakes the idle lead, and the
// size setting is said in a note. The rules themselves are conformance/team.json's.
import { afterEach, describe, expect, test } from "bun:test"
import { mkdtempSync, realpathSync, writeFileSync, mkdirSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createApp, type App } from "../src/app.ts"
import type { AskReply } from "../src/bus/index.ts"
import { paths } from "../src/config/paths.ts"
import { LOOP } from "../src/harness.ts"
import { teamLabel, teamText } from "../src/session/commands.ts"
import { delta, fakeProvider, toolCall, type Fake, type Scripted } from "./fake-provider.ts"
let fake: Fake | undefined
let app: App | undefined
afterEach(async () => {
await app?.close()
app = undefined
fake?.stop()
})
const T = LOOP.team
const yes = { ask: async (): Promise<AskReply> => ({ kind: "once" }) }
const say = (text: string): Scripted => ({ chunks: [delta({ content: text }, "stop")] })
const call = (id: string, name: string, args: unknown): Scripted => ({ chunks: [toolCall(0, id, name, JSON.stringify(args)), delta({}, "tool_calls")] })
const system = (body: any): string => body.messages[0].content
const isMember = (body: any, name: string) => system(body).includes(`You are "${name}", a member of a team`)
const lastUser = (body: any): string => {
const u = [...body.messages].reverse().find((m: any) => m.role === "user")
return typeof u.content === "string" ? u.content : u.content.map((p: any) => p.text ?? "").join("")
}
// A result as the model reads it, less its id line (context.md §1).
const toolResults = (body: any): string[] => body.messages.filter((m: any) => m.role === "tool").map((m: any) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content)).replace(/^\[#\d+\]\n/, ""))
function setup(answer: (body: any, n: number) => Scripted | undefined, config = "", spec = "{ context: 32768, parallel: 2 }") {
fake = fakeProvider(answer)
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n fake:\n type: webui\n url: ${fake.url}\n models:\n fake/m: ${spec}\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), `model: fake/m\n${config}`)
const dir = realpathSync(mkdtempSync(join(tmpdir(), "lembas-team-")))
Bun.spawnSync(["git", "init", "-q", dir])
return dir
}
async function until(what: () => boolean, ms = 10000) {
const end = Date.now() + ms
while (!what()) {
if (Date.now() > end) throw new Error("timed out")
await Bun.sleep(20)
}
}
describe("a team at work (team.md)", () => {
test("the lead adds a member, is refused work, and is woken by the member's report", async () => {
let lead = 0
const dir = setup((body) => {
if (isMember(body, "parser")) return say("The parser is done: src/parse.ts.")
const n = lead++
if (n === 0) return call("a1", "team_add", { name: "parser", role: "the parser", task: "Write the parser; report the file.", goal: "a calculator" })
if (n === 1) return call("w1", "write", { path: "x.txt", content: "x" })
return say(n === 2 ? "parser is on it." : "All done.")
})
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
await app.engine.prompt("build a calculator")
// The lead's report arrives — at a step boundary, or as a turn of its own when it was idle.
await until(() => fake!.requests.some((r) => !isMember(r, "parser") && r.messages.some((m: any) => m.role === "user" && JSON.stringify(m.content).includes("[Team report from parser to lead:]"))))
await until(() => !app!.engine.busy && app!.team.team.get("lead")!.state === "idle")
const first = fake!.requests[0]
expect(first.tools.map((t: any) => t.function.name)).toEqual(expect.arrayContaining(["team_add", "team_send", "team_read", "team_stop"]))
expect(system(first)).toContain("team_add")
// The size is said once, in the note on the first message.
expect(lastUser(first)).toContain(LOOP.context.note.team.replace("{{min}}", "1").replace("{{max}}", "4"))
const leads = fake!.requests.filter((r) => !isMember(r, "parser"))
const results = toolResults(leads[leads.length - 1])
expect(results[0]).toContain("Added parser (the parser)")
expect(results[1]).toContain(T.texts.manager)
// The member: its own prompt, its brief as its first message, and the team tools a member has.
const member = fake!.requests.find((r) => isMember(r, "parser"))!
expect(system(member)).toContain("The team's goal: a calculator")
expect(system(member)).toContain('you report to "lead"')
expect(lastUser(member)).toContain("Write the parser; report the file.")
const memberTools = member.tools.map((t: any) => t.function.name)
expect(memberTools).toEqual(expect.arrayContaining(["team_send", "team_read"]))
expect(memberTools).not.toContain("team_add")
expect(memberTools).not.toContain("task")
// The room has the report; the lead manages the finished member; nothing was written.
const t = app.team.team
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(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.
expect(app.jobs().every((j) => j.team === "parser")).toBe(true)
})
test("max 1: the tools are listed but answer one_agent, and task too; the note says to work alone", async () => {
let n = 0
const dir = setup(() => (n++ === 0 ? call("a1", "team_add", { name: "x", role: "r", task: "t", goal: "g" }) : n === 2 ? call("t1", "task", { description: "d", prompt: "p" }) : say("alone")), "agents: { min: 1, max: 1 }\n")
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
await app.engine.prompt("go")
expect(lastUser(fake!.requests[0])).toContain(LOOP.context.note.team_one)
const results = toolResults(fake!.requests[2])
expect(results).toEqual([T.unavailable.one_agent, T.unavailable.one_agent])
})
test("min 2: the lead is a manager from the start, and the note asks for members", async () => {
let n = 0
const dir = setup(() => (n++ === 0 ? call("e1", "bash", { command: "touch made.txt" }) : say("ok")), "agents: { min: 2, max: 4 }\n")
app = createApp({ cwd: dir, mode: "auto", asker: yes, store: false })
await app.engine.prompt("go")
expect(lastUser(fake!.requests[0])).toContain(LOOP.context.note.team_lead.replace("{{members}}", "1"))
expect(toolResults(fake!.requests[1])).toEqual([T.texts.manager])
expect(await Bun.file(join(dir, "made.txt")).exists()).toBe(false)
})
test("a model that serves one request at a time offers no team: no team tools, /team says why", async () => {
const dir = setup(() => say("ok"), "", "{ context: 32768, parallel: 1 }")
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
await app.engine.prompt("go")
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => n.startsWith("team_"))).toEqual([])
expect(lastUser(fake!.requests[0])).not.toContain("The team may have")
expect(teamText(app, "")).toContain("serves one request at a time")
})
test("the size is refused outside its bounds, whole, and a change is said in the next note", async () => {
let n = 0
const dir = setup(() => say(`ok ${n++}`))
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
await app.engine.prompt("one")
expect(teamText(app, "size 3 2")).toContain("1 ≤ min ≤ max ≤ 32")
expect(teamText(app, "size 1 40")).toContain("1 ≤ min ≤ max ≤ 32")
expect([app.team.team.min, app.team.team.max]).toEqual([1, 4])
expect(teamText(app, "size 2 6")).toContain("2 to 6")
expect(app.settings.get("agents.max").value).toBe(6)
await app.engine.prompt("two")
expect(lastUser(fake!.requests[1])).toContain("The team may have 2 to 6 agents, you included.")
await app.engine.prompt("three")
expect(lastUser(fake!.requests[2])).toBe("three")
})
})