From 6ec95fbd666868ff106d2d16f038574a07c2479a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Mon, 9 Nov 2026 09:08:52 +0000 Subject: [PATCH] Teams in the CLI (harness spec 3.2.0, team.md) 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 ": ". - 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 --- src/app.ts | 151 ++++++++++++++++++++++++-- src/bus/index.ts | 3 + src/jobs/registry.ts | 3 + src/prompt/assemble.ts | 23 +++- src/session/commands.ts | 48 ++++++++- src/session/engine.ts | 50 ++++++++- src/session/prefix.ts | 6 +- src/team/runtime.ts | 197 ++++++++++++++++++++++++++++++++++ src/team/team.ts | 40 ++++--- src/tool/job_tools.ts | 14 ++- src/tool/names.ts | 2 + src/tool/registry.ts | 6 ++ src/tool/team_tools.ts | 94 ++++++++++++++++ src/tool/tool.ts | 12 ++- src/tui/app.tsx | 7 +- src/tui/commands.ts | 58 +++++++++- src/tui/components/status.tsx | 3 +- tests/e2e.test.ts | 2 +- tests/fake-provider.ts | 6 +- tests/prefix.test.ts | 6 +- tests/team.test.ts | 148 +++++++++++++++++++++++++ 21 files changed, 834 insertions(+), 45 deletions(-) create mode 100644 src/team/runtime.ts create mode 100644 src/tool/team_tools.ts create mode 100644 tests/team.test.ts diff --git a/src/app.ts b/src/app.ts index dd0d56a..d1bcb95 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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> = { 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) => { + 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() 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((x, k) => (x && typeof x === "object" ? (x as Record)[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 = "" diff --git a/src/bus/index.ts b/src/bus/index.ts index 371323b..5a8ad2f 100644 --- a/src/bus/index.ts +++ b/src/bus/index.ts @@ -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 diff --git a/src/jobs/registry.ts b/src/jobs/registry.ts index 1bac15e..5d9e1b5 100644 --- a/src/jobs/registry.ts +++ b/src/jobs/registry.ts @@ -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 diff --git a/src/prompt/assemble.ts b/src/prompt/assemble.ts index 8a6eed8..13cd630 100644 --- a/src/prompt/assemble.ts +++ b/src/prompt/assemble.ts @@ -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, diff --git a/src/session/commands.ts b/src/session/commands.ts index ccc9ebe..9728dec 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -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 | stop " }, ] +/** 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 | stop ]`: 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 — 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 — 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 ); /team stop 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 ` ` 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 } diff --git a/src/session/engine.ts b/src/session/engine.ts index ab63ef7..502f2e2 100644 --- a/src/session/engine.ts +++ b/src/session/engine.ts @@ -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 + } } /** 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 diff --git a/src/session/prefix.ts b/src/session/prefix.ts index 4579ba8..9b2fa1b 100644 --- a/src/session/prefix.ts +++ b/src/session/prefix.ts @@ -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/.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) => 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 diff --git a/src/team/runtime.ts b/src/team/runtime.ts new file mode 100644 index 0000000..688ad4a --- /dev/null +++ b/src/team/runtime.ts @@ -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() + /** 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 { + 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((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) + } +} diff --git a/src/team/team.ts b/src/team/team.ts index 4995dd2..04b70bd 100644 --- a/src/team/team.ts +++ b/src/team/team.ts @@ -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 diff --git a/src/tool/job_tools.ts b/src/tool/job_tools.ts index cdf3685..0d699a7 100644 --- a/src/tool/job_tools.ts +++ b/src/tool/job_tools.ts @@ -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.") diff --git a/src/tool/names.ts b/src/tool/names.ts index 0a60d35..80dc6a3 100644 --- a/src/tool/names.ts +++ b/src/tool/names.ts @@ -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) } diff --git a/src/tool/registry.ts b/src/tool/registry.ts index b0289b5..632d329 100644 --- a/src/tool/registry.ts +++ b/src/tool/registry.ts @@ -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, diff --git a/src/tool/team_tools.ts b/src/tool/team_tools.ts new file mode 100644 index 0000000..35441c1 --- /dev/null +++ b/src/tool/team_tools.ts @@ -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 } + }, +}) diff --git a/src/tool/tool.ts b/src/tool/tool.ts index 02400b5..b0ace51 100644 --- a/src/tool/tool.ts +++ b/src/tool/tool.ts @@ -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 + /** 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 { * 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 { * `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, ctx: ToolContext): PermissionRequest /** What the call would change, as a unified diff, shown when the user is asked. Writes nothing. */ preview?(args: z.infer, ctx: ToolContext): Promise diff --git a/src/tui/app.tsx b/src/tui/app.tsx index e6335e1..4d7cfc1 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -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: () => !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} /> - (rev(), share.label())} spend={spend} /> + (rev(), share.label())} spend={spend} team={teamWord} /> setDialog(undefined)} /> diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 0df9e23..14fc0f3 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -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 ` `. */ +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() + 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 [ ] | stop []]", + 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("|")}]`, diff --git a/src/tui/components/status.tsx b/src/tui/components/status.tsx index a84d358..efa5791 100644 --- a/src/tui/components/status.tsx +++ b/src/tui/components/status.tsx @@ -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={ + {p.team?.() ? `${p.team()} · ` : ""} {jobsWord(view.state.jobs) ? `${jobsWord(view.state.jobs)} · ` : ""} {view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""} {p.spend?.() ? `${p.spend()} · ` : ""} diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index 2012f32..49eb64e 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -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 diff --git a/tests/fake-provider.ts b/tests/fake-provider.ts index 251b4b0..376788c 100644 --- a/tests/fake-provider.ts +++ b/tests/fake-provider.ts @@ -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[] } = {}): 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[] } = {}): Fake { const requests: any[] = [] const calls: { path: string; headers: Record }[] = [] 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. diff --git a/tests/prefix.test.ts b/tests/prefix.test.ts index 75cc34f..923a5e4 100644 --- a/tests/prefix.test.ts +++ b/tests/prefix.test.ts @@ -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\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\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 () => { diff --git a/tests/team.test.ts b/tests/team.test.ts new file mode 100644 index 0000000..7b7352e --- /dev/null +++ b/tests/team.test.ts @@ -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 => ({ 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") + }) +})