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
@@ -0,0 +1,197 @@
|
||||
// A lead session's team at work (harness spec 3.2.0, team.md): the rules of team.ts driven by real
|
||||
// turns. The lead is the session's own engine; each member is a subagent engine of its own (the job
|
||||
// machinery, app.ts spawn), started when the team gives it a slot. What the rules decide happens
|
||||
// through three effects: a turn starts (a member's brief, a woken agent's messages), a message reaches
|
||||
// a working agent at its next step, an agent is stopped.
|
||||
import type { Bus } from "../bus/index.ts"
|
||||
import { TOOL_SPECS } from "../harness.ts"
|
||||
import type { Engine } from "../session/engine.ts"
|
||||
import type { Tool } from "../tool/tool.ts"
|
||||
import { parallelFrom, Team, TEAM, type TeamAgent } from "./team.ts"
|
||||
|
||||
/** A member's engine, as the app made it. */
|
||||
export interface MemberHandle {
|
||||
engine: Engine
|
||||
/** Its next turn, with this text as the prompt (its context intact). */
|
||||
run(text: string): void
|
||||
/** Stop it where it is; nothing more comes from it. */
|
||||
cancel(): void
|
||||
}
|
||||
|
||||
export interface RuntimeOptions {
|
||||
team: Team
|
||||
/** The lead's bus: where the team's news is said (`team` events). */
|
||||
bus: Bus
|
||||
/** Make a member's engine (attached here with `attach`) and start its first turn with `text`. */
|
||||
spawnMember(member: TeamAgent, text: string): void
|
||||
/** The lead: a message at its next step (`note`), or — idle — a turn of its own (`wake`). */
|
||||
lead: { note(text: string): void; wake(text: string): void }
|
||||
/** An unknown agent or model, answered as task answers it; undefined when both are known. The
|
||||
* model's ref as the session knows it. */
|
||||
resolve?(agent: string, model: string | undefined): { error: string } | { model: string }
|
||||
}
|
||||
|
||||
export class TeamRuntime {
|
||||
team: Team
|
||||
private members = new Map<string, MemberHandle>()
|
||||
/** The lead's turn waiting for a slot: resolved when it is given one. */
|
||||
private leadWaiter: (() => void) | undefined
|
||||
|
||||
constructor(private o: RuntimeOptions) {
|
||||
this.team = o.team
|
||||
this.wire()
|
||||
}
|
||||
|
||||
private wire() {
|
||||
this.team.effects = {
|
||||
start: (name, text) => this.start(name, text),
|
||||
deliver: (name, text) => this.deliver(name, text),
|
||||
stop: (name) => this.stopped(name),
|
||||
}
|
||||
}
|
||||
|
||||
/** Another session: its members are stopped, and the team starts again, empty. */
|
||||
reset(team: Team) {
|
||||
this.stopAll()
|
||||
this.leadWaiter = undefined
|
||||
this.team = team
|
||||
this.wire()
|
||||
this.changed()
|
||||
}
|
||||
|
||||
private changed() {
|
||||
this.o.bus.emit({ type: "team" })
|
||||
}
|
||||
|
||||
/** Something the tools did changed the team: said, for whoever shows it. */
|
||||
notify() {
|
||||
this.changed()
|
||||
}
|
||||
|
||||
/** team_add for `as`: an unknown agent or model is answered as task answers it, after the
|
||||
* team's own refusals (the name, the goal, the size), which come first. */
|
||||
add(as: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string }): { text: string; added?: TeamAgent } {
|
||||
const agent = a.agent ?? "general"
|
||||
const r = this.o.resolve?.(agent, a.model) ?? { model: a.model ?? this.team.get(as)?.model ?? this.team.model }
|
||||
// The team's refusals first; an added member needs a known agent and model.
|
||||
const dry = this.team.wouldRefuse(as, a)
|
||||
if (dry) return { text: dry }
|
||||
if ("error" in r) return { text: r.error }
|
||||
const out = this.team.add(as, { ...a, agent, model: r.model })
|
||||
this.changed()
|
||||
return out
|
||||
}
|
||||
|
||||
private start(name: string, text: string) {
|
||||
if (name === TEAM.names.lead) {
|
||||
const waiter = this.leadWaiter
|
||||
this.leadWaiter = undefined
|
||||
if (waiter) {
|
||||
// The person's turn was waiting: it goes now, and what woke it meanwhile goes in at its first step.
|
||||
if (text) this.o.lead.note(text)
|
||||
waiter()
|
||||
} else if (text) this.o.lead.wake(text)
|
||||
this.changed()
|
||||
return
|
||||
}
|
||||
const known = this.members.get(name)
|
||||
if (known) known.run(text)
|
||||
else {
|
||||
const m = this.team.get(name)
|
||||
if (m) this.o.spawnMember(m, text)
|
||||
}
|
||||
this.changed()
|
||||
}
|
||||
|
||||
private deliver(name: string, text: string) {
|
||||
if (name === TEAM.names.lead) this.o.lead.note(text)
|
||||
else this.members.get(name)?.engine.note(text)
|
||||
}
|
||||
|
||||
private stopped(name: string) {
|
||||
const h = this.members.get(name)
|
||||
this.members.delete(name)
|
||||
h?.cancel()
|
||||
this.changed()
|
||||
}
|
||||
|
||||
/** The lead's turn is about to run: it holds a slot, or waits for one (the lead first). False
|
||||
* when it was stopped while it waited. */
|
||||
async beforeLeadTurn(signal: AbortSignal): Promise<boolean> {
|
||||
const lead = this.team.get(TEAM.names.lead)!
|
||||
if (lead.state === "working") return true
|
||||
if (this.team.leadWants()) {
|
||||
this.changed()
|
||||
return true
|
||||
}
|
||||
this.o.bus.emit({ type: "notice", message: `waiting for a free slot: the model serves ${parallelFrom(this.team.capacityOf(this.team.model))} requests at once and the team's members hold them` })
|
||||
this.changed()
|
||||
return await new Promise<boolean>((resolve) => {
|
||||
const onAbort = () => {
|
||||
this.leadWaiter = undefined
|
||||
this.team.leadIdle()
|
||||
this.changed()
|
||||
resolve(false)
|
||||
}
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
this.leadWaiter = () => {
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** The lead's turn ended. */
|
||||
leadDone() {
|
||||
this.team.finish(TEAM.names.lead)
|
||||
this.changed()
|
||||
}
|
||||
|
||||
/** A member's turn ended: its reply is its report; messages it was given too late wake it again. */
|
||||
memberDone(name: string, reply: string, reason: string, leftover: string[]) {
|
||||
if (!this.team.get(name)) return
|
||||
this.team.finish(name, reply, reason)
|
||||
if (leftover.length) this.team.undelivered(name, leftover)
|
||||
this.changed()
|
||||
}
|
||||
|
||||
/** What a call answers before its permission check, for the agent `as`. */
|
||||
refuse(as: string, tool: Tool, args: unknown): string | undefined {
|
||||
if (tool.name === "task" && as !== TEAM.names.lead) return undefined
|
||||
// A tool the spec does not have (an MCP server's) by the class its call carries: read when it
|
||||
// says readOnlyHint, interact for the server's memory, else execute.
|
||||
let risk: string | undefined
|
||||
if (!TOOL_SPECS[tool.name]) {
|
||||
try {
|
||||
risk = tool.permission(args as never, {} as never).class
|
||||
} catch {
|
||||
risk = "execute"
|
||||
}
|
||||
}
|
||||
return this.team.refusal(as, tool.name, args, risk)
|
||||
}
|
||||
|
||||
/** The person stopped a member in the UI (/team stop). */
|
||||
personStop(name: string): string[] {
|
||||
const gone = this.team.personStop(name)
|
||||
this.changed()
|
||||
return gone
|
||||
}
|
||||
|
||||
/** Stop every member: the lead's session ends or is left. */
|
||||
stopAll() {
|
||||
for (const m of this.team.membersOf(TEAM.names.lead)) this.team.personStop(m.name)
|
||||
this.team.forgetStopped()
|
||||
for (const h of this.members.values()) h.cancel()
|
||||
this.members.clear()
|
||||
}
|
||||
|
||||
/** A member's engine, made. */
|
||||
attach(name: string, h: MemberHandle) {
|
||||
this.members.set(name, h)
|
||||
}
|
||||
|
||||
handle(name: string): MemberHandle | undefined {
|
||||
return this.members.get(name)
|
||||
}
|
||||
}
|
||||
+28
-12
@@ -156,18 +156,22 @@ export class Team {
|
||||
return undefined
|
||||
}
|
||||
|
||||
capacityOf(model: string): Capacity {
|
||||
return this.o.capacity(model)
|
||||
}
|
||||
|
||||
/** How many requests the lead's model serves at once now. */
|
||||
parallelNow(): number {
|
||||
return parallelFrom(this.o.capacity(this.model))
|
||||
}
|
||||
|
||||
/** §2: offered when subagents are on and the chat's model serves two at once — fixed as the
|
||||
* prompt is made (and made again on a model switch). */
|
||||
fix(model = this.model): boolean {
|
||||
* prompt is made (and made again on a model switch); `offered` is a kept prompt's decision. */
|
||||
fix(model = this.model, offered?: boolean): boolean {
|
||||
this.model = model
|
||||
const lead = this.agents.get(LEAD)
|
||||
if (lead) lead.model = model
|
||||
this.offered = this.subagents && this.parallelNow() >= 2
|
||||
this.offered = offered ?? (this.subagents && this.parallelNow() >= 2)
|
||||
return this.offered
|
||||
}
|
||||
|
||||
@@ -338,17 +342,24 @@ export class Team {
|
||||
|
||||
// ── the tools ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** team_add's refusals, in the spec's order (§4), or undefined when it would add. */
|
||||
wouldRefuse(by: string, a: { name: string; goal?: string }): string | undefined {
|
||||
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||
if (no) return no
|
||||
if (a.name === "all") return TEXTS.all_name
|
||||
if (!NAME.test(a.name)) return fill(TEXTS.bad_name, { name: a.name })
|
||||
if (this.agents.has(a.name)) return fill(TEXTS.taken, { name: a.name })
|
||||
if (by === LEAD && !this.agents.get(LEAD)!.goal && !a.goal?.trim()) return TEXTS.no_goal
|
||||
if (this.size() + 1 > this.max) return fill(TEXTS.full, { max: this.max })
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** team_add (§4). `brief` is what the member's first turn is given. */
|
||||
add(by: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string }): { text: string; added?: TeamAgent } {
|
||||
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
|
||||
const no = this.wouldRefuse(by, a)
|
||||
if (no) return { text: no }
|
||||
if (a.name === "all") return { text: TEXTS.all_name }
|
||||
if (!NAME.test(a.name)) return { text: fill(TEXTS.bad_name, { name: a.name }) }
|
||||
if (this.agents.has(a.name)) return { text: fill(TEXTS.taken, { name: a.name }) }
|
||||
const lead = this.agents.get(LEAD)!
|
||||
const goal = a.goal?.trim()
|
||||
if (by === LEAD && !lead.goal && !goal) return { text: TEXTS.no_goal }
|
||||
if (this.size() + 1 > this.max) return { text: fill(TEXTS.full, { max: this.max }) }
|
||||
const caller = this.agents.get(by) ?? lead
|
||||
if (by === LEAD && goal) lead.goal = goal
|
||||
const member: TeamAgent = {
|
||||
@@ -511,13 +522,18 @@ export class Team {
|
||||
this.fillSlots()
|
||||
}
|
||||
|
||||
/** The lead's turn is starting without having asked (the engine's own): it holds a slot. */
|
||||
leadWorking() {
|
||||
/** The lead stopped waiting (the person pressed Esc): idle, out of the queue. */
|
||||
leadIdle() {
|
||||
const lead = this.agents.get(LEAD)!
|
||||
lead.state = "working"
|
||||
if (lead.state === "waiting") lead.state = "idle"
|
||||
lead.waitSince = undefined
|
||||
}
|
||||
|
||||
/** Nothing of the stopped members is said: the session they were in is gone. */
|
||||
forgetStopped() {
|
||||
this.stoppedByPerson = []
|
||||
}
|
||||
|
||||
// ── what the lead is told ───────────────────────────────────────────────────
|
||||
|
||||
/** §10: the note's entries for the lead's next user message — the size when it was not said yet
|
||||
|
||||
Reference in new issue
Block a user