ci / check (push) Waiting to run
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>
198 lines
6.9 KiB
TypeScript
198 lines
6.9 KiB
TypeScript
// 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)
|
|
}
|
|
}
|