The four team tools (tool/team_tools.ts) on the rules of team/team.ts, driven from real turns by team/runtime.ts: - Members run on the subagent job machinery (app.ts spawn with `member`): background, unattended, their own prompt from blocks/team_member.md (the whole of team_lead.md for one with lead: true), team_send and team_read (team_add and team_stop for one that leads). Not jobs to the model: job_list and the job_* tools skip them; a client over the link still sees them as subagent jobs named "<name>: <role>". - The room: messages and reports with their marks, delivered at the next step boundary or waking a finished member or the idle lead; report posts to the manager. - A manager's work calls answer the refusal before the permission check, by risk class (an MCP tool by its readOnlyHint); the tool list is unchanged. job_stop and reading bash stay. - Slots: the lead waits for one before its turn (Esc stops the wait); members wait in order; task helpers in a team count toward the size and hold a slot; job_send restarting a helper is refused when full. - The offer is decided with the prompt and kept with it: a resumed session takes it from its kept tool list. - The size: agents.min/max settings, refused outside 1 <= min <= max <= cap, kept across /new and resume; said in a note on the first message, after a change, after a compaction and a rewind. - TUI: /team (tree and room, size, stop a member, dialogs for both), an "agents" row beside the effort in /effort, "team n/max" in the status bar. /team works over ACP too. tests/team.test.ts runs a lead, a member and its report through the app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
21 files changed
+834
-45
No files matched your search
+48
-2
@@ -63,6 +63,21 @@ export interface EngineOptions {
|
||||
/** What a message sent mid-turn starts with: the user's words for a session, the main agent's for
|
||||
* a subagent (loop.json jobs.marks to_subagent). */
|
||||
steerMark?: string
|
||||
/** The team this engine's agent is on (spec 3.2.0, team.md): whether it is offered (decided as
|
||||
* the prompt is made, for `model`), what a call answers before its permission check (the team
|
||||
* tools' requirement, task's size, a manager's work), the lead's note lines, and — the lead —
|
||||
* a slot of the model before a turn runs. */
|
||||
team?: {
|
||||
/** Decide whether the team is offered for a new prompt on `model` — or, for a prompt the
|
||||
* session kept (a resume), take what it decided then (`offered`). */
|
||||
fix(model: string, offered?: boolean): boolean
|
||||
offered(): boolean
|
||||
refuse(tool: Tool, args: unknown): string | undefined
|
||||
note?(): string[]
|
||||
/** What the lead was told of the team is to be said again: a new prompt, or a rewind. */
|
||||
forget?(): void
|
||||
beforeTurn?(signal: AbortSignal): Promise<boolean>
|
||||
}
|
||||
}
|
||||
|
||||
/** What a prompt carries besides its text. */
|
||||
@@ -168,6 +183,8 @@ export class Engine {
|
||||
private keptBase: Told | undefined
|
||||
/** The last request's usage, cached input tokens included, and what it was for. */
|
||||
lastRequest: { usage: Usage; kind: "step" | "compaction" } | undefined
|
||||
/** The prompt the team's offer was last decided for. */
|
||||
private teamFixed: Kept | undefined
|
||||
|
||||
constructor(readonly o: EngineOptions) {
|
||||
this.mode = o.mode
|
||||
@@ -201,6 +218,9 @@ export class Engine {
|
||||
if (this.model.spec.vision === true) could.add("vision")
|
||||
if (this.o.toolCtx.projectDir !== undefined) could.add("project")
|
||||
if (this.o.toolCtx.show !== undefined) could.add("display")
|
||||
// The team tools' requirement: offered as the prompt is made; whether it is met is the team's
|
||||
// to answer at each call (team.md §2: one_agent or one_request).
|
||||
if (this.o.team?.offered()) could.add("team")
|
||||
const L = this.o.toolCtx.library
|
||||
const have = L?.lib.bases().filter((b) => b.documents > 0).map((b) => b.name) ?? []
|
||||
if (have.some((b) => !L!.bases || L!.bases.includes(b))) could.add("knowledge")
|
||||
@@ -223,7 +243,20 @@ export class Engine {
|
||||
|
||||
/** The session's prefix, made now if there is none yet (or it was made for another model). */
|
||||
prefix(): Kept {
|
||||
if (this.kept && this.kept.model === this.model.ref) return this.kept
|
||||
if (this.kept && this.kept.model === this.model.ref) {
|
||||
// A kept prompt (a resumed session) decided whether the team is offered when it was made: its
|
||||
// tool list says so (team.md §2: fixed for as long as the prompt is).
|
||||
if (this.teamFixed !== this.kept) {
|
||||
this.teamFixed = this.kept
|
||||
this.o.team?.fix(this.model.ref, !this.kept.left.includes("team_add"))
|
||||
}
|
||||
return this.kept
|
||||
}
|
||||
// Whether a team is offered is decided here, with the prompt and the tool list (team.md §2).
|
||||
this.o.team?.fix(this.model.ref)
|
||||
// After a compaction the size is said again, as the date is: neither is in the prompt. A model
|
||||
// switch keeps the conversation, and what it said.
|
||||
if (!this.kept) this.o.team?.forget?.()
|
||||
const changing = this.o.changing?.() ?? NO_CHANGING
|
||||
const now = this.now(changing)
|
||||
const could = this.could()
|
||||
@@ -235,6 +268,7 @@ export class Engine {
|
||||
if (this.kept?.told.date) told.date = this.kept.told.date
|
||||
this.keptBase = { ...structuredClone(told), date: undefined }
|
||||
this.kept = { system: this.o.system(this.mode, this.model, this.plan, changing), left, model: this.model.ref, told }
|
||||
this.teamFixed = this.kept
|
||||
this.saveKept()
|
||||
return this.kept
|
||||
}
|
||||
@@ -261,7 +295,7 @@ export class Engine {
|
||||
* the model's from then on. */
|
||||
private takeNote(): string {
|
||||
const kept = this.prefix()
|
||||
const { note, told } = noteFor(kept.told, this.now(), (m) => this.o.modeText?.(m) ?? fillPrompt(promptText(`modes/${m}.md`), { plan_dir: this.o.toolCtx.planDir ?? "" }))
|
||||
const { note, told } = noteFor(kept.told, this.now(), (m) => this.o.modeText?.(m) ?? fillPrompt(promptText(`modes/${m}.md`), { plan_dir: this.o.toolCtx.planDir ?? "" }), this.o.team?.note?.() ?? [])
|
||||
if (note) {
|
||||
kept.told = told
|
||||
this.saveKept()
|
||||
@@ -282,6 +316,7 @@ export class Engine {
|
||||
rewound() {
|
||||
if (!this.kept || !this.keptBase) return
|
||||
this.kept.told = structuredClone(this.keptBase)
|
||||
this.o.team?.forget?.()
|
||||
this.saveKept()
|
||||
}
|
||||
|
||||
@@ -596,6 +631,11 @@ export class Engine {
|
||||
// Before a session's first request: the configured MCP servers get a moment to connect, so
|
||||
// their tools are in the first list (§10 rule 4).
|
||||
if (!this.kept || this.kept.model !== this.model.ref) await this.o.beforeFirst?.()
|
||||
// A team's lead holds a slot of its model while its turn runs (team.md §8): it may wait for one.
|
||||
if (this.o.team?.beforeTurn && !(await this.o.team.beforeTurn(signal))) {
|
||||
bus.emit({ type: "done", reason: "cancelled" })
|
||||
return "cancelled"
|
||||
}
|
||||
// What changed since the prompt was made goes after the person's words, as a note (§10 rule 3).
|
||||
const note = this.takeNote()
|
||||
this.push({
|
||||
@@ -878,6 +918,7 @@ export class Engine {
|
||||
const resolved = resolveCall(call.name, raw)
|
||||
tool = toolMap.get(resolved.name) ?? tool
|
||||
raw = resolved.raw
|
||||
const team = this.o.team
|
||||
// Offered for the whole session; while what it needs is not met, the spec's sentence (§10 rule 4).
|
||||
const no = unavailable(tool.name, tool.requires ? [tool.requires] : undefined, this.met())
|
||||
if (no) return { result: { output: no, isError: true } }
|
||||
@@ -888,10 +929,15 @@ export class Engine {
|
||||
const { args: own, purpose } = splitPurpose(tool, raw)
|
||||
const parsed = tool.schema.safeParse(own)
|
||||
if (!parsed.success) {
|
||||
const spoken = tool.invalid?.(own, { ...this.o.toolCtx, signal: new AbortController().signal })
|
||||
if (spoken) return { result: { output: spoken, isError: true } }
|
||||
const why = parsed.error.issues.map((i) => `${i.path.join(".") || "(arguments)"}: ${i.message}`).join("; ")
|
||||
return { result: { output: `Invalid arguments for ${tool.name}: ${why}`, isError: true } }
|
||||
}
|
||||
let args = parsed.data
|
||||
// A team's answers come before the permission check — nothing is asked (team.md §6).
|
||||
const byTeam = team?.refuse(tool, args)
|
||||
if (byTeam) return { result: { output: byTeam, isError: true } }
|
||||
const toolCtx: ToolContext = { ...this.o.toolCtx, signal: new AbortController().signal }
|
||||
let request = tool.permission(args, toolCtx)
|
||||
let edited: string | undefined
|
||||
|
||||
Reference in new issue
Block a user