From 8c5edc0f01fb60317b03e8cb3f662a8897b18e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Tue, 10 Nov 2026 08:21:29 +0000 Subject: [PATCH] Subagents v2 in the CLI (harness spec 3.4.0): the main agent's tree, modes and requests, findings, finishing and running out of context; progress phases team/team.ts: ids, positions, parents; only the main agent adds, assigns, replaces, stops and decides; sub-managers; slots; modes inherited and never widened, request options and grants; findings; team_finish; pause and out_of_context; ended subagents kept; the tree's data. Seven new tools. A subagent's call is decided by team/delegation.ts (itself, the main agent through a request, or the person). Its engine does not compact: at pause_at it is paused and gets one final turn. Every engine says its progress as phase events (session/progress.ts). Co-Authored-By: Claude Opus 5.5 --- src/app.ts | 65 ++++-- src/bus/index.ts | 5 +- src/harness.ts | 14 +- src/prompt/assemble.ts | 12 +- src/provider/common.ts | 3 +- src/provider/types.ts | 2 +- src/session/engine.ts | 66 +++++- src/session/progress.ts | 136 ++++++++++++ src/team/runtime.ts | 103 ++++++++- src/team/team.ts | 447 +++++++++++++++++++++++++++++++++++++--- src/tool/registry.ts | 10 +- src/tool/team_tools.ts | 118 ++++++++++- tests/conformance.ts | 41 +++- tests/harness.test.ts | 2 +- tests/team.test.ts | 4 +- 15 files changed, 951 insertions(+), 77 deletions(-) diff --git a/src/app.ts b/src/app.ts index f58a4fc..4ffee9d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -46,7 +46,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 { memberTools as memberToolsOf, readingBashByDefault, Team, TEAM, TEAM_TOOLS, type Capacity, type TeamAgent } from "./team/team.ts" +import { whoDecides } from "./team/delegation.ts" import { TeamRuntime, type MemberHandle } from "./team/runtime.ts" export interface AppOptions { @@ -210,6 +211,8 @@ function modelInfo(m: ResolvedModel): ModelInfo { /** Tools the spec offers to subagents only (`offered: subagent`): never a session's own model's. */ export const SUBAGENT_ONLY = new Set(Object.values(TOOL_SPECS).filter((t) => t.offered === "subagent").map((t) => t.name)) +/** Spec 3.4.0: tools the spec offers to the main agent only (`offered: main`): never a subagent's. */ +export const MAIN_ONLY_TOOLS = new Set(Object.values(TOOL_SPECS).filter((t) => t.offered === "main").map((t) => t.name)) const MODE_RANK: Record = { auto: 0, edit: 1, manual: 2, plan: 3 } export function stricterMode(a: Mode | undefined, b: Mode): Mode { @@ -370,7 +373,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, member?: { name: string; manager: string; role: string; goal: string; lead: boolean }) => { + 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?: { id: string; name: string; manager: string; role: string; goal: string; lead: boolean }) => { const src = ch.source as Source return assembleSystem({ modelRef: current.ref, @@ -523,13 +526,17 @@ export function createApp(o: AppOptions): App { } : perm // `report` is every subagent's: its way of speaking to the main agent before its final reply. - // A team member also has team_send and team_read, and one that may lead team_add and team_stop - // (team.md §4); a task subagent has none of them. - const memberTools = member ? (member.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"]) : [] + // A subagent of the tree also has its team tools (spec 3.4.0 §8: whoami, read, send, findings, + // finish, and request with request options) — never the main agent's (`offered: main`); a task + // subagent has none of them. + const memberTools = member ? memberToolsOf(member) : [] + const isTeamTool = (n: string) => (TEAM_TOOLS as readonly string[]).includes(n) 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)), + ...tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && !MAIN_ONLY_TOOLS.has(t.name) && (!isTeamTool(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) && (!isTeamTool(t.name) || memberTools.includes(t.name))), ] + // Its mode: the one the main agent gave it, made stricter by its agent's own (explore reads). + if (member) member.mode = stricterMode(spec.mode, member.mode) 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) @@ -555,9 +562,9 @@ export function createApp(o: AppOptions): App { tools: childTools, store, sessionId: childRow?.id, - // An agent's own mode can make it stricter than its parent, never looser. - // A team member's is no looser than its manager's (team.md §4): a sub-manager's own mode. - mode: stricterMode(spec.mode, (member?.manager && member.manager !== TEAM.names.lead ? teamRt.handle(member.manager)?.engine.mode : undefined) ?? engine.mode), + // An agent's own mode can make it stricter than its parent, never looser. A subagent of the + // tree runs in the mode the main agent gave it (spec 3.4.0 §7), set above. + mode: member ? member.mode : stricterMode(spec.mode, engine.mode), effort: m.ref === engine.model.ref ? engine.effort : startEffort(m), maxSteps: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default, compaction, @@ -574,6 +581,13 @@ export function createApp(o: AppOptions): App { fix: () => teamRt.team.offered, offered: () => teamRt.team.offered, refuse: (tool: Tool, args: unknown) => teamRt.refuse(member.name, tool, args), + // Spec 3.4.0 §7: itself, the main agent through a request, or the person. + permit: (request, ctx) => { + const a = teamRt.team.get(member.name) + return whoDecides(request, { ctx, mode: a?.mode ?? member.mode, main: engine.mode, request: a?.request ?? [], grants: a?.grants ?? [], unattended: o.unattended }) + }, + contextFull: (used: number, window: number) => (window ? teamRt.contextFull(member.name) : "go"), + ended: () => teamRt.hasEnded(member.name), }, } : {}), @@ -596,7 +610,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, + member ? { id: member.id, 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 @@ -619,7 +633,7 @@ export function createApp(o: AppOptions): App { const id = teamRt.team.taskStart(TEAM.names.lead, m.ref) return p.finally(() => teamRt.team.taskDone(id)) } - const run = (text: string, machine = false) => counted(child.prompt(text, [], text, machine ? { machine: true } : {}).then(finish, (e) => (job.lastError = (e as Error).message, finish("error")))) + const run = (text: string, machine = false, steps?: number) => counted(child.prompt(text, [], text, { ...(machine ? { machine: true as const } : {}), ...(steps ? { maxSteps: steps } : {}) }).then(finish, (e) => (job.lastError = (e as Error).message, finish("error")))) const handle: SubagentHandle = { bus: childBus, engine: child, @@ -631,15 +645,25 @@ export function createApp(o: AppOptions): App { 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) => { + // Its progress and context use, for the tree (spec 3.4.0 §12). + childBus.on((e) => { + if (e.type === "phase") { + teamRt.team.setProgress(member.name, e.progress) + teamRt.notify() + } else if (e.type === "usage" && !e.child && e.context) { + teamRt.team.setContext(member.name, e.used ?? e.usage.input + e.usage.output, e.context) + teamRt.notify() + } + }) + const memberRun = (text: string, machine: boolean, steps?: number) => { if (job.state !== "running") registry.restartSubagent(job) - void run(text, machine).then((r) => { + void run(text, machine, steps).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() } + const mh: MemberHandle = { engine: child, run: (text) => memberRun(text, true), cancel: () => child.cancel(), final: (text) => memberRun(text, true, TEAM.context.final_steps) } teamRt.attach(member.name, mh) memberRun(prompt, false) return { id: job.id, done: Promise.resolve({ text: "", steps: 0, tools: 0, reason: "stop" }), member: mh } @@ -713,6 +737,8 @@ export function createApp(o: AppOptions): App { models: usableModels, leadState: "idle", readingBash: (command) => readingBashByDefault(command, project.root), + mode: engine.mode, + steps: { main: loaded.config.limits?.steps ?? LOOP.steps.default, sub: loaded.config.limits?.subagent_steps ?? LOOP.jobs.subagent_steps.default }, }) const teamRt: TeamRuntime = new TeamRuntime({ team: newTeam(), @@ -726,6 +752,7 @@ export function createApp(o: AppOptions): App { }) }, lead: { note: (text) => engine.note(text), wake: (text) => wake(text, shownLine(text)) }, + tools: () => tools, 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 { @@ -740,6 +767,14 @@ export function createApp(o: AppOptions): App { }) // The lead's turn ended: it is idle, and its slot frees (team.md §8). bus.on((e) => e.type === "done" && teamRt.leadDone()) + // Spec 3.4.0: the main agent's mode reaches down the tree; its progress and context are in it. + bus.on((e) => { + if (e.type === "mode") teamRt.mainMode(e.mode) + else if (e.type === "phase") { + teamRt.team.setProgress(TEAM.names.lead, e.progress) + if (teamRt.team.hasMembers()) teamRt.notify() + } else if (e.type === "usage" && !e.child && e.context) teamRt.team.setContext(TEAM.names.lead, e.used ?? e.usage.input + e.usage.output, e.context) + }) // MCP tools join the engine's list as servers connect (the engine reads it at each prompt). let mcpNames = new Set() diff --git a/src/bus/index.ts b/src/bus/index.ts index 5a8ad2f..d27755f 100644 --- a/src/bus/index.ts +++ b/src/bus/index.ts @@ -47,7 +47,7 @@ export type Event = | { type: "steered"; texts: string[]; ids?: string[] } /** A task — one prompt, with every step and tool it takes — starts or ends; `turnId` as on `prompt`. */ | { type: "task"; state: "start" | "end"; turnId?: string } - | { type: "notice"; message: string; level?: "info" | "warn" } + | { type: "notice"; message: string; level?: "info" | "warn"; /** Spec 3.4.0: a request that will be sent again. */ retry?: { reason: string; in: number; attempt?: number } } /** Discard the reply streamed so far in this step: it is being retried. */ | { type: "retract" } /** The session's plan was written, changed or cleared (plan_write, plan_update, /new). */ @@ -65,6 +65,9 @@ export type Event = /** 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" } + /** Spec 3.4.0 (harness/progress.md): what the session's agent is doing now, or null. A + * subagent's progress is in the tree (`team`). */ + | { type: "phase"; progress: import("../session/progress.ts").Progress | null } /** 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/harness.ts b/src/harness.ts index 76576bf..92b1d8f 100644 --- a/src/harness.ts +++ b/src/harness.ts @@ -52,6 +52,13 @@ import t_team_add from "../harness/tools/team_add.json" import t_team_read from "../harness/tools/team_read.json" import t_team_send from "../harness/tools/team_send.json" import t_team_stop from "../harness/tools/team_stop.json" +import t_team_assign from "../harness/tools/team_assign.json" +import t_team_decide from "../harness/tools/team_decide.json" +import t_team_findings from "../harness/tools/team_findings.json" +import t_team_finish from "../harness/tools/team_finish.json" +import t_team_replace from "../harness/tools/team_replace.json" +import t_team_request from "../harness/tools/team_request.json" +import t_team_whoami from "../harness/tools/team_whoami.json" import t_view_image from "../harness/tools/view_image.json" import t_web_fetch from "../harness/tools/web_fetch.json" import t_web_search from "../harness/tools/web_search.json" @@ -76,8 +83,9 @@ export interface ToolSpecDef { purpose: string | false /** Names each project used before spec v1; a call under one is read as this tool. */ aliases: { cli: string[]; llembas: string[] } - /** `subagent`: offered only to a subagent (report), never to a session's own model. */ - offered?: "subagent" + /** `subagent`: offered only to a subagent (report, team_finish, team_request), never to a + * session's own model; `main` (spec 3.4.0): only to the main agent, never to a subagent. */ + offered?: "subagent" | "main" argument_aliases?: Record /** Spec 3.0.0: what must hold for it to be offered — `display` (the session is shown where a file * can be opened: over the link, to a client that offers `show`), `page`, `forge` (the chat's); @@ -293,7 +301,7 @@ export function narrowParameters(parameters: Record, narrow: Na } export const TOOL_SPECS: Record = Object.fromEntries( - ([t_apply_patch, t_ask_user, t_bash, t_branch, t_commit, t_decisions, t_edit, t_glob, t_grep, t_job_list, t_job_output, t_job_send, t_job_stop, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_merge, t_mkdir, t_multiedit, t_mv, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_pr, t_python, t_read, t_recall, t_release, t_report, t_rm, t_search, t_session_search, t_settings, t_show, t_skill_manage, t_skill_view, t_skills_list, t_tag, t_task, t_tasks, t_team_add, t_team_read, t_team_send, t_team_stop, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]), + ([t_apply_patch, t_ask_user, t_bash, t_branch, t_commit, t_decisions, t_edit, t_glob, t_grep, t_job_list, t_job_output, t_job_send, t_job_stop, t_knowledge_get, t_knowledge_search, t_list, t_memory, t_merge, t_mkdir, t_multiedit, t_mv, t_note_manage, t_note_view, t_notes_search, t_plan_submit, t_plan_update, t_plan_write, t_pr, t_python, t_read, t_recall, t_release, t_report, t_rm, t_search, t_session_search, t_settings, t_show, t_skill_manage, t_skill_view, t_skills_list, t_tag, t_task, t_tasks, t_team_add, t_team_assign, t_team_decide, t_team_findings, t_team_finish, t_team_read, t_team_replace, t_team_request, t_team_send, t_team_stop, t_team_whoami, t_view_image, t_web_fetch, t_web_search, t_write] as ToolSpecDef[]).map((t) => [t.name, t]), ) /** Whether the spec gives a tool to this CLI: not the server's alone (`scope: llembas`), and diff --git a/src/prompt/assemble.ts b/src/prompt/assemble.ts index 1180bad..e6fd69c 100644 --- a/src/prompt/assemble.ts +++ b/src/prompt/assemble.ts @@ -20,6 +20,7 @@ import blockSubagent from "../../harness/prompts/blocks/subagent.md" with { type import blockContext from "../../harness/prompts/blocks/context.md" with { type: "text" } import blockTeamLead from "../../harness/prompts/blocks/team_lead.md" with { type: "text" } import blockTeamMember from "../../harness/prompts/blocks/team_member.md" with { type: "text" } +import blockTeamSubmanager from "../../harness/prompts/blocks/team_submanager.md" with { type: "text" } import agentExplore from "../../harness/prompts/agents/explore.md" with { type: "text" } import agentGeneral from "../../harness/prompts/agents/general.md" with { type: "text" } import modeManual from "../../harness/prompts/modes/manual.md" with { type: "text" } @@ -61,6 +62,7 @@ const BUILTIN: Record = { "blocks/context.md": blockContext, "blocks/team_lead.md": blockTeamLead, "blocks/team_member.md": blockTeamMember, + "blocks/team_submanager.md": blockTeamSubmanager, "agents/explore.md": agentExplore, "agents/general.md": agentGeneral, "modes/manual.md": modeManual, @@ -236,9 +238,9 @@ 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 §4), 3.4.0: a subagent of the tree — blocks/team_member.md in place of + * blocks/subagent.md, with blocks/team_submanager.md for a sub-manager. */ + member?: { id: string; 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 @@ -391,12 +393,14 @@ export function assembleSystem(ctx: PromptContext): string { sections.push( fillPrompt(promptText("blocks/team_member.md"), { name: ctx.member.name, + id: ctx.member.id, 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") : "", + // Spec 3.4.0: a sub-manager directs; it no longer gets the whole lead block. + lead: ctx.member.lead ? promptText("blocks/team_submanager.md") : "", }) .replace(/\n{3,}/g, "\n\n") .trim(), diff --git a/src/provider/common.ts b/src/provider/common.ts index bdeb763..a0a1bf5 100644 --- a/src/provider/common.ts +++ b/src/provider/common.ts @@ -54,7 +54,8 @@ export async function* retrying(attempt: () => AsyncGenerator, hand // Twice: the first at once, the second after a pause (a model still loading, a busy server). if ((e.status ?? 0) >= 500 && retried5xx < 2) { retried5xx++ - yield { type: "notice", message: `server error, retrying (${retried5xx} of 2) — ${e.message.split("\n")[0]!.slice(0, 160)}` } + const reason = e.message.split("\n")[0]!.slice(0, 160) + yield { type: "notice", message: `server error, retrying (${retried5xx} of 2) — ${reason}`, retry: { reason, in: retried5xx === 2 ? 2 : 0, attempt: retried5xx } } if (retried5xx === 2) await new Promise((r) => setTimeout(r, 2000)) continue } diff --git a/src/provider/types.ts b/src/provider/types.ts index c388fab..7992e8a 100644 --- a/src/provider/types.ts +++ b/src/provider/types.ts @@ -54,7 +54,7 @@ export type StreamEvent = | { type: "progress"; total: number; cache: number; processed: number; ms: number } | { type: "finish"; reason: string; message: Extract } /** Something worth telling the user that is not an error, e.g. a retry. */ - | { type: "notice"; message: string } + | { type: "notice"; message: string; retry?: { reason: string; in: number; attempt?: number } } export interface ChatRequest { system: string diff --git a/src/session/engine.ts b/src/session/engine.ts index a1f9097..9e5608a 100644 --- a/src/session/engine.ts +++ b/src/session/engine.ts @@ -8,7 +8,7 @@ import { isAbsolute } from "node:path" import type { Asker, AttachmentInfo, Bus } from "../bus/index.ts" import type { Mode } from "../config/schema.ts" import type { Level } from "../provider/effort.ts" -import { evaluate, shadowsDeny, type Context as PermContext, type Rule } from "../permission/evaluate.ts" +import { evaluate, shadowsDeny, type Context as PermContext, type PermissionRequest, type Rule } from "../permission/evaluate.ts" import { ProviderError, type Client, type ImagePart, type Message, type ResolvedModel, type ToolCallPart, type Usage } from "../provider/types.ts" import type { Plan } from "../plan/plan.ts" import { LOOP } from "../harness.ts" @@ -23,6 +23,8 @@ import { duration } from "../duration.ts" import { memoryResults, presentIds, ResultContext } from "./results.ts" import { render } from "../plan/plan.ts" import { personTexts } from "../team/subagents.ts" +import { ProgressTracker } from "./progress.ts" +import type { Verdict } from "../team/delegation.ts" import { compactAsk, isNote, NO_CHANGING, noteFor, offeredFor, PREFIX_META, summaryOf, today, toldAt, unavailable, type Changing, type Kept, type Now, type Requirement, type Told } from "./prefix.ts" export interface EngineOptions { @@ -78,6 +80,15 @@ export interface EngineOptions { /** 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 + /** Spec 3.4.0 (subagents.md §7), a subagent: who decides its call — itself, the main agent + * (through a request), or the person. Replaces the plain evaluation. */ + permit?(request: PermissionRequest, ctx: Omit): Verdict + /** Spec 3.4.0 §9, a subagent: its context reached `pause_at` of its window before a step. + * "stop": its turn ends now (it is paused; its final turn follows); "go": carry on without + * compacting (it is in its final turn). Undefined: not a subagent of the tree. */ + contextFull?(used: number, window: number): "stop" | "go" | undefined + /** Spec 3.4.0, a subagent: it ended by its own call (team_finish): the turn stops after this step. */ + ended?(): boolean } } @@ -87,6 +98,8 @@ export interface PromptMeta { attachments?: AttachmentInfo[] /** The harness started this turn — a job's note — not the person. */ machine?: true + /** Spec 3.4.0: the steps this turn may take, when fewer than the engine's (a subagent's final turn). */ + maxSteps?: number } const MAX_PARALLEL = LOOP.parallel.max @@ -187,7 +200,11 @@ export class Engine { /** The prompt the team's offer was last decided for. */ private teamFixed: Kept | undefined + /** Spec 3.4.0 (harness/progress.md): what this agent is doing, said as `phase` events. */ + readonly progress: ProgressTracker + constructor(readonly o: EngineOptions) { + this.progress = new ProgressTracker(o.bus) this.mode = o.mode this._effort = o.effort this.model = o.model @@ -464,8 +481,19 @@ export class Engine { /** Before a step: past compaction.auto_at of the window, compact. Old tool results are never * pruned in place (harness context.md: nothing already sent is rewritten — it would throw the * prompt cache away from that point); compaction is the one deliberate break. */ - private async manageContext(signal: AbortSignal) { + private async manageContext(signal: AbortSignal): Promise<"stop" | void> { const window = contextWindow(this.model) + // Spec 3.4.0 §9: a subagent of the tree does not compact; at pause_at it is paused instead. + const full = this.o.team?.contextFull + if (full && window) { + const used = this.contextUsed() + if (used !== undefined && used >= window * LOOP.team.context.pause_at) { + const r = full(used, window) + if (r === "stop") return "stop" + if (r === "go") return + } + if (full(0, 0) !== undefined) return + } const autoAt = this.o.compaction?.autoAt ?? 0.85 if (!window || autoAt >= 1) return const limit = window * autoAt @@ -482,6 +510,7 @@ export class Engine { } this.autoCompactions++ this.o.bus.emit({ type: "notice", message: `context at ${Math.round((used / window) * 100)}% of ${window}: compacting the conversation` }) + this.progress.set({ phase: "compacting" }) try { await this.summarise(signal) // The task is never left to the summary: the prompt that started this turn comes back word @@ -508,11 +537,13 @@ export class Engine { /** Time spent waiting for the user does not count against the wall-clock budget: somebody who * thinks for ten minutes about one command has not spent the agent's time. */ - private async waiting(p: Promise): Promise { + private async waiting(p: Promise, what: "approval" | "question" | "plan" = "approval", tool?: string): Promise { const t0 = performance.now() + const back = this.progress.hold({ phase: "waiting_for_person", what, ...(tool ? { tool } : {}) }) try { return await p } finally { + back() this.turnWaitedMs += performance.now() - t0 } } @@ -626,13 +657,13 @@ export class Engine { this.busy = true this.o.bus.emit({ type: "prompt", text: shown, ...(meta.turnId ? { turnId: meta.turnId } : {}), ...(meta.attachments?.length ? { attachments: meta.attachments } : {}), ...(meta.machine ? { machine: true as const } : {}) }) try { - return await this.run(text, [...this.pendingContext.splice(0), ...extra], meta.turnId, meta.machine) + return await this.run(text, [...this.pendingContext.splice(0), ...extra], meta.turnId, meta.machine, meta.maxSteps) } finally { this.busy = false } } - private async run(text: string, extra: (string | ImagePart)[], turnId?: string, machine?: true): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> { + private async run(text: string, extra: (string | ImagePart)[], turnId?: string, machine?: true, stepCap?: number): Promise<"stop" | "steps" | "budget" | "cancelled" | "error"> { const { bus } = this.o this.abort = new AbortController() const signal = this.abort.signal @@ -656,7 +687,7 @@ export class Engine { this.turnSteers = [] this.autoCompactions = 0 const toolMap = new Map(this.o.tools.map((t) => [t.name, t])) - const maxSteps = this.o.maxSteps ?? LOOP.steps.default + const maxSteps = Math.min(this.o.maxSteps ?? LOOP.steps.default, stepCap ?? Infinity) const started = performance.now() this.turnWaitedMs = 0 this.turnOutputBytes = 0 @@ -674,7 +705,10 @@ export class Engine { if (ranOut) bus.emit({ type: "notice", message: `stopped ${ranOut.said} — the model answers from what it has; send a message to carry on` }) } const wrapUp = step > maxSteps || ranOut !== undefined - if (!wrapUp) await this.manageContext(signal) + if (!wrapUp && (await this.manageContext(signal)) === "stop") { + bus.emit({ type: "done", reason: "stop" }) + return "stop" + } // After compacting, so what was just sent is never folded into a summary. if (!wrapUp) this.deliverInbox() bus.emit({ type: "step", n: step }) @@ -817,11 +851,11 @@ export class Engine { ...this.o.toolCtx, signal, backgroundNow: bgNow.signal, - question: this.o.asker.question ? (req, id) => this.waiting(this.o.asker.question!(req, id)) : undefined, + question: this.o.asker.question ? (req, id) => this.waiting(this.o.asker.question!(req, id), "question") : undefined, plan: { get: () => this.plan, set: (p) => this.setPlan(p, "model"), - ask: this.o.asker.plan ? (req, id) => this.waiting(this.o.asker.plan!(req, id)) : undefined, + ask: this.o.asker.plan ? (req, id) => this.waiting(this.o.asker.plan!(req, id), "plan") : undefined, }, setMode: (m) => { this.mode = m @@ -881,6 +915,11 @@ export class Engine { // No dialect takes an image inside a tool result the same way; a user turn right after the // results works everywhere. if (images.length) this.push({ role: "user", parts: [{ type: "text", text: "The image(s) you asked to view:" }, ...images], turn: false }) + // Spec 3.4.0: a subagent that called team_finish has ended — nothing runs after that call. + if (this.o.team?.ended?.()) { + bus.emit({ type: "done", reason: "stop" }) + return "stop" + } if (signal.aborted) { bus.emit({ type: "done", reason: "cancelled" }) return "cancelled" @@ -951,6 +990,13 @@ export class Engine { let edited: string | undefined const ctx: PermContext = { ...this.o.perm, mode: this.mode, rules: [...this.o.perm.rules, ...this.sessionRules] } let decision = evaluate(request, ctx) + // Spec 3.4.0 (subagents.md §7): a subagent of the tree — itself, the main agent, or the person. + if (team?.permit) { + const { mode: _m, ...rest } = ctx + const v = team.permit(request, rest) + if (v.answer === "refused") return { result: { output: v.text, isError: true }, denied: true } + decision = { ...v.decision, action: v.answer === "run" ? "allow" : "ask" } + } // Doom-loop guard: the same call three times running is asked about, whatever the mode. const key = `${call.name} ${JSON.stringify(args)}` @@ -967,7 +1013,7 @@ export class Engine { (diff) => ({ diff }), (e) => ({ error: (e as Error).message }), ) - const reply = await this.waiting(this.o.asker.ask({ tool: tool.name, args, request, decision, preview, purpose })) + const reply = await this.waiting(this.o.asker.ask({ tool: tool.name, args, request, decision, preview, purpose }), "approval", tool.name) if (reply.kind === "deny" && looping) return { result: { diff --git a/src/session/progress.ts b/src/session/progress.ts index dd11f5b..46d02bd 100644 --- a/src/session/progress.ts +++ b/src/session/progress.ts @@ -102,3 +102,139 @@ export function samePhase(a: Progress | null | undefined, b: Omit + for (const k of ["command", "path", "file_path", "pattern", "query", "url", "description", "name"]) if (typeof a[k] === "string" && a[k]) return shortWhat(a[k] as string) + return undefined +} + +type BusLike = { on(fn: (e: any) => void): () => void; emit(e: any): void } + +/** The progress of one engine, derived from the events it emits (progress.md §4), and said on its + * bus as `phase` events: a new phase at once, a change of its data at most pace_per_second times a + * second. Explicit phases (compacting, waiting for the person or a model, queued) are `set` by + * whoever knows them. */ +export class ProgressTracker { + current: Progress | null = null + private rate: number | undefined + private running = new Map>() + private lastEmit = 0 + private pendingTimer: ReturnType | undefined + private error: string | undefined + private held: Progress | null = null + private off: () => void + + constructor( + private bus: BusLike, + private now: () => number = Date.now, + ) { + this.off = bus.on((e) => this.on(e)) + } + + dispose() { + this.off() + if (this.pendingTimer) clearTimeout(this.pendingTimer) + } + + /** Set the phase (or null). Same phase for the same thing keeps `since`. */ + set(next: Omit | null) { + if (next === null) { + if (this.current === null) return + this.current = null + return this.emit(true) + } + if (samePhase(this.current, next)) { + const merged = { ...this.current!, ...next, since: this.current!.since } as Progress + if (JSON.stringify(merged) === JSON.stringify(this.current)) return + this.current = merged + return this.emit(false) + } + this.current = { ...next, since: this.now() } as Progress + this.emit(true) + } + + /** A phase that waits on something outside the turn (the person, a model): set now, and the + * phase before it put back by the function returned. */ + hold(next: Omit): () => void { + const before = this.current + this.held = before + this.set(next) + return () => { + if (this.current && this.current.phase === next.phase) { + this.current = before ? { ...before } : null + this.emit(true) + } + this.held = null + } + } + + private emit(now: boolean) { + const t = this.now() + const gap = 1000 / LOOP.progress.pace_per_second + if (!now && t - this.lastEmit < gap) { + this.pendingTimer ??= setTimeout(() => { + this.pendingTimer = undefined + this.emit(true) + }, gap - (t - this.lastEmit)) + return + } + if (this.pendingTimer) { + clearTimeout(this.pendingTimer) + this.pendingTimer = undefined + } + this.lastEmit = t + this.bus.emit({ type: "phase", progress: this.current ? { ...this.current } : null }) + } + + private on(e: any) { + switch (e.type) { + case "task": + if (e.state === "start") { + this.rate = undefined + this.running.clear() + this.error = undefined + } + return + case "step": + this.running.clear() + return this.set({ phase: "reading_prompt" }) + case "progress": + return this.set({ phase: "reading_prompt", done: (e.cache ?? 0) + (e.processed ?? 0), total: e.total }) + case "reasoning": + return this.set({ phase: "thinking" }) + case "text": + case "tool_call_delta": + return this.set(this.rate !== undefined ? { phase: "writing", rate: this.rate } : { phase: "writing" }) + case "usage": + if (!e.child && typeof e.rate === "number" && Number.isFinite(e.rate)) this.rate = e.rate + return + case "tool_start": { + const a = (e.args ?? {}) as Record + const p: Omit = e.name === "task" && a.background !== true ? { phase: "waiting_for_subagents", running: 1 } : { phase: "running_tool", tool: e.name, ...(callWhat(e.args) ? { what: callWhat(e.args) } : {}) } + this.running.set(e.id, p) + if (this.running.size === 1) this.set(p) + return + } + case "tool_end": { + this.running.delete(e.id) + const next = this.running.values().next() + if (!next.done) this.set(next.value) + return + } + case "notice": + if (e.retry) this.set({ phase: "retrying", reason: shortWhat(e.retry.reason) ?? "", in: e.retry.in, ...(e.retry.attempt ? { attempt: e.retry.attempt } : {}) }) + return + case "error": + this.error = shortWhat(String(e.message ?? "")) ?? "error" + return + case "done": + this.running.clear() + if (e.reason === "stop") return this.set(null) + if (e.reason === "error") return this.set({ phase: "failed", reason: this.error ?? "error" }) + return this.set({ phase: "stopped", reason: e.reason === "cancelled" ? "person" : e.reason }) + } + } +} diff --git a/src/team/runtime.ts b/src/team/runtime.ts index 688ad4a..66ed872 100644 --- a/src/team/runtime.ts +++ b/src/team/runtime.ts @@ -1,5 +1,5 @@ -// 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 +// A lead session's team at work (harness spec 3.2.0, team.md; 3.4.0, subagents.md §5–§13): 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. @@ -8,10 +8,14 @@ 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" +import type { Mode } from "../config/schema.ts" +import type { ToolClass } from "../permission/evaluate.ts" /** A member's engine, as the app made it. */ export interface MemberHandle { engine: Engine + /** Its one final turn after its context filled (spec 3.4.0 §9), with this text, few steps. */ + final?(text: string): void /** 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. */ @@ -29,6 +33,8 @@ export interface RuntimeOptions { /** 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 } + /** A tool's risk class and permission key, by its name (team_request, team_decide). */ + tools?: () => readonly Tool[] } export class TeamRuntime { @@ -36,6 +42,11 @@ export class TeamRuntime { private members = new Map() /** The lead's turn waiting for a slot: resolved when it is given one. */ private leadWaiter: (() => void) | undefined + /** Spec 3.4.0: subagents that ended by their own team_finish (their turn stops after the call), + * and the final turn waiting for one whose context filled. */ + private ended = new Set() + private finals = new Map() + private pace: ReturnType | undefined constructor(private o: RuntimeOptions) { this.team = o.team @@ -47,6 +58,8 @@ export class TeamRuntime { start: (name, text) => this.start(name, text), deliver: (name, text) => this.deliver(name, text), stop: (name) => this.stopped(name), + end: (name) => void this.ended.add(name), + final: (name, text) => void this.finals.set(name, text), } } @@ -59,8 +72,75 @@ export class TeamRuntime { this.changed() } + /** Said on the lead's bus as `team`, at most four times a second (acp.md `team` event). */ private changed() { + if (this.pace) return this.o.bus.emit({ type: "team" }) + this.pace = setTimeout(() => { + this.pace = undefined + }, 1000 / 4) + } + + /** Something changed that a reader must see now (the tools' answers, tests): said at once. */ + private changedNow() { + if (this.pace) clearTimeout(this.pace) + this.pace = undefined + this.o.bus.emit({ type: "team" }) + } + + /** A tool's risk class, for team_request. */ + classOf(tool: string): ToolClass | undefined { + const spec = TOOL_SPECS[tool]?.risk + if (spec) return spec as ToolClass + const t = this.o.tools?.().find((x) => x.name === tool) + try { + return t ? (t.permission({} as never, {} as never).class as ToolClass) : undefined + } catch { + return "execute" + } + } + + /** A tool's permission key (edit and write share `edit`), for a grant. */ + permissionKeyOf(tool: string): string | undefined { + return this.o.tools?.().find((x) => x.name === tool)?.access ?? tool + } + + /** Every subagent's engine in its team's mode (spec 3.4.0 §7: the main agent made stricter, an + * assignment). */ + syncModes() { + for (const [name, h] of this.members) { + const m = this.team.get(name)?.mode + if (m && h.engine.mode !== m) h.engine.mode = m + } + } + + /** The main agent's mode changed (the person, plan approved): stricter reaches down the tree. */ + mainMode(mode: Mode) { + this.team.setMainMode(mode) + this.syncModes() + this.changed() + } + + /** Spec 3.4.0: whether this subagent ended by its own call (its engine stops after the step). */ + hasEnded(name: string): boolean { + return this.ended.has(name) + } + + /** Spec 3.4.0 §9: its context reached pause_at. "stop" the first time (it is paused, its final + * turn follows), "go" in that final turn (no compaction, it carries on). */ + contextFull(name: string): "stop" | "go" { + const a = this.team.get(name) + if (!a || a.state === "paused") return "go" + this.team.pause(name) + this.changedNow() + return "stop" + } + + /** What a member's run should do when its turn ended: its final turn (context full), or report. */ + takeFinal(name: string): string | undefined { + const t = this.finals.get(name) + this.finals.delete(name) + return t } /** Something the tools did changed the team: said, for whoever shows it. */ @@ -70,7 +150,7 @@ export class TeamRuntime { /** 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 } { + add(as: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string; parent?: string; mode?: Mode; request?: 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. @@ -111,6 +191,7 @@ export class TeamRuntime { private stopped(name: string) { const h = this.members.get(name) this.members.delete(name) + this.finals.delete(name) h?.cancel() this.changed() } @@ -147,8 +228,21 @@ export class TeamRuntime { this.changed() } - /** A member's turn ended: its reply is its report; messages it was given too late wake it again. */ + /** A member's turn ended: its reply is its report; messages it was given too late wake it again. + * Spec 3.4.0: one that ended by its own team_finish is let go; one whose context filled gets its + * final turn first. */ memberDone(name: string, reply: string, reason: string, leftover: string[]) { + if (this.ended.delete(name)) { + this.members.delete(name) + this.changed() + return + } + const final = this.takeFinal(name) + if (final !== undefined && this.team.get(name)?.state === "paused") { + this.members.get(name)?.final?.(final) + this.changed() + return + } if (!this.team.get(name)) return this.team.finish(name, reply, reason) if (leftover.length) this.team.undelivered(name, leftover) @@ -157,7 +251,6 @@ export class TeamRuntime { /** 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 diff --git a/src/team/team.ts b/src/team/team.ts index dfc5cb4..5367424 100644 --- a/src/team/team.ts +++ b/src/team/team.ts @@ -1,5 +1,7 @@ -// Teams (harness spec 3.2.0, harness/team.md): a lead session's agents, their room, who receives -// what, the size the person allows, a manager's refusals, and the slots of the model they run on. +// Teams (harness spec 3.2.0, harness/team.md; 3.4.0, harness/subagents.md §5–§13): the main agent +// (`lead`) and its subagents, their room, who receives what, the size the person allows, a +// manager's refusals, the slots of the model they run on — and from 3.4.0 the tree only the main +// agent builds, modes and request options, findings, and a subagent's end. // This file is the rules alone, without an engine: the conformance area `team` drives it step by // step, and the app (team/runtime.ts) drives it from real turns — the effects say when an agent's // turn starts (it was given a slot), when a message reaches an agent that is working, and when an @@ -9,14 +11,42 @@ import { fillPrompt } from "../prompt/fill.ts" import { DEFAULT_RULES, evaluate, toRules } from "../permission/evaluate.ts" import { BUILTIN_HARDLINE } from "../permission/hardline.ts" import { duration } from "../duration.ts" +import type { Mode } from "../config/schema.ts" +import type { ToolClass } from "../permission/evaluate.ts" +import { checkMode, strictest, type Grant } from "./delegation.ts" +import type { Progress } from "../session/progress.ts" export const TEAM = LOOP.team const TEXTS = TEAM.texts const LEAD = TEAM.names.lead -export const TEAM_TOOLS = ["team_add", "team_read", "team_send", "team_stop"] as const +export const TEAM_TOOLS = ["team_add", "team_assign", "team_decide", "team_findings", "team_finish", "team_read", "team_replace", "team_request", "team_send", "team_stop", "team_whoami"] as const +/** Spec 3.4.0 (subagents.md §8): the main agent's alone. */ +export const MAIN_ONLY = TEAM.main_only.tools as readonly string[] +/** The main agent's team tools, and a subagent's (with team_request only when it has request options). */ +export const LEAD_TOOLS = ["team_add", "team_assign", "team_decide", "team_findings", "team_read", "team_replace", "team_send", "team_stop", "team_whoami"] as const +export const MEMBER_TOOLS = ["team_findings", "team_finish", "team_read", "team_send", "team_whoami"] as const +export function memberTools(a: { request: readonly string[] }): string[] { + return [...MEMBER_TOOLS, ...(a.request.length ? ["team_request"] : [])].sort() +} const NAME = new RegExp(TEAM.names.pattern) -export type AgentState = "working" | "waiting" | "finished" | "idle" +export type AgentState = "working" | "waiting" | "finished" | "idle" | "paused" | "done" | "out_of_context" | "stopped" +export type EndState = "done" | "out_of_context" | "stopped" + +export interface Finding { + n: number + text: string + time: number +} + +export interface Request { + id: string + from: string + tool: string + pattern: string + why: string + open: boolean +} /** What a model's server can do at once (models/schema.json capacity). */ export interface Capacity { @@ -32,11 +62,26 @@ export function parallelFrom(c: Capacity | undefined): number { } export interface TeamAgent { + /** `lead` for the main agent; s1, s2, … for subagents, never reused (spec 3.4.0). */ + id: string name: string + /** Its position (team_add's role). */ role: string + /** Its task, as given and as assigned since. */ + task: string + mode: Mode + /** Request options (spec 3.4.0 §7). */ + request: ToolClass[] + /** What the main agent allowed it (team_decide). */ + grants: Grant[] + findings: Finding[] + /** Its context use, as its engine last said it. */ + context?: { used: number; window: number } + /** Its current progress (progress.md). */ + progress?: Progress | null /** Who added it; null for the lead. */ manager: string | null - /** May add members of its own (team_add's `lead`); the lead may. */ + /** A sub-manager (team_add's `lead`), or the main agent: subagents may be placed under it. */ leads: boolean /** The team's goal as it was told it (the lead's: the last one it gave). */ goal: string @@ -54,6 +99,11 @@ export interface TeamAgent { ended?: number /** Added in this order (tree order among siblings). */ seq: number + /** How it ended, and when (an ended subagent stays in the tree's `ended`). */ + endState?: EndState + endedAt?: number + /** The sub-manager it was under before that one ended (team_replace puts it back). */ + movedFrom?: string } export interface RoomMessage { @@ -65,6 +115,38 @@ export interface RoomMessage { text: string time: number report: boolean + /** Spec 3.4.0: what it is — a message, a report, an assignment, a request, a decision. */ + kind: RoomKind +} + +export type RoomKind = "message" | "report" | "assignment" | "request" | "decision" + +/** A row of the tree (subagents.md §12). */ +export interface TreeAgent { + id: string + name: string + position: string + parent: string | null + state: AgentState + model: string + mode: Mode + request: string[] + manager: boolean + task?: string + context?: { used: number; window: number } + progress: Progress | null + findings: number + messages: number + ended?: number +} + +export interface TreeInfo { + min: number + max: number + size: number + open_slots: number + agents: TreeAgent[] + ended: TreeAgent[] } export interface TeamEffects { @@ -75,6 +157,11 @@ export interface TeamEffects { deliver(name: string, text: string): void /** It left the team: its turn stops where it is, its background commands with it. */ stop(name: string): void + /** Spec 3.4.0: it ended by its own team_finish — its turn stops after that call. */ + end?(name: string): void + /** Spec 3.4.0: its context is full — its turn stops after this step, and its one final turn + * starts with `text`. */ + final?(name: string, text: string): void } export interface TeamOptions { @@ -95,6 +182,10 @@ export interface TeamOptions { now?: () => number /** The lead's state as the team begins: working (its first prompt runs) or idle. */ leadState?: "working" | "idle" + /** The main agent's mode (the session's). Default: auto. */ + mode?: Mode + /** Steps a turn may take: the main agent's and a subagent's (for team_whoami). */ + steps?: { main: number; sub: number } } /** The default rules' answer for a command in manual mode, with no rules of the person's. */ @@ -125,6 +216,11 @@ export class Team { /** Members the person stopped, not yet said to the lead. */ private stoppedByPerson: string[] = [] private waits = 0 + /** Subagents that ended (stopped, done, out of context), in the order they ended. */ + ended: TeamAgent[] = [] + private idSeq = 0 + private reqSeq = 0 + requests: Request[] = [] constructor(private o: TeamOptions) { this.cap = o.cap ?? TEAM.agents.cap @@ -133,7 +229,7 @@ export class Team { this.min = TEAM.agents.min this.max = TEAM.agents.max this.setSetting(o.min ?? TEAM.agents.min, o.max ?? TEAM.agents.max) - this.agents.set(LEAD, { name: LEAD, role: TEAM.read.lead_role, manager: null, leads: true, goal: "", model: o.model, agent: "", state: o.leadState ?? "working", inbox: [], seq: this.seq++ }) + this.agents.set(LEAD, { id: LEAD, name: LEAD, role: TEAM.read.lead_role, task: "", mode: o.mode ?? "auto", request: [], grants: [], findings: [], manager: null, leads: true, goal: "", model: o.model, agent: "", state: o.leadState ?? "working", inbox: [], seq: this.seq++ }) this.fix() } @@ -263,8 +359,14 @@ export class Team { /** What a call by `by` answers before it runs — the team tools' requirement, task's size, a * manager's work — or undefined when it goes on to its own checks (permission first). */ refusal(by: string, tool: string, args: unknown, risk?: string): string | undefined { - if ((TEAM_TOOLS as readonly string[]).includes(tool)) return this.offered ? this.unavailable() : TEAM.unavailable.one_agent - if (tool === "task") return this.taskRefusal() + if ((TEAM_TOOLS as readonly string[]).includes(tool)) { + const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent + if (no) return no + // Spec 3.4.0 (subagents.md §6): only the main agent adds, assigns, replaces, stops, decides. + if (by !== LEAD && MAIN_ONLY.includes(tool)) return TEXTS.only_main + return undefined + } + if (tool === "task") return by !== LEAD ? TEXTS.only_main : this.taskRefusal() if (this.isManager(by) && this.isWork(tool, args, risk)) return TEXTS.manager return undefined } @@ -336,6 +438,8 @@ export class Team { private deliver(name: string, text: string) { const a = this.agents.get(name) if (!a) return + // Spec 3.4.0 §9: a paused subagent is given nothing new; the message stays in the room. + if (a.state === "paused") return this.delivered.push({ name, text }) if (a.state === "working") return this.o.effects?.deliver(name, text) a.inbox.push(text) @@ -345,10 +449,11 @@ export class Team { /** What was delivered, to whom, since this was last read (for a front end and the conformance). */ delivered: { name: string; text: string }[] = [] - private post(from: string, to: string[], recipients: string[], text: string, report: boolean): RoomMessage { - const m: RoomMessage = { n: this.room.length + 1, from, to, recipients, text, time: this.now(), report } + private post(from: string, to: string[], recipients: string[], text: string, report: boolean, kind: RoomKind = report ? "report" : "message", id = ""): RoomMessage { + const m: RoomMessage = { n: this.room.length + 1, from, to, recipients, text, time: this.now(), report, kind } this.room.push(m) - const mark = fill(report ? TEAM.marks.report : TEAM.marks.message, { from, to: to.join(", ") }) + const marks = TEAM.marks as Record + const mark = fill(marks[kind]!, { from, to: to.join(", "), id }) for (const r of recipients) this.deliver(r, `${mark}\n${text}`) return m } @@ -356,32 +461,49 @@ 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 { + wouldRefuse(by: string, a: { name: string; goal?: string; parent?: string; mode?: Mode; request?: string[] }): string | undefined { const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent if (no) return no + if (by !== LEAD) return TEXTS.only_main 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.agents.has(a.name) || a.name === TEAM.names.parent) return fill(TEXTS.taken, { name: a.name }) + if (!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 + return this.placeRefusal(a.parent, a.mode, a.request) + } + + /** Spec 3.4.0 (§6, §7): `parent` must be the lead or a sub-manager; the mode no looser than its. */ + private placeRefusal(parent: string | undefined, mode: Mode | undefined, request: string[] | undefined): string | undefined { + const p = this.agents.get(parent ?? LEAD) + if (!p) return fill(TEXTS.unknown, { name: parent!, names: this.names() }) + if (!p.leads) return fill(TEXTS.not_manager, { name: p.name }) + const m = checkMode(p.mode, mode, request) + return "error" in m ? m.error : 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 } { + add(by: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string; parent?: string; mode?: Mode; request?: string[] }): { text: string; added?: TeamAgent } { const no = this.wouldRefuse(by, a) if (no) return { text: no } const lead = this.agents.get(LEAD)! const goal = a.goal?.trim() - const caller = this.agents.get(by) ?? lead - if (by === LEAD && goal) lead.goal = goal + const parent = this.agents.get(a.parent ?? LEAD)! + if (goal) lead.goal = goal + const placed = checkMode(parent.mode, a.mode, a.request) as { mode: Mode; request: ToolClass[] } const member: TeamAgent = { + id: `${TEAM.names.id_prefix}${++this.idSeq}`, name: a.name, role: a.role.trim().split("\n")[0]!, - manager: by, + task: a.task, + mode: placed.mode, + request: placed.request, + grants: [], + findings: [], + manager: parent.name, leads: a.lead === true, - goal: goal || caller.goal, - model: a.model ?? caller.model, + goal: goal || parent.goal || lead.goal, + model: a.model ?? lead.model, agent: a.agent ?? "general", state: "finished", inbox: [], @@ -399,7 +521,13 @@ export class Team { send(by: string, a: { to: string[] | string; message: string }): string { const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent if (no) return no - const list = [...new Set(typeof a.to === "string" ? [a.to] : a.to)] + let list = [...new Set(typeof a.to === "string" ? [a.to] : a.to)] + // Spec 3.4.0: `parent` is the sender's parent. + if (list.includes(TEAM.names.parent)) { + const mgr = this.agents.get(by)?.manager + if (!mgr) return TEXTS.no_parent + list = [...new Set(list.map((n) => (n === TEAM.names.parent ? mgr : n)))] + } const all = list.includes("all") const others = [...this.agents.keys()].filter((n) => n !== by) if (all) { @@ -452,12 +580,21 @@ export class Team { fill(R.agent, { indent: " ".repeat(depth(a)), name: a.name, + id: a.id, role: a.role, state: R.states[a.state], model: a.model, + mode: a.request.length ? `${a.mode} ${fill(R.request, { kinds: a.request.join(", ") })}` : a.mode, + context: a.context && a.context.window > 0 ? fill(R.context, { percent: Math.floor((a.context.used * 100) / a.context.window) }) : "—", last: a.state === "working" ? R.active : a.ended !== undefined ? `${duration(this.now() - a.ended)} ago` : "—", }), ) + const open = this.openSlots() + if (open) lines.push(fill(R.slots, { n: open })) + if (this.ended.length) { + lines.push(R.ended) + for (const a of this.ended) lines.push(fill(R.ended_agent, { name: a.name, id: a.id, role: a.role, state: R.states[a.state], findings: a.findings.length })) + } const { messages, more } = this.readRoom(by, since) lines.push("") if (!messages.length) lines.push(fill(R.none, { since })) @@ -477,6 +614,7 @@ export class Team { stopCmd(by: string, name: string): string { const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent if (no) return no + if (by !== LEAD) return TEXTS.only_main if (name === "all") { const under = this.membersOf(by) if (!under.length) return TEXTS.nobody_to_stop @@ -500,10 +638,26 @@ export class Team { return gone.map((a) => a.name) } - private remove(gone: TeamAgent[]) { + private remove(gone: TeamAgent[], end: EndState = "stopped") { for (const a of gone) { this.agents.delete(a.name) - this.o.effects?.stop(a.name) + a.state = end + a.endState = end + a.endedAt = this.now() + a.inbox = [] + a.brief = undefined + this.ended.push(a) + for (const r of this.requests) if (r.from === a.name) r.open = false + if (end === "done") this.o.effects?.end?.(a.name) + else if (end === "stopped") this.o.effects?.stop(a.name) + // Spec 3.4.0: the subagents under one that ended by itself move under its parent until the + // main agent replaces it (team_replace moves them back under the replacement). + if (end !== "stopped") + for (const c of this.agents.values()) + if (c.manager === a.name) { + c.manager = a.manager ?? LEAD + c.movedFrom = a.name + } } this.fillSlots() } @@ -512,7 +666,16 @@ export class Team { * stop, steps, budget, or `error — `. */ finish(name: string, reply = "", reason = "stop") { const a = this.agents.get(name) - if (!a || a.state !== "working") return + if (!a) return + // Spec 3.4.0 §9: the final turn after its context filled has ended — it is out of context. + if (a.state === "paused") { + const gone = fill(TEXTS.gone, { name: a.name, id: a.id }) + const text = reply.trim() ? `${gone}\n\n${reply.trim()}` : gone + if (a.manager) this.post(name, [a.manager], [a.manager], text, true) + this.remove([a], "out_of_context") + return + } + if (a.state !== "working") return a.state = name === LEAD ? "idle" : "finished" a.ended = this.now() if (a.manager) { @@ -547,6 +710,240 @@ export class Team { this.stoppedByPerson = [] } + + // ── spec 3.4.0: the main agent's tree (subagents.md §5–§13) ───────────────────── + + /** A subagent by name (live first, else the latest that ended) or by id. */ + find(nameOrId: string): TeamAgent | undefined { + return this.agents.get(nameOrId) ?? [...this.agents.values()].find((a) => a.id === nameOrId) ?? [...this.ended].reverse().find((a) => a.name === nameOrId || a.id === nameOrId) + } + + /** Open slots (§6): min − 1 places for subagents not added yet, while min ≥ 2 and offered. */ + openSlots(): number { + if (!this.offered || this.min < 2) return 0 + return Math.max(0, this.min - 1 - (this.agents.size - 1)) + } + + /** The main agent's mode changed: stricter reaches down the tree, looser changes nobody (§7). */ + setMainMode(mode: Mode) { + this.agents.get(LEAD)!.mode = mode + for (const a of this.tree()) if (a.manager) a.mode = strictest(a.mode, this.agents.get(a.manager)?.mode ?? mode) + } + + /** Its engine's context use, and its progress, as last said. */ + setContext(name: string, used: number, window: number) { + const a = this.agents.get(name) + if (a) a.context = { used, window } + } + + setProgress(name: string, p: Progress | null) { + const a = this.agents.get(name) + if (a) a.progress = p + } + + /** team_whoami (§8). */ + whoami(by: string): string { + const a = this.agents.get(by) + if (!a) return fill(TEXTS.unknown, { name: by, names: this.names() }) + const W = TEAM.whoami.lines + const steps = by === LEAD ? (this.o.steps?.main ?? LOOP.steps.default) : (this.o.steps?.sub ?? LOOP.jobs.subagent_steps.default) + const lines = [fill(W.who, { name: a.name, id: a.id, role: a.role })] + if (a.manager) lines.push(fill(W.parent, { parent: a.manager })) + if (a.manager) lines.push(fill(W.task, { task: a.task.split("\n")[0]! })) + lines.push(fill(W.mode, { mode: a.mode })) + if (a.request.length) lines.push(fill(W.request, { kinds: a.request.join(", ") })) + lines.push(fill(W.model, { model: a.model })) + lines.push(fill(W.limits, { size: this.size(), max: this.max, steps, spawn: by === LEAD ? W.spawn_yes : W.spawn_no })) + if (a.context && a.context.window > 0) lines.push(fill(W.context, { used: a.context.used, window: a.context.window, percent: Math.floor((a.context.used * 100) / a.context.window) })) + return lines.join("\n") + } + + /** team_findings write (§8). */ + noteFinding(by: string, text: string): string { + const a = this.agents.get(by) + if (!a) return fill(TEXTS.unknown, { name: by, names: this.names() }) + const n = a.findings.length + 1 + a.findings.push({ n, text: text.trim(), time: this.now() }) + return fill(TEXTS.noted, { n }) + } + + /** Whether `by` may read `target`'s findings: its own, one in its subtree, or the lead anyone's. */ + private mayRead(by: string, target: TeamAgent): boolean { + if (by === LEAD || target.name === by) return true + let up: string | null | undefined = target.endState ? (target.movedFrom ?? target.manager) : target.manager + for (let guard = 0; up && guard < 1000; guard++) { + if (up === by) return true + up = this.agents.get(up)?.manager ?? this.find(up)?.manager + } + return false + } + + /** team_findings read (§8). */ + readFindings(by: string, of?: string): string { + const target = of ? this.find(of) : this.agents.get(by) + if (!target) return fill(TEXTS.unknown, { name: of ?? by, names: this.names() }) + if (!this.mayRead(by, target)) return TEXTS.not_yours_findings + const lines = [fill(TEAM.findings.header, { name: target.name, id: target.id })] + if (!target.findings.length) lines.push(TEXTS.no_findings) + for (const f of target.findings) lines.push(fill(TEAM.findings.item, { n: f.n, text: f.text })) + return lines.join("\n") + } + + /** team_finish (§8): the report to its parent, and it ends (done). */ + finishTask(by: string, report: string): string { + const a = this.agents.get(by) + if (!a || !a.manager) return TEXTS.no_parent + this.post(by, [a.manager], [a.manager], report.trim() || TEXTS.no_report, true) + const to = a.manager + this.remove([a], "done") + return fill(TEXTS.finished, { to }) + } + + /** Its context is full (§9): paused, and given one final turn. The text of that turn. */ + pause(name: string): string | undefined { + const a = this.agents.get(name) + if (!a || a.name === LEAD || a.state === "paused") return undefined + a.state = "paused" + a.inbox = [] + const text = `${fill(TEAM.marks.context_full, { to: name })}\n${fill(TEXTS.context_full, { steps: TEAM.context.final_steps })}` + this.delivered.push({ name, text }) + this.o.effects?.final?.(name, text) + return text + } + + /** team_assign (§8). */ + assign(by: string, a: { name: string; task: string; role?: string; parent?: string; mode?: Mode }): string { + const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent + if (no) return no + if (by !== LEAD) return TEXTS.only_main + const t = this.agents.get(a.name) + if (!t || t.name === LEAD) return this.find(a.name)?.endState ? fill(TEXTS.has_ended, { name: a.name }) : fill(TEXTS.unknown, { name: a.name, names: this.names() }) + const parentName = a.parent ?? t.manager! + const p = this.agents.get(parentName) + if (!p) return fill(TEXTS.unknown, { name: parentName, names: this.names() }) + if (!p.leads) return fill(TEXTS.not_manager, { name: p.name }) + if (this.subtree(t.name).some((x) => x.name === p.name)) return fill(TEXTS.cycle, { name: t.name, parent: p.name }) + let mode = strictest(t.mode, p.mode) + let request = t.request + if (a.mode) { + const m = checkMode(p.mode, a.mode, t.request.length && a.mode !== p.mode ? t.request : undefined) + if ("error" in m) return m.error + mode = m.mode + request = m.mode === p.mode ? [] : t.request + } + t.task = a.task + if (a.role?.trim()) t.role = a.role.trim().split("\n")[0]! + t.manager = p.name + t.movedFrom = undefined + t.mode = mode + t.request = request + for (const c of this.subtree(t.name).slice(1)) c.mode = strictest(c.mode, this.agents.get(c.manager!)!.mode) + this.post(LEAD, [t.name], [t.name], a.task, false, "assignment") + this.fillSlots() + return fill(TEXTS.assigned, { name: t.name }) + } + + /** team_replace (§8): the same name, position, parent, mode and model; a new id; briefed with + * the task and the old one's findings. */ + replace(by: string, a: { name: string; task?: string }): { text: string; added?: TeamAgent } { + const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent + if (no) return { text: no } + if (by !== LEAD) return { text: TEXTS.only_main } + const old = this.agents.get(a.name) ?? [...this.ended].reverse().find((x) => x.name === a.name) + if (!old || old.name === LEAD) return { text: fill(TEXTS.unknown, { name: a.name, names: this.names() }) } + const live = this.agents.get(a.name) === old + if (!live && this.size() + 1 > this.max) return { text: fill(TEXTS.full, { max: this.max }) } + if (live) { + // It alone ends: the subagents under it move to the replacement. + for (const c of this.membersOf(old.name)) c.movedFrom = old.name + for (const c of this.membersOf(old.name)) c.manager = old.manager ?? LEAD + this.remove([old], "stopped") + } + const manager = old.movedFrom ?? old.manager + const parent = manager && this.agents.get(manager) ? manager : LEAD + const findings = old.findings.length ? old.findings.map((f) => fill(TEAM.findings.item, { n: f.n, text: f.text })).join("\n") : TEXTS.no_findings + const brief = fill(TEXTS.replace_brief, { task: a.task?.trim() || old.task, name: old.name, id: old.id, findings }) + const p = this.agents.get(parent)! + const fresh: TeamAgent = { + ...old, + id: `${TEAM.names.id_prefix}${++this.idSeq}`, + task: a.task?.trim() || old.task, + mode: strictest(old.mode, p.mode), + grants: [], + findings: [], + context: undefined, + progress: undefined, + manager: parent, + state: "finished", + inbox: [], + brief, + seq: this.seq++, + endState: undefined, + endedAt: undefined, + movedFrom: undefined, + ended: undefined, + waitSince: undefined, + } + this.agents.set(fresh.name, fresh) + for (const c of this.agents.values()) + if (c.movedFrom === old.name) { + c.manager = fresh.name + c.movedFrom = undefined + } + this.want(fresh) + this.fillSlots() + return { text: fill(TEXTS.replaced, { name: fresh.name, id: fresh.id, size: this.size(), max: this.max }), added: fresh } + } + + /** team_request (§7): `cls` is the tool's risk class. */ + request(by: string, a: { tool: string; pattern: string; why: string }, cls: ToolClass | undefined): string { + const t = this.agents.get(by) + if (!t || !t.manager) return TEXTS.no_parent + if (!cls || !t.request.includes(cls)) return fill(TEXTS.not_requestable, { tool: a.tool, kinds: t.request.join(", ") || "none" }) + const id = `${TEAM.request.id_prefix}${++this.reqSeq}` + this.requests.push({ id, from: by, tool: a.tool, pattern: a.pattern, why: a.why, open: true }) + this.post(by, [LEAD], [LEAD], fill(TEXTS.request_body, { tool: a.tool, pattern: a.pattern, why: a.why }), false, "request", id) + this.fillSlots() + return fill(TEXTS.requested, { id }) + } + + /** team_decide (§7): a grant for the subagent when allowed; it is told either way. `permission`: + * the tool's permission key. */ + decide(by: string, a: { id: string; allow: boolean; note?: string }, permission?: (tool: string) => string | undefined): string { + if (by !== LEAD) return TEXTS.only_main + const r = this.requests.find((x) => x.id === a.id && x.open) + const t = r && this.agents.get(r.from) + if (!r || !t) return fill(TEXTS.no_request, { id: a.id }) + r.open = false + if (a.allow) t.grants.push({ tool: r.tool, permission: permission?.(r.tool) ?? r.tool, pattern: r.pattern }) + const head = fill(a.allow ? TEXTS.decision_allowed : TEXTS.decision_refused, { tool: r.tool, pattern: r.pattern }) + this.post(LEAD, [t.name], [t.name], a.note?.trim() ? `${head}\n\n${a.note.trim()}` : head, false, "decision", r.id) + this.fillSlots() + return fill(TEXTS.decided, { decision: a.allow ? TEXTS.allowed : TEXTS.refused, id: r.id, name: t.name }) + } + + /** The tree as clients show it (§12). */ + info(): TreeInfo { + const row = (a: TeamAgent): TreeAgent => ({ + id: a.id, + name: a.name, + position: a.role, + parent: a.manager, + state: a.state, + model: a.model, + mode: a.mode, + request: [...a.request], + manager: a.name === LEAD ? true : a.leads, + ...(a.manager ? { task: a.task } : {}), + ...(a.context ? { context: { ...a.context } } : {}), + progress: a.progress ?? null, + findings: a.findings.length, + messages: this.room.filter((m) => m.from === a.name || m.recipients.includes(a.name)).length, + ...(a.endedAt !== undefined ? { ended: a.endedAt } : {}), + }) + return { min: this.min, max: this.max, size: this.size(), open_slots: this.openSlots(), agents: this.tree().map(row), ended: this.ended.map(row) } + } + // ── 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/registry.ts b/src/tool/registry.ts index 632d329..848267b 100644 --- a/src/tool/registry.ts +++ b/src/tool/registry.ts @@ -11,7 +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 { teamAddTool, teamAssignTool, teamDecideTool, teamFindingsTool, teamFinishTool, teamReadTool, teamReplaceTool, teamRequestTool, teamSendTool, teamStopTool, teamWhoamiTool } from "./team_tools.ts" import { decisionsTool, tasksTool } from "./project_tools.ts" import { webFetchTool, webSearchTool } from "./web.ts" import { memoryTool } from "./memory.ts" @@ -46,6 +46,14 @@ export const BUILTIN_TOOLS: Tool[] = [ teamSendTool, teamReadTool, teamStopTool, + // Spec 3.4.0 (subagents.md §8). + teamWhoamiTool, + teamFindingsTool, + teamFinishTool, + teamRequestTool, + teamDecideTool, + teamAssignTool, + teamReplaceTool, planWriteTool, planUpdateTool, tasksTool, diff --git a/src/tool/team_tools.ts b/src/tool/team_tools.ts index 35441c1..d653bf4 100644 --- a/src/tool/team_tools.ts +++ b/src/tool/team_tools.ts @@ -1,5 +1,6 @@ -// 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 +// The team tools (harness spec 3.2.0, team.md; 3.4.0, subagents.md §5–§13; tools/team_*.json): +// add a subagent, send through the room, read the tree and the room, stop one — and from 3.4.0 +// whoami, findings, finish, request and decide, assign and replace. 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" @@ -37,6 +38,9 @@ export const teamAddTool = defineTool({ lead: z.boolean().optional(), agent: z.string().optional(), model: z.string().optional(), + parent: z.string().optional(), + mode: z.enum(["auto", "edit", "manual", "plan"]).optional(), + request: z.array(z.enum(["read", "write", "execute"])).optional(), }), invalid: badName, permission: (a) => ({ permission: "team_add", class: "interact", patterns: [a.agent ?? "general"] }), @@ -92,3 +96,113 @@ export const teamStopTool = defineTool({ return { output, title: t.runtime.team.size() < size ? `stopped ${a.name}` : "not stopped", isError: t.runtime.team.size() >= size } }, }) + +export const teamWhoamiTool = defineTool({ + ...described("team_whoami"), + purpose: false, + access: "team_whoami", + requires: "team", + schema: z.object({}), + permission: () => ({ permission: "team_whoami", class: "read", patterns: ["*"] }), + async run(_a, ctx) { + const t = teamOf(ctx) + return { output: t.runtime.team.whoami(t.as), title: t.as } + }, +}) + +export const teamFindingsTool = defineTool({ + ...described("team_findings"), + purpose: false, + access: "team_findings", + requires: "team", + schema: z.object({ action: z.enum(["write", "read"]), text: z.string().optional(), of: z.string().optional() }), + permission: (a) => ({ permission: "team_findings", class: "interact", patterns: [a.action] }), + async run(a, ctx) { + const t = teamOf(ctx) + if (a.action === "write") { + if (!a.text?.trim()) throw new ToolError("write needs `text`: the finding.") + const output = t.runtime.team.noteFinding(t.as, a.text) + t.runtime.notify() + return { output, title: "finding noted" } + } + return { output: t.runtime.team.readFindings(t.as, a.of), title: `findings of ${a.of ?? t.as}` } + }, +}) + +export const teamFinishTool = defineTool({ + ...described("team_finish"), + purpose: false, + access: "team_finish", + requires: "team", + schema: z.object({ report: z.string().min(1) }), + permission: () => ({ permission: "team_finish", class: "interact", patterns: ["*"] }), + async run(a, ctx) { + const t = teamOf(ctx) + const output = t.runtime.team.finishTask(t.as, a.report) + t.runtime.notify() + return { output, title: "finished" } + }, +}) + +export const teamRequestTool = defineTool({ + ...described("team_request"), + purpose: false, + access: "team_request", + requires: "team", + schema: z.object({ tool: z.string().min(1), pattern: z.string().min(1), why: z.string().min(1) }), + permission: () => ({ permission: "team_request", class: "interact", patterns: ["*"] }), + async run(a, ctx) { + const t = teamOf(ctx) + const before = t.runtime.team.requests.length + const output = t.runtime.team.request(t.as, a, t.runtime.classOf(a.tool)) + t.runtime.notify() + const asked = t.runtime.team.requests.length > before + return { output, title: asked ? `asked for ${a.tool} ${a.pattern}` : "not asked", isError: !asked } + }, +}) + +export const teamDecideTool = defineTool({ + ...described("team_decide"), + purpose: false, + access: "team_decide", + requires: "team", + schema: z.object({ id: z.string().min(1), allow: z.boolean(), note: z.string().optional() }), + permission: () => ({ permission: "team_decide", class: "interact", patterns: ["*"] }), + async run(a, ctx) { + const t = teamOf(ctx) + const output = t.runtime.team.decide(t.as, a, (tool) => t.runtime.permissionKeyOf(tool)) + t.runtime.notify() + return { output, title: `${a.allow ? "allowed" : "refused"} ${a.id}` } + }, +}) + +export const teamAssignTool = defineTool({ + ...described("team_assign"), + purpose: false, + access: "team_assign", + requires: "team", + schema: z.object({ name: z.string().min(1), task: z.string().min(1), role: z.string().optional(), parent: z.string().optional(), mode: z.enum(["auto", "edit", "manual", "plan"]).optional() }), + permission: (a) => ({ permission: "team_assign", class: "interact", patterns: [a.name] }), + async run(a, ctx) { + const t = teamOf(ctx) + const output = t.runtime.team.assign(t.as, a) + t.runtime.syncModes() + t.runtime.notify() + return { output, title: `to ${a.name}` } + }, +}) + +export const teamReplaceTool = defineTool({ + ...described("team_replace"), + purpose: false, + access: "team_replace", + requires: "team", + schema: z.object({ name: z.string().min(1), task: z.string().optional() }), + permission: (a) => ({ permission: "team_replace", class: "interact", patterns: [a.name] }), + async run(a, ctx) { + const t = teamOf(ctx) + const r = t.runtime.team.replace(t.as, a) + t.runtime.notify() + return { output: r.text, title: r.added ? `${a.name} → ${r.added.id}` : "not replaced", isError: !r.added } + }, +}) diff --git a/tests/conformance.ts b/tests/conformance.ts index fbc7a44..af73b01 100644 --- a/tests/conformance.ts +++ b/tests/conformance.ts @@ -28,7 +28,7 @@ import { compactAsk, noteFor, offeredFor, toldAt, unavailable, type Now, type Re import { envLines, promptText } from "../src/prompt/assemble.ts" import { absPath, type ToolContext } from "../src/tool/tool.ts" import { LOOP, TOOL_SPECS } from "../src/harness.ts" -import { Team, TEAM_TOOLS, type Capacity } from "../src/team/team.ts" +import { LEAD_TOOLS, memberTools, Team, TEAM_TOOLS, type Capacity } from "../src/team/team.ts" import { noteBlock } from "../src/session/prefix.ts" import { offer, subagentModel, type ModelInfo } from "../src/team/subagents.ts" import { progressLine, progressText, type Progress } from "../src/session/progress.ts" @@ -260,9 +260,12 @@ export const AREAS: Record unknown> = { // Teams (team.md): one lead session's team, step by step, through the CLI's own rules // (team/team.ts); the app drives the same class from real turns. team: (c) => { - const start = c.start as { min: number; max: number; subagents?: boolean; capacity: Capacity } + const start = c.start as { min: number; max: number; subagents?: boolean; capacity: Capacity; mode?: Mode } let capacity = start.capacity - const team = new Team({ min: start.min, max: start.max, subagents: start.subagents, model: "m", capacity: () => capacity }) + const team = new Team({ min: start.min, max: start.max, subagents: start.subagents, model: "m", capacity: () => capacity, mode: start.mode ?? "edit", steps: { main: 200, sub: 50 } }) + // What each agent's turn was started with (a brief, or the messages that woke it). + const started: Record = {} + team.effects = { start: (n, t) => void (started[n] = t), deliver: () => {}, stop: () => {} } const delivered = () => { const out: Record = {} for (const d of team.delivered.splice(0)) (out[d.name] ??= []).push(d.text) @@ -273,8 +276,8 @@ export const AREAS: Record unknown> = { switch (st.op) { case "offered": { const a = team.get(by)! - const tools = !team.offered ? [] : a.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"] - return { tools: tools.sort(), setting: team.offered } + const tools = !team.offered ? [] : by === "lead" ? [...LEAD_TOOLS] : memberTools(a) + return { tools: [...tools].sort(), setting: team.offered } } case "call": { const args = st.args as Record @@ -284,9 +287,18 @@ export const AREAS: Record unknown> = { const room = no ? [] : team.readRoom(by, args.since ?? 0).messages.map((m) => ({ n: m.n, from: m.from, to: m.to.join(", ") })) return { room, delivered: delivered() } } - if (st.tool === "team_add") result = team.add(by, args as any).text + const first = (TEAM_TOOLS as readonly string[]).includes(st.tool) ? team.refusal(by, st.tool, args) : undefined + if (first) result = first + else if (st.tool === "team_add") result = team.add(by, args as any).text else if (st.tool === "team_send") result = team.send(by, args as any) else if (st.tool === "team_stop") result = team.stopCmd(by, args.name) + else if (st.tool === "team_assign") result = team.assign(by, args as any) + else if (st.tool === "team_replace") result = team.replace(by, args as any).text + else if (st.tool === "team_finish") result = team.finishTask(by, args.report) + else if (st.tool === "team_whoami") result = team.whoami(by) + else if (st.tool === "team_findings") result = args.action === "write" ? team.noteFinding(by, args.text ?? "") : team.readFindings(by, args.of) + else if (st.tool === "team_request") result = team.request(by, args as any, TOOL_SPECS[args.tool]?.risk as any) + else if (st.tool === "team_decide") result = team.decide(by, args as any) else { result = team.refusal(by, st.tool, args) ?? null if (st.tool === "task" && result === null && team.offered) team.taskStart(by) @@ -313,6 +325,23 @@ export const AREAS: Record unknown> = { } case "state": return team.state() + // Spec 3.4.0. + case "context": + team.setContext(by, st.used, st.window) + return null + case "context_full": + team.pause(by) + return { delivered: delivered() } + case "started": + return { text: started[by] ?? null } + case "tree": { + const t = team.info() + const row = (a: (typeof t.agents)[number]) => ({ id: a.id, name: a.name, parent: a.parent, state: a.state, mode: a.mode, request: a.request }) + return { size: t.size, open_slots: t.open_slots, agents: t.agents.map(row), ended: t.ended.map((a) => ({ id: a.id, name: a.name, state: a.state })) } + } + case "main_mode": + team.setMainMode(st.mode) + return null } throw new Error(`team: unknown op ${String(st.op)}`) }) diff --git a/tests/harness.test.ts b/tests/harness.test.ts index 769c0de..bb4bdb1 100644 --- a/tests/harness.test.ts +++ b/tests/harness.test.ts @@ -153,7 +153,7 @@ test("spec 3.0.0: every tool says where it is offered; the CLI's are the agent's // plan_write's former name rather than a tool. expect(TOOL_SPECS.todo).toBeUndefined() expect(TOOL_SPECS.plan_write!.aliases.cli).toEqual(["todo"]) - const shared = ["ask_user", "edit", "glob", "grep", "job_list", "job_output", "job_send", "job_stop", "list", "memory", "plan_submit", "plan_update", "plan_write", "read", "recall", "report", "session_search", "show", "skill_manage", "skill_view", "skills_list", "task", "team_add", "team_read", "team_send", "team_stop", "web_fetch", "web_search", "write"] + const shared = ["ask_user", "edit", "glob", "grep", "job_list", "job_output", "job_send", "job_stop", "list", "memory", "plan_submit", "plan_update", "plan_write", "read", "recall", "report", "session_search", "show", "skill_manage", "skill_view", "skills_list", "task", "team_add", "team_assign", "team_decide", "team_findings", "team_finish", "team_read", "team_replace", "team_request", "team_send", "team_stop", "team_whoami", "web_fetch", "web_search", "write"] expect(Object.values(TOOL_SPECS).filter((t) => t.scope === "shared").map((t) => t.name).sort()).toEqual(shared.sort()) }) diff --git a/tests/team.test.ts b/tests/team.test.ts index a57ecf6..f5f0217 100644 --- a/tests/team.test.ts +++ b/tests/team.test.ts @@ -26,7 +26,7 @@ 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 isMember = (body: any, name: string) => new RegExp(`You are "${name}" \\(s\\d+\\), a subagent`).test(system(body)) 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("") @@ -95,7 +95,7 @@ describe("a team at work (team.md)", () => { expect(t.get("parser")!.state).toBe("finished") expect(t.isManager("lead")).toBe(true) expect(teamLabel(t)).toBe("agents 2/4") - expect(teamText(app, "")).toContain("- parser — the parser · finished") + expect(teamText(app, "")).toContain("- parser (s1) — 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)