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
ci / check (push) Waiting to run

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 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 08:21:29 +00:00
1 parent b66891e186
commit 8c5edc0f01
15 files changed
+951 -77

No files matched your search

+50 -15
View File
@@ -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<Mode, number> = { 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<string>()
+4 -1
View File
@@ -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
+11 -3
View File
@@ -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<string, string>
/** 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<string, unknown>, narrow: Na
}
export const TOOL_SPECS: Record<string, ToolSpecDef> = 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
+8 -4
View File
@@ -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<string, string> = {
"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(),
+2 -1
View File
@@ -54,7 +54,8 @@ export async function* retrying(attempt: () => AsyncGenerator<StreamEvent>, 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
}
+1 -1
View File
@@ -54,7 +54,7 @@ export type StreamEvent =
| { type: "progress"; total: number; cache: number; processed: number; ms: number }
| { type: "finish"; reason: string; message: Extract<Message, { role: "assistant" }> }
/** 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
+56 -10
View File
@@ -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<boolean>
/** 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<PermContext, "mode">): 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<T>(p: Promise<T>): Promise<T> {
private async waiting<T>(p: Promise<T>, what: "approval" | "question" | "plan" = "approval", tool?: string): Promise<T> {
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: {
+136
View File
@@ -102,3 +102,139 @@ export function samePhase(a: Progress | null | undefined, b: Omit<Progress, "sin
if (b.phase === "retrying") return a.attempt === b.attempt && a.reason === b.reason
return true
}
/** What a tool call does, in a line (`what` of running_tool): the command, the path, the query. */
export function callWhat(args: unknown): string | undefined {
if (!args || typeof args !== "object") return undefined
const a = args as Record<string, unknown>
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<string, Omit<Progress, "since">>()
private lastEmit = 0
private pendingTimer: ReturnType<typeof setTimeout> | 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<Progress, "since"> | 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<Progress, "since">): () => 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<string, unknown>
const p: Omit<Progress, "since"> = 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 })
}
}
}
+98 -5
View File
@@ -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<string, MemberHandle>()
/** 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<string>()
private finals = new Map<string, string>()
private pace: ReturnType<typeof setTimeout> | 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
+422 -25
View File
@@ -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<string, string>
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 — <text>`. */
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
+9 -1
View File
@@ -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,
+116 -2
View File
@@ -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 }
},
})
+35 -6
View File
@@ -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<string, (c: Case) => 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<string, string> = {}
team.effects = { start: (n, t) => void (started[n] = t), deliver: () => {}, stop: () => {} }
const delivered = () => {
const out: Record<string, string[]> = {}
for (const d of team.delivered.splice(0)) (out[d.name] ??= []).push(d.text)
@@ -273,8 +276,8 @@ export const AREAS: Record<string, (c: Case) => 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<string, any>
@@ -284,9 +287,18 @@ export const AREAS: Record<string, (c: Case) => 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<string, (c: Case) => 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)}`)
})
+1 -1
View File
@@ -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())
})
+2 -2
View File
@@ -26,7 +26,7 @@ const yes = { ask: async (): Promise<AskReply> => ({ kind: "once" }) }
const say = (text: string): Scripted => ({ chunks: [delta({ content: text }, "stop")] })
const call = (id: string, name: string, args: unknown): Scripted => ({ chunks: [toolCall(0, id, name, JSON.stringify(args)), delta({}, "tool_calls")] })
const system = (body: any): string => body.messages[0].content
const isMember = (body: any, name: string) => system(body).includes(`You are "${name}", a member of a team`)
const 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)