Files
LLeMbas-CLI/src/team/subagents.ts
T
HomerandClaude Opus 5.5 6e0582dc22
ci / check (push) Waiting to run
Subagents in the CLI (harness spec 3.3.0): the model, capacity, the offer, /context
- A task subagent or a team member runs on the session's model unless the
  person named another in this session's own messages (team/subagents.ts;
  engine.personSaid reads every prompt and mid-turn message, compacted ones
  included). Not named: refused; an agent file's own model is not used.
- Another model on the session's one-model server is refused; a one-request
  session model is not its own subagent, and nothing else is picked.
- The team is offered when a model could serve: the session's with two
  slots, or any usable model off its one-model server; the size note adds
  team_own when the session's model cannot serve its own.
- "Subagents" in /effort, /team, /settings and the config schema; the status
  bar reads "agents 3/4 · 2 subagents working".
- /context: used of the window, one bar by category, each category's share,
  what is free, and "N until auto-compact".
- Conformance runner for the subagents area (26 cases); helper -> task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 17:04:58 +00:00

102 lines
4.3 KiB
TypeScript

// Subagents (harness spec 3.3.0, harness/subagents.md): which model a subagent — a task subagent or
// a team's member — runs on, and whether subagents are offered at all. The rules alone, without an
// engine: the conformance area `subagents` drives them, and the app (spawn, team_add) asks them.
import { LOOP } from "../harness.ts"
import type { Message } from "../provider/types.ts"
import { parallelFrom, type Capacity } from "./team.ts"
export const SUBAGENTS = LOOP.subagents
const TEXTS = SUBAGENTS.texts
/** A model the person may use: its served id (the ref), its id at its connection, its display name. */
export interface ModelInfo {
ref: string
id?: string
name?: string
capacity: Capacity
}
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const BEFORE = new RegExp(`${SUBAGENTS.named.before}$`, "u")
const AFTER = new RegExp(`^(?:${SUBAGENTS.named.after})`, "u")
/** The names a model goes by: the served id, the part after its provider, the display name. */
export function namesOf(m: Pick<ModelInfo, "ref" | "id" | "name">): string[] {
const out = [m.ref]
if (m.id) out.push(m.id)
const slash = m.ref.lastIndexOf("/")
if (slash >= 0) out.push(m.ref.slice(slash + 1))
if (m.name?.trim()) out.push(m.name.trim())
return [...new Set(out.filter(Boolean))]
}
/** Whether `name` occurs in `text` as a whole word, without case (subagents.md §2). White space in
* the name matches any run of white space. */
export function occursAsWord(text: string, name: string): boolean {
const pattern = name
.trim()
.split(/\s+/)
.map(escape)
.join("\\s+")
if (!pattern) return false
const re = new RegExp(pattern, "giu")
for (let m = re.exec(text); m; m = re.exec(text)) {
const before = text.slice(0, m.index)
const after = text.slice(m.index + m[0].length)
if (!BEFORE.test(before) && !AFTER.test(after)) return true
re.lastIndex = m.index + 1
}
return false
}
/** Whether the person named the model in any of the messages they typed. */
export function named(m: Pick<ModelInfo, "ref" | "id" | "name">, said: readonly string[]): boolean {
const names = namesOf(m)
return said.some((t) => names.some((n) => occursAsWord(t, n)))
}
/** What the person typed in a session, oldest first: their prompts and what they sent mid-turn
* (after `steerMark`) — never a note, a job's or a team's marked message, or a machine turn. */
export function personTexts(messages: readonly Message[], steerMark: string): string[] {
const out: string[] = []
for (const m of messages) {
if (m.role !== "user" || m.machine) continue
const first = m.parts[0]
if (!first || first.type !== "text") continue
if (m.turn === false) {
if (first.text.startsWith(steerMark)) out.push(first.text.slice(steerMark.length).trim())
continue
}
out.push(first.text)
}
return out
}
/** Whether a model can be its own session's subagent: it serves two requests at once, or has no limit. */
export function servesOwn(c: Capacity): boolean {
return parallelFrom(c) >= 2
}
/** subagents.md §4: whether subagents are offered for a session on `session`, given the models the
* person may use, and whether the session's model can serve its own. */
export function offer(session: ModelInfo, models: readonly ModelInfo[], subagents = true): { offered: boolean; own: boolean } {
const own = servesOwn(session.capacity)
const group = session.capacity.group
const other = models.some((m) => m.ref !== session.ref && (group === undefined || m.capacity.group !== group))
return { offered: subagents && (own || other), own }
}
/** subagents.md §2, §3: the model a subagent runs on — the session's, or `wanted` (already resolved
* to a model the person may use) — or the refusal, word for word. */
export function subagentModel(session: ModelInfo, wanted: ModelInfo | undefined, said: readonly string[]): { model: string } | { refused: string } {
const m = wanted ?? session
if (m.ref !== session.ref) {
if (!named(m, said)) return { refused: TEXTS.not_named }
const group = session.capacity.group
if (group !== undefined && m.capacity.group === group) return { refused: TEXTS.same_group }
return { model: m.ref }
}
if (!servesOwn(session.capacity)) return { refused: TEXTS.one_request }
return { model: session.ref }
}