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
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:
15 files changed
+951
-77
No files matched your search
+50
-15
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user