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
+140
-11
@@ -45,6 +45,8 @@ import { LIBRARY_SERVER, mcpInstructions } from "./mcp/index.ts"
|
|||||||
import type { McpServer } from "./config/schema.ts"
|
import type { McpServer } from "./config/schema.ts"
|
||||||
import { embedderFor } from "./library/embed.ts"
|
import { embedderFor } from "./library/embed.ts"
|
||||||
import { isLevel, levelsFor, readLevel, type Level } from "./provider/effort.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 { TeamRuntime, type MemberHandle } from "./team/runtime.ts"
|
||||||
|
|
||||||
export interface AppOptions {
|
export interface AppOptions {
|
||||||
cwd: string
|
cwd: string
|
||||||
@@ -149,6 +151,8 @@ export interface App {
|
|||||||
context: SessionContext
|
context: SessionContext
|
||||||
/** Replace it (`{}`: none), naming the instance it came from for the prompt's heading. */
|
/** Replace it (`{}`: none), naming the instance it came from for the prompt's heading. */
|
||||||
setContext(ctx: SessionContext, from?: string): void
|
setContext(ctx: SessionContext, from?: string): void
|
||||||
|
/** The session's team (spec 3.2.0, team.md): its rules and its members at work. */
|
||||||
|
team: TeamRuntime
|
||||||
/** The chat a session the client opened belongs to (its id): sent with every call to the server's
|
/** The chat a session the client opened belongs to (its id): sent with every call to the server's
|
||||||
* memory, which answers in that chat's data group (harness/acp.md *Memory*); undefined for a
|
* memory, which answers in that chat's data group (harness/acp.md *Memory*); undefined for a
|
||||||
* session of this device's own. The memory for the prompt is read again in it. */
|
* session of this device's own. The memory for the prompt is read again in it. */
|
||||||
@@ -360,7 +364,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
if (src.instance) named[`Instructions from ${src.instance.name}`] = src.instance.instructions
|
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 }
|
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) => {
|
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 src = ch.source as Source
|
const src = ch.source as Source
|
||||||
return assembleSystem({
|
return assembleSystem({
|
||||||
modelRef: current.ref,
|
modelRef: current.ref,
|
||||||
@@ -392,6 +396,9 @@ export function createApp(o: AppOptions): App {
|
|||||||
library: libraryBlock(),
|
library: libraryBlock(),
|
||||||
prototypes: prototypesBlock(),
|
prototypes: prototypesBlock(),
|
||||||
context: subagent ? subagent.recall : true,
|
context: subagent ? subagent.recall : true,
|
||||||
|
...(member ? { member } : {}),
|
||||||
|
// The lead's prompt says how to lead whenever the team tools are offered (team.md §10).
|
||||||
|
teamLead: !subagent && teamRt.team.offered,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/** A mode's text for a note, as the prompt would have it. */
|
/** A mode's text for a note, as the prompt would have it. */
|
||||||
@@ -417,6 +424,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
const inWords = (sec: number) => (sec % 60 === 0 ? `${sec / 60} minute${sec === 60 ? "" : "s"}` : `${sec} seconds`)
|
const inWords = (sec: number) => (sec % 60 === 0 ? `${sec / 60} minute${sec === 60 ? "" : "s"}` : `${sec} seconds`)
|
||||||
const vars: Record<string, Record<string, string>> = {
|
const vars: Record<string, Record<string, string>> = {
|
||||||
task: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") },
|
task: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") },
|
||||||
|
team_add: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") },
|
||||||
bash: {
|
bash: {
|
||||||
timeout: inWords(loaded.config.limits?.bash_timeout ?? 120),
|
timeout: inWords(loaded.config.limits?.bash_timeout ?? 120),
|
||||||
timeout_max: inWords(600),
|
timeout_max: inWords(600),
|
||||||
@@ -464,11 +472,15 @@ export function createApp(o: AppOptions): App {
|
|||||||
// The model asked for (task's `model`), else the agent's own, else the session's — and whether
|
// The model asked for (task's `model`), else the agent's own, else the session's — and whether
|
||||||
// its server can take it beside the session's and beside every subagent already running
|
// its server can take it beside the session's and beside every subagent already running
|
||||||
// (src/session/capacity.ts), before anything is made.
|
// (src/session/capacity.ts), before anything is made.
|
||||||
|
const member = opts.member
|
||||||
const m = opts.model ? resolveModel(loaded, opts.model) : spec.model ? resolveModel(loaded, spec.model) : engine.model
|
const m = opts.model ? resolveModel(loaded, opts.model) : spec.model ? resolveModel(loaded, spec.model) : engine.model
|
||||||
const running = registry.running(jobsSessionId()).filter((j) => j.kind === "subagent" && j.subagent)
|
// A team's member is not refused for capacity: the team gave it its slot (team.md §8).
|
||||||
const onIt = running.filter((j) => j.subagent!.engine.model.ref === m.ref).length
|
if (!member) {
|
||||||
const refused = capacityRefusal(engine.model, m, onIt) || running.map((j) => capacityBeside(j.subagent!.engine.model, m, onIt)).find(Boolean)
|
const running = registry.running(jobsSessionId()).filter((j) => j.kind === "subagent" && j.subagent && !j.team)
|
||||||
if (refused) throw new ToolError(`${refused} Do this part yourself, or hand it to a model on another connection.`)
|
const onIt = running.filter((j) => j.subagent!.engine.model.ref === m.ref).length
|
||||||
|
const refused = capacityRefusal(engine.model, m, onIt) || running.map((j) => capacityBeside(j.subagent!.engine.model, m, onIt)).find(Boolean)
|
||||||
|
if (refused) throw new ToolError(`${refused} Do this part yourself, or hand it to a model on another connection.`)
|
||||||
|
}
|
||||||
// A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation).
|
// A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation).
|
||||||
let wt: Worktree | undefined
|
let wt: Worktree | undefined
|
||||||
if (opts.worktree || spec.isolation === "worktree") {
|
if (opts.worktree || spec.isolation === "worktree") {
|
||||||
@@ -496,8 +508,14 @@ export function createApp(o: AppOptions): App {
|
|||||||
}
|
}
|
||||||
: perm
|
: perm
|
||||||
// `report` is every subagent's: its way of speaking to the main agent before its final reply.
|
// `report` is every subagent's: its way of speaking to the main agent before its final reply.
|
||||||
const childTools = [...tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && (!spec.tools || spec.tools.includes(t.name))), ...BUILTIN_TOOLS.filter((t) => SUBAGENT_ONLY.has(t.name))]
|
// A team member also has team_send and team_read, and one that may lead team_add and team_stop
|
||||||
const description = opts.description ?? prompt.split("\n")[0]!.slice(0, 60)
|
// (team.md §4); a task helper has none of them.
|
||||||
|
const memberTools = member ? (member.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"]) : []
|
||||||
|
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)),
|
||||||
|
]
|
||||||
|
const description = member ? `${member.name}: ${member.role}` : (opts.description ?? prompt.split("\n")[0]!.slice(0, 60))
|
||||||
const ownerSession = jobsSessionId()
|
const ownerSession = jobsSessionId()
|
||||||
const childRow = store?.createSession(at?.root ?? project.root, m.ref, engine.sessionId)
|
const childRow = store?.createSession(at?.root ?? project.root, m.ref, engine.sessionId)
|
||||||
if (childRow) store!.setTitle(childRow.id, `${spec.name}: ${description}`)
|
if (childRow) store!.setTitle(childRow.id, `${spec.name}: ${description}`)
|
||||||
@@ -532,7 +550,17 @@ export function createApp(o: AppOptions): App {
|
|||||||
...baseToolCtx(),
|
...baseToolCtx(),
|
||||||
...(at ? { root: at.root, cwd: at.cwd, projectDir: undefined } : {}),
|
...(at ? { root: at.root, cwd: at.cwd, projectDir: undefined } : {}),
|
||||||
jobs: { registry, owner: () => ({ sessionId: childSessionId, job }) },
|
jobs: { registry, owner: () => ({ sessionId: childSessionId, job }) },
|
||||||
|
...(member ? { team: { runtime: teamRt, as: member.name } } : {}),
|
||||||
},
|
},
|
||||||
|
...(member
|
||||||
|
? {
|
||||||
|
team: {
|
||||||
|
fix: () => teamRt.team.offered,
|
||||||
|
offered: () => teamRt.team.offered,
|
||||||
|
refuse: (tool: Tool, args: unknown) => teamRt.refuse(member.name, tool, args),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
steerMark: MARKS.to_subagent,
|
steerMark: MARKS.to_subagent,
|
||||||
changing: () => changing(true, at),
|
changing: () => changing(true, at),
|
||||||
modeText: modeNote,
|
modeText: modeNote,
|
||||||
@@ -552,6 +580,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
},
|
},
|
||||||
at,
|
at,
|
||||||
plan,
|
plan,
|
||||||
|
member ? { 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
|
// Its own background commands wake it at its next step while it runs; once it has ended they
|
||||||
@@ -567,7 +596,14 @@ export function createApp(o: AppOptions): App {
|
|||||||
const end = registry.finishSubagent(job, { reason, report: text, ...(error ? { error } : {}), ...(worktree ? { worktree } : {}) })
|
const end = registry.finishSubagent(job, { reason, report: text, ...(error ? { error } : {}), ...(worktree ? { worktree } : {}) })
|
||||||
return { text: worktree ? `${text}\n\n[worktree] ${worktree}` : text, steps: job.steps ?? 0, tools: job.tools ?? 0, reason, ...(error ? { error } : {}), ...(end.stopped ? { stopped: end.stopped } : {}) }
|
return { text: worktree ? `${text}\n\n[worktree] ${worktree}` : text, steps: job.steps ?? 0, tools: job.tools ?? 0, reason, ...(error ? { error } : {}), ...(end.stopped ? { stopped: end.stopped } : {}) }
|
||||||
}
|
}
|
||||||
const run = (text: string) => child.prompt(text).then(finish, (e) => (job.lastError = (e as Error).message, finish("error")))
|
// A task helper started inside a team counts toward its size and holds a slot while it runs
|
||||||
|
// (team.md §3, §8).
|
||||||
|
const counted = (p: Promise<SubagentResult>) => {
|
||||||
|
if (member || !teamRt.team.offered) return p
|
||||||
|
const id = teamRt.team.helperStart(TEAM.names.lead, m.ref)
|
||||||
|
return p.finally(() => teamRt.team.helperDone(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 handle: SubagentHandle = {
|
const handle: SubagentHandle = {
|
||||||
bus: childBus,
|
bus: childBus,
|
||||||
engine: child,
|
engine: child,
|
||||||
@@ -575,7 +611,23 @@ export function createApp(o: AppOptions): App {
|
|||||||
restart: (text) => run(text).then(() => undefined),
|
restart: (text) => run(text).then(() => undefined),
|
||||||
cancel: () => child.cancel(),
|
cancel: () => child.cancel(),
|
||||||
}
|
}
|
||||||
job = registry.startSubagent({ sessionId: ownerSession, ownerCallId: opts.callId, ownerJobId: baseOwner().job?.id, agent: spec.name, model: m.ref, description, handle, childSessionId, silent: !opts.background })
|
job = registry.startSubagent({ sessionId: ownerSession, ownerCallId: opts.callId, ownerJobId: baseOwner().job?.id, agent: spec.name, model: m.ref, description, handle, childSessionId, silent: !opts.background || member !== undefined })
|
||||||
|
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) => {
|
||||||
|
if (job.state !== "running") registry.restartSubagent(job)
|
||||||
|
void run(text, machine).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() }
|
||||||
|
teamRt.attach(member.name, mh)
|
||||||
|
memberRun(prompt, false)
|
||||||
|
return { id: job.id, done: Promise.resolve({ text: "", steps: 0, tools: 0, reason: "stop" }), member: mh }
|
||||||
|
}
|
||||||
// A synchronous subagent goes with its parent's turn (Esc stops both); a background one does not.
|
// A synchronous subagent goes with its parent's turn (Esc stops both); a background one does not.
|
||||||
const onAbort = () => child.cancel()
|
const onAbort = () => child.cancel()
|
||||||
opts.signal?.addEventListener("abort", onAbort, { once: true })
|
opts.signal?.addEventListener("abort", onAbort, { once: true })
|
||||||
@@ -601,16 +653,72 @@ export function createApp(o: AppOptions): App {
|
|||||||
budget: { wall_seconds: loaded.config.limits?.wall_seconds, output_bytes: loaded.config.limits?.output_bytes, completion_tokens: loaded.config.limits?.completion_tokens },
|
budget: { wall_seconds: loaded.config.limits?.wall_seconds, output_bytes: loaded.config.limits?.output_bytes, completion_tokens: loaded.config.limits?.completion_tokens },
|
||||||
compaction,
|
compaction,
|
||||||
perm,
|
perm,
|
||||||
toolCtx: { ...baseToolCtx(), jobs: { registry, owner: baseOwner } },
|
toolCtx: { ...baseToolCtx(), jobs: { registry, owner: baseOwner }, team: { get runtime() { return teamRt }, as: TEAM.names.lead } },
|
||||||
system: (m, current, plan, ch) => system(m, current, ch, undefined, undefined, plan),
|
system: (m, current, plan, ch) => system(m, current, ch, undefined, undefined, plan),
|
||||||
|
team: {
|
||||||
|
fix: (ref, offered) => teamRt.team.fix(ref, offered),
|
||||||
|
offered: () => teamRt.team.offered,
|
||||||
|
refuse: (tool, args) => teamRt.refuse(TEAM.names.lead, tool, args),
|
||||||
|
note: () => teamRt.team.noteLines(),
|
||||||
|
forget: () => teamRt.team.forgetTold(),
|
||||||
|
beforeTurn: (signal) => teamRt.beforeLeadTurn(signal),
|
||||||
|
},
|
||||||
changing: () => changing(),
|
changing: () => changing(),
|
||||||
modeText: modeNote,
|
modeText: modeNote,
|
||||||
// The configured MCP servers, up to loop.json's wait: their tools in the first list.
|
// The configured MCP servers, up to loop.json's wait: their tools in the first list.
|
||||||
beforeFirst: () => Promise.race([mcpReady.catch(() => {}), Bun.sleep(LOOP.context.mcp_wait_ms)]),
|
beforeFirst: () => Promise.race([mcpReady.catch(() => {}), Bun.sleep(LOOP.context.mcp_wait_ms)]),
|
||||||
persistRule: trusted ? (rule) => persistProjectRule(project, rule) : undefined,
|
persistRule: trusted ? (rule) => persistProjectRule(project, rule) : undefined,
|
||||||
spawn,
|
// subagents: false — no task tool, and no team (team.md §2).
|
||||||
|
spawn: loaded.config.subagents === false ? undefined : spawn,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The session's team (spec 3.2.0, team.md): its size from the configuration (the cap the global
|
||||||
|
// one's), the capacity of each model as the server says it.
|
||||||
|
const capacityOf = (ref: string): Capacity => {
|
||||||
|
try {
|
||||||
|
const s = ref === engine.model.ref ? engine.model.spec : resolveModel(loaded, ref).spec
|
||||||
|
return { parallel: s.parallel, single_session: s.single_session, group: s.group }
|
||||||
|
} catch {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The size as set while the process runs (any scope): a new or resumed session keeps it.
|
||||||
|
const teamSize: { min?: number; max?: number } = {}
|
||||||
|
const newTeam = () =>
|
||||||
|
new Team({
|
||||||
|
min: teamSize.min ?? loaded.config.agents?.min,
|
||||||
|
max: teamSize.max ?? loaded.config.agents?.max,
|
||||||
|
cap: loaded.config.agents?.cap,
|
||||||
|
subagents: loaded.config.subagents !== false,
|
||||||
|
model: engine.model.ref,
|
||||||
|
capacity: capacityOf,
|
||||||
|
leadState: "idle",
|
||||||
|
readingBash: (command) => readingBashByDefault(command, project.root),
|
||||||
|
})
|
||||||
|
const teamRt: TeamRuntime = new TeamRuntime({
|
||||||
|
team: newTeam(),
|
||||||
|
bus,
|
||||||
|
// spawn makes the member's engine and attaches it before its first await, so a message to it
|
||||||
|
// finds it at once.
|
||||||
|
spawnMember: (member: TeamAgent, text: string) => {
|
||||||
|
spawn(member.agent, text, { member, model: member.model, background: true, description: `${member.name}: ${member.role}` }).catch((e) => {
|
||||||
|
bus.emit({ type: "notice", level: "warn", message: `team: ${member.name} could not start: ${(e as Error).message}` })
|
||||||
|
teamRt.memberDone(member.name, "", `error — ${(e as Error).message}`, [])
|
||||||
|
})
|
||||||
|
},
|
||||||
|
lead: { note: (text) => engine.note(text), wake: (text) => wake(text, shownLine(text)) },
|
||||||
|
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 {
|
||||||
|
return { model: model ? resolveModel(loaded, model).ref : engine.model.ref }
|
||||||
|
} catch (e) {
|
||||||
|
return { error: (e as Error).message }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
// The lead's turn ended: it is idle, and its slot frees (team.md §8).
|
||||||
|
bus.on((e) => e.type === "done" && teamRt.leadDone())
|
||||||
|
|
||||||
// MCP tools join the engine's list as servers connect (the engine reads it at each prompt).
|
// MCP tools join the engine's list as servers connect (the engine reads it at each prompt).
|
||||||
let mcpNames = new Set<string>()
|
let mcpNames = new Set<string>()
|
||||||
const syncMcp = () => {
|
const syncMcp = () => {
|
||||||
@@ -722,6 +830,9 @@ export function createApp(o: AppOptions): App {
|
|||||||
if (key === "model") return engine.model.ref
|
if (key === "model") return engine.model.ref
|
||||||
if (key === "effort") return engine.effort
|
if (key === "effort") return engine.effort
|
||||||
if (key === "mode") return engine.mode
|
if (key === "mode") return engine.mode
|
||||||
|
// The team's size as the session holds it (the cap applied).
|
||||||
|
if (key === "agents.min") return teamRt.team.min
|
||||||
|
if (key === "agents.max") return teamRt.team.max
|
||||||
return key.split(".").reduce<unknown>((x, k) => (x && typeof x === "object" ? (x as Record<string, unknown>)[k] : undefined), loaded.config)
|
return key.split(".").reduce<unknown>((x, k) => (x && typeof x === "object" ? (x as Record<string, unknown>)[k] : undefined), loaded.config)
|
||||||
},
|
},
|
||||||
check(key, v) {
|
check(key, v) {
|
||||||
@@ -729,6 +840,12 @@ export function createApp(o: AppOptions): App {
|
|||||||
if (key === "effort" && !(levelsFor(engine.model).levels as string[]).includes(String(v)))
|
if (key === "effort" && !(levelsFor(engine.model).levels as string[]).includes(String(v)))
|
||||||
throw new Error(`${engine.model.ref} offers the levels ${levelsFor(engine.model).levels.join(", ")}`)
|
throw new Error(`${engine.model.ref} offers the levels ${levelsFor(engine.model).levels.join(", ")}`)
|
||||||
if (key === "knowledge") for (const b of v as string[]) if (!library.base(b)) throw new Error(`there is no knowledge base "${b}" — lembas kb lists them`)
|
if (key === "knowledge") for (const b of v as string[]) if (!library.base(b)) throw new Error(`there is no knowledge base "${b}" — lembas kb lists them`)
|
||||||
|
// The team's size (team.md §2): refused outside 1 ≤ min ≤ max ≤ cap, with the bounds.
|
||||||
|
if (key === "agents.min" || key === "agents.max") {
|
||||||
|
const t = teamRt.team
|
||||||
|
const no = Team.check(key === "agents.min" ? Number(v) : t.min, key === "agents.max" ? Number(v) : t.max, t.cap)
|
||||||
|
if (no) throw new Error(no)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
apply(key, v) {
|
apply(key, v) {
|
||||||
if (key === "model") switchModel(String(v))
|
if (key === "model") switchModel(String(v))
|
||||||
@@ -740,6 +857,14 @@ export function createApp(o: AppOptions): App {
|
|||||||
else if (key === "limits.wall_seconds" || key === "limits.output_bytes" || key === "limits.completion_tokens") engine.o.budget = { ...engine.o.budget, [key.slice("limits.".length)]: Number(v) }
|
else if (key === "limits.wall_seconds" || key === "limits.output_bytes" || key === "limits.completion_tokens") engine.o.budget = { ...engine.o.budget, [key.slice("limits.".length)]: Number(v) }
|
||||||
else if (key === "compaction.auto_at") compaction.autoAt = Number(v)
|
else if (key === "compaction.auto_at") compaction.autoAt = Number(v)
|
||||||
else if (key === "knowledge") libraryCtx.bases = (v as string[]).length ? (v as string[]) : undefined
|
else if (key === "knowledge") libraryCtx.bases = (v as string[]).length ? (v as string[]) : undefined
|
||||||
|
else if (key === "agents.min" || key === "agents.max") {
|
||||||
|
// Said to the lead as a note on its next message (team.md §2), never in the prompt.
|
||||||
|
const t = teamRt.team
|
||||||
|
t.setSetting(key === "agents.min" ? Number(v) : t.min, key === "agents.max" ? Number(v) : t.max)
|
||||||
|
teamSize.min = t.min
|
||||||
|
teamSize.max = t.max
|
||||||
|
bus.emit({ type: "team" })
|
||||||
|
}
|
||||||
if (key !== "model" && key !== "effort" && key !== "mode") setIn(key, v)
|
if (key !== "model" && key !== "effort" && key !== "mode") setIn(key, v)
|
||||||
bus.emit({ type: "setting", key, value: v })
|
bus.emit({ type: "setting", key, value: v })
|
||||||
},
|
},
|
||||||
@@ -791,6 +916,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
mcpReady,
|
mcpReady,
|
||||||
small,
|
small,
|
||||||
close: () => {
|
close: () => {
|
||||||
|
teamRt.stopAll()
|
||||||
detach()
|
detach()
|
||||||
for (const d of childDetach.splice(0)) d()
|
for (const d of childDetach.splice(0)) d()
|
||||||
return mcp.close()
|
return mcp.close()
|
||||||
@@ -805,6 +931,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
jobsSessionId,
|
jobsSessionId,
|
||||||
jobs: () => registry.list(jobsSessionId()),
|
jobs: () => registry.list(jobsSessionId()),
|
||||||
wake,
|
wake,
|
||||||
|
team: teamRt,
|
||||||
get context() {
|
get context() {
|
||||||
return context
|
return context
|
||||||
},
|
},
|
||||||
@@ -830,6 +957,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
const s = store?.createSession(project.root, engine.model.ref)
|
const s = store?.createSession(project.root, engine.model.ref)
|
||||||
keepCwd(s?.id)
|
keepCwd(s?.id)
|
||||||
standIn = `local_${crypto.randomUUID().slice(0, 8)}`
|
standIn = `local_${crypto.randomUUID().slice(0, 8)}`
|
||||||
|
teamRt.reset(newTeam())
|
||||||
engine.newSession(s?.id)
|
engine.newSession(s?.id)
|
||||||
turns.reset()
|
turns.reset()
|
||||||
if (s) bus.emit({ type: "session", id: s.id, model: engine.model.ref, mode: engine.mode })
|
if (s) bus.emit({ type: "session", id: s.id, model: engine.model.ref, mode: engine.mode })
|
||||||
@@ -843,6 +971,7 @@ export function createApp(o: AppOptions): App {
|
|||||||
const before = engine.sessionId
|
const before = engine.sessionId
|
||||||
frozen = freeze()
|
frozen = freeze()
|
||||||
void readServerMemory()
|
void readServerMemory()
|
||||||
|
teamRt.reset(newTeam())
|
||||||
engine.resume(id, store.context(id))
|
engine.resume(id, store.context(id))
|
||||||
// A session from before titles gets its first prompt's line now, and the model's after the next reply.
|
// A session from before titles gets its first prompt's line now, and the model's after the next reply.
|
||||||
firstShown = ""
|
firstShown = ""
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ export type Event =
|
|||||||
/** The note about that job was given to the model: inside a turn at its next step, or as the
|
/** The note about that job was given to the model: inside a turn at its next step, or as the
|
||||||
* `machine` turn whose `prompt` follows. */
|
* `machine` turn whose `prompt` follows. */
|
||||||
| { type: "job_note"; jobId: string; text: string }
|
| { type: "job_note"; jobId: string; text: string }
|
||||||
|
/** 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" }
|
||||||
/** The permission mode changed from inside a turn (plan approved). */
|
/** The permission mode changed from inside a turn (plan approved). */
|
||||||
| { type: "mode"; mode: Mode }
|
| { type: "mode"; mode: Mode }
|
||||||
/** The session's model or effort changed, for whatever reason — /model, /effort, the settings
|
/** The session's model or effort changed, for whatever reason — /model, /effort, the settings
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ export interface Job {
|
|||||||
/** Set when the note's wake should not happen: a synchronous subagent whose result goes back
|
/** Set when the note's wake should not happen: a synchronous subagent whose result goes back
|
||||||
* as the task call's output. */
|
* as the task call's output. */
|
||||||
silent?: boolean
|
silent?: boolean
|
||||||
|
/** A team member's name (spec 3.2.0): not a job to the model — the job tools do not take it —
|
||||||
|
* but a subagent job to a client over the link (team.md §4, §11). */
|
||||||
|
team?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A front end holding a session: where its jobs' events go, and how a note wakes it. The
|
/** A front end holding a session: where its jobs' events go, and how a note wakes it. The
|
||||||
|
|||||||
+22
-1
@@ -236,6 +236,12 @@ export interface PromptContext {
|
|||||||
/** Set for a subagent: who it is and what it is for; replaces the mode's own instructions.
|
/** Set for a subagent: who it is and what it is for; replaces the mode's own instructions.
|
||||||
* `report`: it has the report tool. */
|
* `report`: it has the report tool. */
|
||||||
subagent?: { name: string; instructions: string; report?: boolean }
|
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 §10): the team tools are offered to this session's agent (the lead):
|
||||||
|
* blocks/team_lead.md is in its prompt. */
|
||||||
|
teamLead?: boolean
|
||||||
/** The memory as it was when the prompt was made ("" when both files are empty); undefined
|
/** The memory as it was when the prompt was made ("" when both files are empty); undefined
|
||||||
* when memory is off. A later change by the person goes as a note. */
|
* when memory is off. A later change by the person goes as a note. */
|
||||||
memory?: string
|
memory?: string
|
||||||
@@ -380,7 +386,22 @@ export function assembleSystem(ctx: PromptContext): string {
|
|||||||
if (ctx.library && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/library.md"), { knowledge_scope: ctx.library.scope }))
|
if (ctx.library && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/library.md"), { knowledge_scope: ctx.library.scope }))
|
||||||
if (ctx.prototypes && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/prototypes.md"), { prototype_sources: ctx.prototypes.sources, prototype_surface: PROTOTYPE_SURFACE }))
|
if (ctx.prototypes && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/prototypes.md"), { prototype_sources: ctx.prototypes.sources, prototype_surface: PROTOTYPE_SURFACE }))
|
||||||
|
|
||||||
if (ctx.subagent)
|
if (ctx.teamLead && !ctx.subagent) sections.push(promptText("blocks/team_lead.md"))
|
||||||
|
if (ctx.subagent && ctx.member)
|
||||||
|
sections.push(
|
||||||
|
fillPrompt(promptText("blocks/team_member.md"), {
|
||||||
|
name: ctx.member.name,
|
||||||
|
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") : "",
|
||||||
|
})
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim(),
|
||||||
|
)
|
||||||
|
else if (ctx.subagent)
|
||||||
sections.push(
|
sections.push(
|
||||||
fillPrompt(promptText("blocks/subagent.md"), {
|
fillPrompt(promptText("blocks/subagent.md"), {
|
||||||
name: ctx.subagent.name,
|
name: ctx.subagent.name,
|
||||||
|
|||||||
+47
-1
@@ -16,6 +16,7 @@ import { customCommands, expandCommand } from "../project/commands.ts"
|
|||||||
import { fill, promptText } from "../prompt/assemble.ts"
|
import { fill, promptText } from "../prompt/assemble.ts"
|
||||||
import { skillMessage, slug, type Skill } from "../skill/index.ts"
|
import { skillMessage, slug, type Skill } from "../skill/index.ts"
|
||||||
import { CONTINUE_PROMPT } from "./engine.ts"
|
import { CONTINUE_PROMPT } from "./engine.ts"
|
||||||
|
import { TEAM, Team } from "../team/team.ts"
|
||||||
|
|
||||||
export type CommandKind = "custom" | "skill" | "builtin"
|
export type CommandKind = "custom" | "skill" | "builtin"
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ export const TUI_COMMAND_NAMES = [
|
|||||||
"agents", "continue", "help", "model", "effort", "mode", "plan", "plans", "prototype", "review", "changelog", "init", "release", "tasks", "decisions", "decide",
|
"agents", "continue", "help", "model", "effort", "mode", "plan", "plans", "prototype", "review", "changelog", "init", "release", "tasks", "decisions", "decide",
|
||||||
"jobs", "sessions", "resume", "remote", "delete", "new", "clear", "copy", "undo", "redo", "diff", "checkpoint", "checkpoints", "branch", "commit", "context",
|
"jobs", "sessions", "resume", "remote", "delete", "new", "clear", "copy", "undo", "redo", "diff", "checkpoint", "checkpoints", "branch", "commit", "context",
|
||||||
"compact", "memory", "skills", "skill", "personality", "voice", "speak", "mcp", "login", "logout", "settings", "usage", "kb", "theme", "skin", "terminal",
|
"compact", "memory", "skills", "skill", "personality", "voice", "speak", "mcp", "login", "logout", "settings", "usage", "kb", "theme", "skin", "terminal",
|
||||||
"icons", "trust", "reload", "restart", "quit", "exit", "q",
|
"icons", "trust", "reload", "restart", "quit", "exit", "q", "team",
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
/** The built-ins that work without a screen of their own. Descriptions as the TUI's (a test). */
|
/** The built-ins that work without a screen of their own. Descriptions as the TUI's (a test). */
|
||||||
@@ -47,8 +48,51 @@ export const SHARED_BUILTINS: { name: string; description: string; hint?: string
|
|||||||
{ name: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" },
|
{ name: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" },
|
||||||
{ name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" },
|
{ name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" },
|
||||||
{ name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" },
|
{ name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" },
|
||||||
|
{ name: "team", description: "the team: its agents and the room; its size; stop a member", hint: "size <min> <max> | stop <name>" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
/** The status bar's word for a team with members: its size of the most it may have, and how many
|
||||||
|
* work now; "" without members. */
|
||||||
|
export function teamLabel(team: Team): string {
|
||||||
|
if (!team.hasMembers()) return ""
|
||||||
|
const n = team.active()
|
||||||
|
return `team ${team.size()}/${team.max}${n ? ` · ${n} working` : ""}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `/team [size <min> <max> | stop <name>]`: the team's tree and room, as the lead sees them (the
|
||||||
|
* person sees everything, team.md §5); its size for this session; a member stopped by the person,
|
||||||
|
* which the lead is told in a note. */
|
||||||
|
export function teamText(app: App, arg: string): string {
|
||||||
|
const rt = app.team
|
||||||
|
const t = rt.team
|
||||||
|
const [verb, ...rest] = arg.trim().split(/\s+/).filter(Boolean)
|
||||||
|
if (verb === "size") {
|
||||||
|
const [min, max] = rest.map(Number)
|
||||||
|
if (min === undefined || max === undefined || Number.isNaN(min) || Number.isNaN(max)) return `/team size <min> <max> — now ${t.min} to ${t.max} (at most ${t.cap})`
|
||||||
|
// Checked whole before anything is set: a refused pair changes nothing (team.md §2).
|
||||||
|
const no = Team.check(min, max, t.cap)
|
||||||
|
if (no) return no
|
||||||
|
try {
|
||||||
|
app.settings.set("agents.min", Math.min(min, t.max))
|
||||||
|
app.settings.set("agents.max", max)
|
||||||
|
app.settings.set("agents.min", min)
|
||||||
|
} catch (e) {
|
||||||
|
return (e as Error).message
|
||||||
|
}
|
||||||
|
return `The team may have ${t.min} to ${t.max} agents for this session; the lead is told with your next message.`
|
||||||
|
}
|
||||||
|
if (verb === "stop") {
|
||||||
|
const name = rest[0]
|
||||||
|
if (!name) return "/team stop <name> — a member and everything under it"
|
||||||
|
const gone = rt.personStop(name)
|
||||||
|
return gone.length ? `Stopped ${gone.join(", ")}; the lead is told with your next message.` : `No member called ${name} on the team.`
|
||||||
|
}
|
||||||
|
if (!t.offered)
|
||||||
|
return t.subagents ? `No team in this session: ${app.engine.model.ref} serves one request at a time, and a team needs two at once.` : "No team in this session: subagents are switched off (subagents: false)."
|
||||||
|
const since = Math.max(0, t.room.length - TEAM.read_max)
|
||||||
|
return `${t.read("lead", since)}\n\nSize ${t.min} to ${t.max} agents (/team size <min> <max>); /team stop <name> stops a member.`
|
||||||
|
}
|
||||||
|
|
||||||
/** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and
|
/** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and
|
||||||
* a client over ACP show. Making one (`new`) and handing one a task need a screen: the TUI's. */
|
* a client over ACP show. Making one (`new`) and handing one a task need a screen: the TUI's. */
|
||||||
export function agentsText(app: App, arg: string): string {
|
export function agentsText(app: App, arg: string): string {
|
||||||
@@ -175,6 +219,8 @@ export function expandSlash(app: App, text: string): Expanded | undefined {
|
|||||||
// List and describe anywhere; `new` and `<name> <task>` are the TUI's own.
|
// List and describe anywhere; `new` and `<name> <task>` are the TUI's own.
|
||||||
if (/^(new)\b/.test(arg) || arg.trim().split(/\s+/).length > 1) return { kind: "message", name, text: "/agents here lists and describes the subagents; making one or handing one a task is done at the terminal." }
|
if (/^(new)\b/.test(arg) || arg.trim().split(/\s+/).length > 1) return { kind: "message", name, text: "/agents here lists and describes the subagents; making one or handing one a task is done at the terminal." }
|
||||||
return { kind: "message", name, text: agentsText(app, arg) }
|
return { kind: "message", name, text: agentsText(app, arg) }
|
||||||
|
case "team":
|
||||||
|
return { kind: "message", name, text: teamText(app, arg) }
|
||||||
case "init":
|
case "init":
|
||||||
// The prompt is ours; only what was typed after the name may attach files.
|
// The prompt is ours; only what was typed after the name may attach files.
|
||||||
return { kind: "prompt", name, prompt: initPrompt(app, arg), shown: t, attachFrom: arg }
|
return { kind: "prompt", name, prompt: initPrompt(app, arg), shown: t, attachFrom: arg }
|
||||||
|
|||||||
+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
|
/** 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). */
|
* a subagent (loop.json jobs.marks to_subagent). */
|
||||||
steerMark?: string
|
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. */
|
/** What a prompt carries besides its text. */
|
||||||
@@ -168,6 +183,8 @@ export class Engine {
|
|||||||
private keptBase: Told | undefined
|
private keptBase: Told | undefined
|
||||||
/** The last request's usage, cached input tokens included, and what it was for. */
|
/** The last request's usage, cached input tokens included, and what it was for. */
|
||||||
lastRequest: { usage: Usage; kind: "step" | "compaction" } | undefined
|
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) {
|
constructor(readonly o: EngineOptions) {
|
||||||
this.mode = o.mode
|
this.mode = o.mode
|
||||||
@@ -201,6 +218,9 @@ export class Engine {
|
|||||||
if (this.model.spec.vision === true) could.add("vision")
|
if (this.model.spec.vision === true) could.add("vision")
|
||||||
if (this.o.toolCtx.projectDir !== undefined) could.add("project")
|
if (this.o.toolCtx.projectDir !== undefined) could.add("project")
|
||||||
if (this.o.toolCtx.show !== undefined) could.add("display")
|
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 L = this.o.toolCtx.library
|
||||||
const have = L?.lib.bases().filter((b) => b.documents > 0).map((b) => b.name) ?? []
|
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")
|
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). */
|
/** The session's prefix, made now if there is none yet (or it was made for another model). */
|
||||||
prefix(): Kept {
|
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 changing = this.o.changing?.() ?? NO_CHANGING
|
||||||
const now = this.now(changing)
|
const now = this.now(changing)
|
||||||
const could = this.could()
|
const could = this.could()
|
||||||
@@ -235,6 +268,7 @@ export class Engine {
|
|||||||
if (this.kept?.told.date) told.date = this.kept.told.date
|
if (this.kept?.told.date) told.date = this.kept.told.date
|
||||||
this.keptBase = { ...structuredClone(told), date: undefined }
|
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.kept = { system: this.o.system(this.mode, this.model, this.plan, changing), left, model: this.model.ref, told }
|
||||||
|
this.teamFixed = this.kept
|
||||||
this.saveKept()
|
this.saveKept()
|
||||||
return this.kept
|
return this.kept
|
||||||
}
|
}
|
||||||
@@ -261,7 +295,7 @@ export class Engine {
|
|||||||
* the model's from then on. */
|
* the model's from then on. */
|
||||||
private takeNote(): string {
|
private takeNote(): string {
|
||||||
const kept = this.prefix()
|
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) {
|
if (note) {
|
||||||
kept.told = told
|
kept.told = told
|
||||||
this.saveKept()
|
this.saveKept()
|
||||||
@@ -282,6 +316,7 @@ export class Engine {
|
|||||||
rewound() {
|
rewound() {
|
||||||
if (!this.kept || !this.keptBase) return
|
if (!this.kept || !this.keptBase) return
|
||||||
this.kept.told = structuredClone(this.keptBase)
|
this.kept.told = structuredClone(this.keptBase)
|
||||||
|
this.o.team?.forget?.()
|
||||||
this.saveKept()
|
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
|
// 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).
|
// their tools are in the first list (§10 rule 4).
|
||||||
if (!this.kept || this.kept.model !== this.model.ref) await this.o.beforeFirst?.()
|
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).
|
// What changed since the prompt was made goes after the person's words, as a note (§10 rule 3).
|
||||||
const note = this.takeNote()
|
const note = this.takeNote()
|
||||||
this.push({
|
this.push({
|
||||||
@@ -878,6 +918,7 @@ export class Engine {
|
|||||||
const resolved = resolveCall(call.name, raw)
|
const resolved = resolveCall(call.name, raw)
|
||||||
tool = toolMap.get(resolved.name) ?? tool
|
tool = toolMap.get(resolved.name) ?? tool
|
||||||
raw = resolved.raw
|
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).
|
// 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())
|
const no = unavailable(tool.name, tool.requires ? [tool.requires] : undefined, this.met())
|
||||||
if (no) return { result: { output: no, isError: true } }
|
if (no) return { result: { output: no, isError: true } }
|
||||||
@@ -888,10 +929,15 @@ export class Engine {
|
|||||||
const { args: own, purpose } = splitPurpose(tool, raw)
|
const { args: own, purpose } = splitPurpose(tool, raw)
|
||||||
const parsed = tool.schema.safeParse(own)
|
const parsed = tool.schema.safeParse(own)
|
||||||
if (!parsed.success) {
|
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("; ")
|
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 } }
|
return { result: { output: `Invalid arguments for ${tool.name}: ${why}`, isError: true } }
|
||||||
}
|
}
|
||||||
let args = parsed.data
|
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 }
|
const toolCtx: ToolContext = { ...this.o.toolCtx, signal: new AbortController().signal }
|
||||||
let request = tool.permission(args, toolCtx)
|
let request = tool.permission(args, toolCtx)
|
||||||
let edited: string | undefined
|
let edited: string | undefined
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function toldAt(now: Now): Told {
|
|||||||
/** The note for a user message: a line for each thing that differs from what the model was told,
|
/** The note for a user message: a line for each thing that differs from what the model was told,
|
||||||
* in loop.json's order, inside the note's tags — "" when nothing does. `modeText` is the mode's
|
* in loop.json's order, inside the note's tags — "" when nothing does. `modeText` is the mode's
|
||||||
* text (modes/<mode>.md, filled as the prompt fills it). Returns what is told after it. */
|
* text (modes/<mode>.md, filled as the prompt fills it). Returns what is told after it. */
|
||||||
export function noteFor(told: Told, now: Now, modeText: (mode: Mode) => string): { note: string; told: Told } {
|
export function noteFor(told: Told, now: Now, modeText: (mode: Mode) => string, extra: string[] = []): { note: string; told: Told } {
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
const say = (text: string, vars: Record<string, string>) => lines.push(fillPrompt(text, vars))
|
const say = (text: string, vars: Record<string, string>) => lines.push(fillPrompt(text, vars))
|
||||||
const or = (s: string) => (s.trim() ? s : NOTHING)
|
const or = (s: string) => (s.trim() ? s : NOTHING)
|
||||||
@@ -88,6 +88,8 @@ export function noteFor(told: Told, now: Now, modeText: (mode: Mode) => string):
|
|||||||
// Only a server that has connected or changed since: one that went away takes its tools with it.
|
// Only a server that has connected or changed since: one that went away takes its tools with it.
|
||||||
const arrived = Object.keys(now.mcp).filter((name) => told.mcp[name] !== now.mcp[name])
|
const arrived = Object.keys(now.mcp).filter((name) => told.mcp[name] !== now.mcp[name])
|
||||||
if (arrived.length) say(NOTE.mcp, { instructions: arrived.map((name) => mcpBlock(name, now.mcp[name]!)).join("\n") })
|
if (arrived.length) say(NOTE.mcp, { instructions: arrived.map((name) => mcpBlock(name, now.mcp[name]!)).join("\n") })
|
||||||
|
// Spec 3.2.0: the team's size and members the person stopped (team.md §10), last.
|
||||||
|
lines.push(...extra)
|
||||||
return { note: lines.length ? noteBlock(lines) : "", told: { ...toldAt(now), date: now.date } }
|
return { note: lines.length ? noteBlock(lines) : "", told: { ...toldAt(now), date: now.date } }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +120,7 @@ export function today(at = new Date()): string {
|
|||||||
|
|
||||||
/** A requirement a tool can have (the spec's `requires`, and the CLI's own: vision, project,
|
/** A requirement a tool can have (the spec's `requires`, and the CLI's own: vision, project,
|
||||||
* knowledge). */
|
* knowledge). */
|
||||||
export type Requirement = "vision" | "plan" | "project" | "knowledge" | "display" | "page" | "forge"
|
export type Requirement = "vision" | "plan" | "project" | "knowledge" | "display" | "page" | "forge" | "team"
|
||||||
|
|
||||||
/** Rule 4: the tool list is fixed for the session. A tool is offered when every requirement it has
|
/** Rule 4: the tool list is fixed for the session. A tool is offered when every requirement it has
|
||||||
* is one the session could meet at some point (`could`), decided once as the prompt is made; a
|
* is one the session could meet at some point (`could`), decided once as the prompt is made; a
|
||||||
|
|||||||
@@ -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
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
capacityOf(model: string): Capacity {
|
||||||
|
return this.o.capacity(model)
|
||||||
|
}
|
||||||
|
|
||||||
/** How many requests the lead's model serves at once now. */
|
/** How many requests the lead's model serves at once now. */
|
||||||
parallelNow(): number {
|
parallelNow(): number {
|
||||||
return parallelFrom(this.o.capacity(this.model))
|
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
|
/** §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). */
|
* prompt is made (and made again on a model switch); `offered` is a kept prompt's decision. */
|
||||||
fix(model = this.model): boolean {
|
fix(model = this.model, offered?: boolean): boolean {
|
||||||
this.model = model
|
this.model = model
|
||||||
const lead = this.agents.get(LEAD)
|
const lead = this.agents.get(LEAD)
|
||||||
if (lead) lead.model = model
|
if (lead) lead.model = model
|
||||||
this.offered = this.subagents && this.parallelNow() >= 2
|
this.offered = offered ?? (this.subagents && this.parallelNow() >= 2)
|
||||||
return this.offered
|
return this.offered
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,17 +342,24 @@ export class Team {
|
|||||||
|
|
||||||
// ── the tools ───────────────────────────────────────────────────────────────
|
// ── 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. */
|
/** 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 }): { 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 (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 lead = this.agents.get(LEAD)!
|
||||||
const goal = a.goal?.trim()
|
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
|
const caller = this.agents.get(by) ?? lead
|
||||||
if (by === LEAD && goal) lead.goal = goal
|
if (by === LEAD && goal) lead.goal = goal
|
||||||
const member: TeamAgent = {
|
const member: TeamAgent = {
|
||||||
@@ -511,13 +522,18 @@ export class Team {
|
|||||||
this.fillSlots()
|
this.fillSlots()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The lead's turn is starting without having asked (the engine's own): it holds a slot. */
|
/** The lead stopped waiting (the person pressed Esc): idle, out of the queue. */
|
||||||
leadWorking() {
|
leadIdle() {
|
||||||
const lead = this.agents.get(LEAD)!
|
const lead = this.agents.get(LEAD)!
|
||||||
lead.state = "working"
|
if (lead.state === "waiting") lead.state = "idle"
|
||||||
lead.waitSince = undefined
|
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 ───────────────────────────────────────────────────
|
// ── 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
|
/** §10: the note's entries for the lead's next user message — the size when it was not said yet
|
||||||
|
|||||||
+12
-2
@@ -17,7 +17,8 @@ function jobsOf(ctx: ToolContext) {
|
|||||||
function find(ctx: ToolContext, id: string): Job {
|
function find(ctx: ToolContext, id: string): Job {
|
||||||
const { registry, owner } = jobsOf(ctx)
|
const { registry, owner } = jobsOf(ctx)
|
||||||
const job = registry.get(owner().sessionId, id.trim())
|
const job = registry.get(owner().sessionId, id.trim())
|
||||||
if (!job) throw new ToolError(`No background job ${id}. job_list shows this session's jobs.`)
|
// A team member is not a job to the model: the team tools take it (team.md §4).
|
||||||
|
if (!job || job.team) throw new ToolError(`No background job ${id}. job_list shows this session's jobs.`)
|
||||||
return job
|
return job
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +43,7 @@ export const jobListTool = defineTool({
|
|||||||
permission: () => ({ permission: "job_list", class: "read", patterns: ["*"] }),
|
permission: () => ({ permission: "job_list", class: "read", patterns: ["*"] }),
|
||||||
async run(_a, ctx) {
|
async run(_a, ctx) {
|
||||||
const { registry, owner } = jobsOf(ctx)
|
const { registry, owner } = jobsOf(ctx)
|
||||||
const jobs = registry.list(owner().sessionId)
|
const jobs = registry.list(owner().sessionId).filter((j) => !j.team)
|
||||||
if (!jobs.length) return { output: "No background jobs.", title: "0 jobs" }
|
if (!jobs.length) return { output: "No background jobs.", title: "0 jobs" }
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const lines = jobs.map((j) => {
|
const lines = jobs.map((j) => {
|
||||||
@@ -101,6 +102,9 @@ export const jobSendTool = defineTool({
|
|||||||
job.subagent.send(a.text)
|
job.subagent.send(a.text)
|
||||||
return { output: `Sent to ${job.id} (${whatWords(job)}); it is given to it at its next step.`, title: `${job.id} · message sent` }
|
return { output: `Sent to ${job.id} (${whatWords(job)}); it is given to it at its next step.`, title: `${job.id} · message sent` }
|
||||||
}
|
}
|
||||||
|
// Starting a finished helper again counts toward a team's size as task does (team.md §3).
|
||||||
|
const no = ctx.team?.runtime.team.taskRefusal()
|
||||||
|
if (no) throw new ToolError(no)
|
||||||
registry.restartSubagent(job)
|
registry.restartSubagent(job)
|
||||||
void job.subagent.restart(a.text)
|
void job.subagent.restart(a.text)
|
||||||
return { output: `${job.id} (${whatWords(job)}) is running again with your message as its new prompt, its context intact. Its report comes to you when it ends.`, title: `${job.id} · started again` }
|
return { output: `${job.id} (${whatWords(job)}) is running again with your message as its new prompt, its context intact. Its report comes to you when it ends.`, title: `${job.id} · started again` }
|
||||||
@@ -114,6 +118,12 @@ export const reportTool = defineTool({
|
|||||||
schema: z.object({ text: z.string().min(1) }),
|
schema: z.object({ text: z.string().min(1) }),
|
||||||
permission: () => ({ permission: "report", class: "interact", patterns: ["*"] }),
|
permission: () => ({ permission: "report", class: "interact", patterns: ["*"] }),
|
||||||
async run(a, ctx) {
|
async run(a, ctx) {
|
||||||
|
// A team member's report goes through the room, to its manager (team.md §4).
|
||||||
|
if (ctx.team && ctx.team.as !== "lead") {
|
||||||
|
const sent = ctx.team.runtime.team.message(ctx.team.as, a.text)
|
||||||
|
ctx.team.runtime.notify()
|
||||||
|
if (sent) return { output: `${sent} An answer, if one comes, arrives as a message to you; carry on meanwhile.`, title: `message to ${ctx.team.runtime.team.get(ctx.team.as)?.manager ?? "your manager"}` }
|
||||||
|
}
|
||||||
const { registry, owner } = jobsOf(ctx)
|
const { registry, owner } = jobsOf(ctx)
|
||||||
const job = owner().job
|
const job = owner().job
|
||||||
if (!job) throw new ToolError("report is for a subagent: you are the main agent, so write to the user directly.")
|
if (!job) throw new ToolError("report is for a subagent: you are the main agent, so write to the user directly.")
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export function resolveCall(name: string, raw: unknown): { name: string; raw: un
|
|||||||
const plan = fromTodos(obj.todos)
|
const plan = fromTodos(obj.todos)
|
||||||
args = isPlan(plan) ? { title: plan.title, phases: plan.phases.map((p) => ({ title: p.title, status: p.status, tasks: p.tasks.map((t) => ({ text: t.text, status: t.status })) })) } : { title: "Work", phases: [] }
|
args = isPlan(plan) ? { title: plan.title, phases: plan.phases.map((p) => ({ title: p.title, status: p.status, tasks: p.tasks.map((t) => ({ text: t.text, status: t.status })) })) } : { title: "Work", phases: [] }
|
||||||
}
|
}
|
||||||
|
// team_send's `to` as one name: read as a list of one (team.md §5).
|
||||||
|
if (to === "team_send" && obj && typeof obj.to === "string") args = { ...obj, to: [obj.to] }
|
||||||
const renamed = ARGUMENTS_RENAMED[to]
|
const renamed = ARGUMENTS_RENAMED[to]
|
||||||
if (renamed && args && typeof args === "object" && !Array.isArray(args)) {
|
if (renamed && args && typeof args === "object" && !Array.isArray(args)) {
|
||||||
const a = { ...(args as Record<string, unknown>) }
|
const a = { ...(args as Record<string, unknown>) }
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { planSubmitTool } from "./plan_submit.ts"
|
|||||||
import { planUpdateTool } from "./plan_update.ts"
|
import { planUpdateTool } from "./plan_update.ts"
|
||||||
import { planWriteTool } from "./plan_write.ts"
|
import { planWriteTool } from "./plan_write.ts"
|
||||||
import { taskTool } from "./task.ts"
|
import { taskTool } from "./task.ts"
|
||||||
|
import { teamAddTool, teamReadTool, teamSendTool, teamStopTool } from "./team_tools.ts"
|
||||||
import { decisionsTool, tasksTool } from "./project_tools.ts"
|
import { decisionsTool, tasksTool } from "./project_tools.ts"
|
||||||
import { webFetchTool, webSearchTool } from "./web.ts"
|
import { webFetchTool, webSearchTool } from "./web.ts"
|
||||||
import { memoryTool } from "./memory.ts"
|
import { memoryTool } from "./memory.ts"
|
||||||
@@ -40,6 +41,11 @@ export const BUILTIN_TOOLS: Tool[] = [
|
|||||||
webSearchTool,
|
webSearchTool,
|
||||||
webFetchTool,
|
webFetchTool,
|
||||||
taskTool,
|
taskTool,
|
||||||
|
// Spec 3.2.0: a team (team.md), offered where the model serves two requests at once.
|
||||||
|
teamAddTool,
|
||||||
|
teamSendTool,
|
||||||
|
teamReadTool,
|
||||||
|
teamStopTool,
|
||||||
planWriteTool,
|
planWriteTool,
|
||||||
planUpdateTool,
|
planUpdateTool,
|
||||||
tasksTool,
|
tasksTool,
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// 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
|
||||||
|
// for the agent the context names (`ctx.team.as`).
|
||||||
|
import { z } from "zod"
|
||||||
|
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
|
||||||
|
import { described, TOOL_SPECS } from "../harness.ts"
|
||||||
|
import { TEAM } from "../team/team.ts"
|
||||||
|
import { fillPrompt } from "../prompt/fill.ts"
|
||||||
|
|
||||||
|
function teamOf(ctx: ToolContext) {
|
||||||
|
if (!ctx.team) throw new ToolError(TOOL_SPECS.team_add!.unavailable ?? TEAM.unavailable.one_agent)
|
||||||
|
return ctx.team
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The spec's own answer when a name does not parse (team.md §4's order: the requirement, `all`,
|
||||||
|
* then the pattern), before the generic one for invalid arguments. */
|
||||||
|
function badName(raw: unknown, ctx: ToolContext): string | undefined {
|
||||||
|
const name = (raw as { name?: unknown } | undefined)?.name
|
||||||
|
if (typeof name !== "string") return undefined
|
||||||
|
const no = ctx.team ? (ctx.team.runtime.team.offered ? ctx.team.runtime.team.unavailable() : TEAM.unavailable.one_agent) : TEAM.unavailable.one_agent
|
||||||
|
if (no) return no
|
||||||
|
if (name === "all") return TEAM.texts.all_name
|
||||||
|
if (!new RegExp(TEAM.names.pattern).test(name)) return fillPrompt(TEAM.texts.bad_name, { name })
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export const teamAddTool = defineTool({
|
||||||
|
...described("team_add"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_add",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({
|
||||||
|
name: z.string().regex(new RegExp(TEAM.names.pattern)),
|
||||||
|
role: z.string().min(1),
|
||||||
|
task: z.string().min(1),
|
||||||
|
goal: z.string().optional(),
|
||||||
|
lead: z.boolean().optional(),
|
||||||
|
agent: z.string().optional(),
|
||||||
|
model: z.string().optional(),
|
||||||
|
}),
|
||||||
|
invalid: badName,
|
||||||
|
permission: (a) => ({ permission: "team_add", class: "interact", patterns: [a.agent ?? "general"] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
const r = t.runtime.add(t.as, a)
|
||||||
|
return { output: r.text, title: r.added ? `${a.name}: ${a.role}` : "not added", isError: !r.added }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamSendTool = defineTool({
|
||||||
|
...described("team_send"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_send",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({ to: z.array(z.string()).min(1), message: z.string().min(1) }),
|
||||||
|
permission: () => ({ permission: "team_send", class: "interact", patterns: ["*"] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
const before = t.runtime.team.room.length
|
||||||
|
const output = t.runtime.team.send(t.as, a)
|
||||||
|
t.runtime.notify()
|
||||||
|
const sent = t.runtime.team.room.length > before
|
||||||
|
return { output, title: sent ? `to ${a.to.includes("all") ? "all" : [...new Set(a.to)].join(", ")}` : "not sent", isError: !sent }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamReadTool = defineTool({
|
||||||
|
...described("team_read"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_read",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({ since: z.number().int().min(0).optional() }),
|
||||||
|
permission: () => ({ permission: "team_read", class: "read", patterns: ["*"] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
return { output: t.runtime.team.read(t.as, a.since ?? 0), title: `team${a.since ? ` since #${a.since}` : ""}` }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const teamStopTool = defineTool({
|
||||||
|
...described("team_stop"),
|
||||||
|
purpose: false,
|
||||||
|
access: "team_stop",
|
||||||
|
requires: "team",
|
||||||
|
schema: z.object({ name: z.string().min(1) }),
|
||||||
|
permission: (a) => ({ permission: "team_stop", class: "interact", patterns: [a.name] }),
|
||||||
|
async run(a, ctx) {
|
||||||
|
const t = teamOf(ctx)
|
||||||
|
const size = t.runtime.team.size()
|
||||||
|
const output = t.runtime.team.stopCmd(t.as, a.name)
|
||||||
|
t.runtime.notify()
|
||||||
|
return { output, title: t.runtime.team.size() < size ? `stopped ${a.name}` : "not stopped", isError: t.runtime.team.size() >= size }
|
||||||
|
},
|
||||||
|
})
|
||||||
+11
-1
@@ -36,6 +36,9 @@ export interface ToolContext {
|
|||||||
* (the TUI's ctrl+b): bash hands it to the registry as it would at its timeout. Set by the
|
* (the TUI's ctrl+b): bash hands it to the registry as it would at its timeout. Set by the
|
||||||
* engine for the step's calls. */
|
* engine for the step's calls. */
|
||||||
backgroundNow?: AbortSignal
|
backgroundNow?: AbortSignal
|
||||||
|
/** The team this agent is on (spec 3.2.0, team/runtime.ts) and its name there (`lead`, or a
|
||||||
|
* member's). Absent where no team is offered. */
|
||||||
|
team?: { runtime: import("../team/runtime.ts").TeamRuntime; as: string }
|
||||||
/** The id of the call being run (set by the engine per call). */
|
/** The id of the call being run (set by the engine per call). */
|
||||||
callId?: string
|
callId?: string
|
||||||
/** The project directory (.agent), when the project has one. */
|
/** The project directory (.agent), when the project has one. */
|
||||||
@@ -91,12 +94,16 @@ export interface SpawnOptions {
|
|||||||
description?: string
|
description?: string
|
||||||
model?: string
|
model?: string
|
||||||
background?: boolean
|
background?: boolean
|
||||||
|
/** Spec 3.2.0: a team member (team.md §4) — run as one, not as a task helper. */
|
||||||
|
member?: import("../team/team.ts").TeamAgent
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the task tool gets back from spawn: the job's id and the report when it ends. */
|
/** What the task tool gets back from spawn: the job's id and the report when it ends. */
|
||||||
export interface SubagentHandleInfo {
|
export interface SubagentHandleInfo {
|
||||||
id: string
|
id: string
|
||||||
done: Promise<import("./task.ts").SubagentResult>
|
done: Promise<import("./task.ts").SubagentResult>
|
||||||
|
/** A team member's engine, for the team's runtime to drive. */
|
||||||
|
member?: import("../team/runtime.ts").MemberHandle
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolResult {
|
export interface ToolResult {
|
||||||
@@ -124,7 +131,7 @@ export interface Tool<S extends z.ZodType = z.ZodType> {
|
|||||||
* display`). The tool list is the session's (harness context.md §10): one the session could
|
* display`). The tool list is the session's (harness context.md §10): one the session could
|
||||||
* never meet leaves the tool out; one that comes and goes (plan mode, a display) keeps it in, and
|
* never meet leaves the tool out; one that comes and goes (plan mode, a display) keeps it in, and
|
||||||
* a call while it is unmet answers the spec's `unavailable` text. */
|
* a call while it is unmet answers the spec's `unavailable` text. */
|
||||||
requires?: "vision" | "plan" | "project" | "knowledge" | "display"
|
requires?: "vision" | "plan" | "project" | "knowledge" | "display" | "team"
|
||||||
description: string
|
description: string
|
||||||
schema: S
|
schema: S
|
||||||
/** The JSON Schema sent to the model, when it does not come from `schema` (MCP tools). */
|
/** The JSON Schema sent to the model, when it does not come from `schema` (MCP tools). */
|
||||||
@@ -136,6 +143,9 @@ export interface Tool<S extends z.ZodType = z.ZodType> {
|
|||||||
* `purpose` argument added to every tool (the default), a field of the tool's own (bash's
|
* `purpose` argument added to every tool (the default), a field of the tool's own (bash's
|
||||||
* `description`), or false for a tool that never asks. */
|
* `description`), or false for a tool that never asks. */
|
||||||
purpose?: string | false
|
purpose?: string | false
|
||||||
|
/** What a call answers when its arguments do not parse, where the spec has its own words for it
|
||||||
|
* (team_add's name); undefined: the generic answer. */
|
||||||
|
invalid?(raw: unknown, ctx: ToolContext): string | undefined
|
||||||
permission(args: z.infer<S>, ctx: ToolContext): PermissionRequest
|
permission(args: z.infer<S>, ctx: ToolContext): PermissionRequest
|
||||||
/** What the call would change, as a unified diff, shown when the user is asked. Writes nothing. */
|
/** What the call would change, as a unified diff, shown when the user is asked. Writes nothing. */
|
||||||
preview?(args: z.infer<S>, ctx: ToolContext): Promise<string>
|
preview?(args: z.infer<S>, ctx: ToolContext): Promise<string>
|
||||||
|
|||||||
+5
-2
@@ -5,6 +5,7 @@ import { stricterMode, type App as Runtime } from "../app.ts"
|
|||||||
import { isLocal } from "../config/paths.ts"
|
import { isLocal } from "../config/paths.ts"
|
||||||
import type { Mode } from "../config/schema.ts"
|
import type { Mode } from "../config/schema.ts"
|
||||||
import { levelLabel, type Level } from "../provider/effort.ts"
|
import { levelLabel, type Level } from "../provider/effort.ts"
|
||||||
|
import { teamLabel } from "../session/commands.ts"
|
||||||
import { COMMANDS, findCommand, type CommandCtx } from "./commands.ts"
|
import { COMMANDS, findCommand, type CommandCtx } from "./commands.ts"
|
||||||
import { Dialog, type DialogSpec } from "./components/dialog.tsx"
|
import { Dialog, type DialogSpec } from "./components/dialog.tsx"
|
||||||
import { Messages } from "./components/messages.tsx"
|
import { Messages } from "./components/messages.tsx"
|
||||||
@@ -60,6 +61,8 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
|||||||
const mode = () => (rev(), app.engine.mode)
|
const mode = () => (rev(), app.engine.mode)
|
||||||
const model = () => (rev(), app.engine.model.ref)
|
const model = () => (rev(), app.engine.model.ref)
|
||||||
const effort = () => (rev(), levelLabel(app.engine.model, app.engine.effort))
|
const effort = () => (rev(), levelLabel(app.engine.model, app.engine.effort))
|
||||||
|
// "team 3/4 · 2 working" while the lead has members (team.md, the TUI's status).
|
||||||
|
const teamWord = () => (rev(), teamLabel(app.team.team))
|
||||||
const busy = () => view.state.phase !== "idle"
|
const busy = () => view.state.phase !== "idle"
|
||||||
const branch = () => (rev(), app.project.gitRoot ? currentBranch(app.project.gitRoot) : undefined)
|
const branch = () => (rev(), app.project.gitRoot ? currentBranch(app.project.gitRoot) : undefined)
|
||||||
// A word in the status bar that goes by itself (a copy): it says something happened without
|
// A word in the status bar that goes by itself (a copy): it says something happened without
|
||||||
@@ -74,7 +77,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
|||||||
onCleanup(() => clearTimeout(flashTimer))
|
onCleanup(() => clearTimeout(flashTimer))
|
||||||
// A mode changed inside a turn (a plan approved) must reach the status bar; so must a branch the
|
// A mode changed inside a turn (a plan approved) must reach the status bar; so must a branch the
|
||||||
// model switched with git.
|
// model switched with git.
|
||||||
app.bus.on((e) => (e.type === "mode" || e.type === "done") && refresh())
|
app.bus.on((e) => (e.type === "mode" || e.type === "done" || e.type === "team") && refresh())
|
||||||
app.bus.on((e) => e.type === "flash" && flash(e.text, 6000))
|
app.bus.on((e) => e.type === "flash" && flash(e.text, 6000))
|
||||||
// A setting changed by the agent, /settings or a command: show it. Theme and icons are drawn here.
|
// A setting changed by the agent, /settings or a command: show it. Theme and icons are drawn here.
|
||||||
app.bus.on((e) => {
|
app.bus.on((e) => {
|
||||||
@@ -642,7 +645,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
|
|||||||
</Show>
|
</Show>
|
||||||
<Queued />
|
<Queued />
|
||||||
<Prompt focused={() => !dialog() && !jobPanel() && !view.state.pending && !view.state.question && !view.state.plan} busy={busy} commands={palette} files={files} onSubmit={submit} ref={(r) => (input = r)} queue={queueAccess} onWheel={(dir, lines) => scroll?.scrollBy(dir * lines)} onBackground={backgroundNow} />
|
<Prompt focused={() => !dialog() && !jobPanel() && !view.state.pending && !view.state.question && !view.state.plan} busy={busy} commands={palette} files={files} onSubmit={submit} ref={(r) => (input = r)} queue={queueAccess} onWheel={(dir, lines) => scroll?.scrollBy(dir * lines)} onBackground={backgroundNow} />
|
||||||
<StatusBar mode={mode} model={model} effort={effort} branch={branch()} voice={voiceStatus} flash={flashText} remote={() => (rev(), share.label())} spend={spend} />
|
<StatusBar mode={mode} model={model} effort={effort} branch={branch()} voice={voiceStatus} flash={flashText} remote={() => (rev(), share.label())} spend={spend} team={teamWord} />
|
||||||
</box>
|
</box>
|
||||||
<Show when={dialog()}>
|
<Show when={dialog()}>
|
||||||
<Dialog spec={dialog()!} close={() => setDialog(undefined)} />
|
<Dialog spec={dialog()!} close={() => setDialog(undefined)} />
|
||||||
|
|||||||
+53
-5
@@ -11,7 +11,7 @@ import { doRelease, planRelease } from "../git/release.ts"
|
|||||||
import { instructionFiles } from "../prompt/assemble.ts"
|
import { instructionFiles } from "../prompt/assemble.ts"
|
||||||
import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts"
|
import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts"
|
||||||
import { contextBreakdown, renderBreakdown } from "../session/context.ts"
|
import { contextBreakdown, renderBreakdown } from "../session/context.ts"
|
||||||
import { changelogPrompt, initPrompt, reviewPrompt } from "../session/commands.ts"
|
import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/commands.ts"
|
||||||
import { git } from "../git/run.ts"
|
import { git } from "../git/run.ts"
|
||||||
import { branches, deleteBranch, switchBranch } from "../git/branch.ts"
|
import { branches, deleteBranch, switchBranch } from "../git/branch.ts"
|
||||||
import type { Ctx } from "./context.ts"
|
import type { Ctx } from "./context.ts"
|
||||||
@@ -94,6 +94,27 @@ You are the ${name} subagent. Say what to do, how to check it, and what to repor
|
|||||||
report is all the session's model sees of your work.
|
report is all the session's model sees of your work.
|
||||||
`
|
`
|
||||||
|
|
||||||
|
const TEAM_ROW = "\u0000team"
|
||||||
|
|
||||||
|
/** The team's size for this session: a few usual pairs, or any typed as `<min> <max>`. */
|
||||||
|
function openTeamSize(c: CommandCtx) {
|
||||||
|
const t = c.app.team.team
|
||||||
|
const pairs = [
|
||||||
|
[1, 1, "one agent: no team, no helper"],
|
||||||
|
[1, 4, "you decide; up to three members"],
|
||||||
|
[2, 4, "a team from the start"],
|
||||||
|
[1, 8, "you decide; up to seven members"],
|
||||||
|
[3, 8, "a team of at least three"],
|
||||||
|
[1, Math.min(16, t.cap), "a bigger team, with sub-managers"],
|
||||||
|
] as const
|
||||||
|
const seen = new Set<string>()
|
||||||
|
const options = pairs
|
||||||
|
.filter(([min, max]) => max <= t.cap && !seen.has(`${min} ${max}`) && seen.add(`${min} ${max}`))
|
||||||
|
.map(([min, max, what]) => ({ label: `${min} to ${max}`, value: `${min} ${max}`, description: what, current: t.min === min && t.max === max }))
|
||||||
|
const apply = (v: string) => (c.view.info(teamText(c.app, `size ${v}`)), c.refresh())
|
||||||
|
c.openDialog({ title: `Agents — now ${t.min} to ${t.max} (at most ${t.cap})`, options, onSelect: apply, custom: { label: "set to", onSubmit: apply } })
|
||||||
|
}
|
||||||
|
|
||||||
export const COMMANDS: Command[] = [
|
export const COMMANDS: Command[] = [
|
||||||
{
|
{
|
||||||
name: "agents",
|
name: "agents",
|
||||||
@@ -194,24 +215,51 @@ export const COMMANDS: Command[] = [
|
|||||||
c.setEffort(v as Level)
|
c.setEffort(v as Level)
|
||||||
c.view.info(control === "switch" ? `thinking ${v === "off" ? "off" : `on (${v})`}` : `effort: ${v}`)
|
c.view.info(control === "switch" ? `thinking ${v === "off" ? "off" : `on (${v})`}` : `effort: ${v}`)
|
||||||
}
|
}
|
||||||
if (control === "none") return c.view.info(`${m.ref} has one level (${levels[0]}): there is nothing to choose`)
|
// The team's size sits beside the effort (team.md §2: shown only where a team is offered).
|
||||||
|
const team = c.app.team.team
|
||||||
|
const agentsRow = team.offered ? [{ label: `agents: ${team.min} to ${team.max}`, value: TEAM_ROW, description: "how many agents the team may have, you included" }] : []
|
||||||
|
const choose = (v: string) => (v === TEAM_ROW ? openTeamSize(c) : set(v))
|
||||||
|
if (control === "none" && (arg || !agentsRow.length)) return c.view.info(`${m.ref} has one level (${levels[0]}): there is nothing to choose`)
|
||||||
if (arg) return set(arg)
|
if (arg) return set(arg)
|
||||||
|
if (control === "none") return c.openDialog({ title: `Chat settings — ${m.ref}`, options: agentsRow, onSelect: choose })
|
||||||
if (control === "switch")
|
if (control === "switch")
|
||||||
return c.openDialog({
|
return c.openDialog({
|
||||||
title: `Thinking — ${m.ref}`,
|
title: `Thinking — ${m.ref}`,
|
||||||
options: [
|
options: [
|
||||||
{ label: "on", value: on, description: on === "default" ? "the model's own" : on, current: c.app.engine.effort !== "off" },
|
{ label: "on", value: on, description: on === "default" ? "the model's own" : on, current: c.app.engine.effort !== "off" },
|
||||||
{ label: "off", value: "off", current: c.app.engine.effort === "off" },
|
{ label: "off", value: "off", current: c.app.engine.effort === "off" },
|
||||||
|
...agentsRow,
|
||||||
],
|
],
|
||||||
onSelect: set,
|
onSelect: choose,
|
||||||
})
|
})
|
||||||
c.openDialog({
|
c.openDialog({
|
||||||
title: `Effort — ${m.ref}`,
|
title: `Effort — ${m.ref}`,
|
||||||
options: levels.map((e) => ({ label: e === "off" ? "off (no thinking)" : e, value: e, current: c.app.engine.effort === e })),
|
options: [...levels.map((e) => ({ label: e === "off" ? "off (no thinking)" : e, value: e, current: c.app.engine.effort === e })), ...agentsRow],
|
||||||
onSelect: set,
|
onSelect: choose,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "team",
|
||||||
|
args: "[size [<min> <max>] | stop [<name>]]",
|
||||||
|
description: "the team: its agents and the room; its size; stop a member",
|
||||||
|
run(c, arg) {
|
||||||
|
// The tree and the room as the lead sees them — the person sees everything (team.md §5).
|
||||||
|
const words = arg.trim().split(/\s+/).filter(Boolean)
|
||||||
|
if (words[0] === "size" && words.length === 1) return openTeamSize(c)
|
||||||
|
if (words[0] === "stop" && words.length === 1) {
|
||||||
|
const members = c.app.team.team.tree().filter((a) => a.manager !== null)
|
||||||
|
if (!members.length) return c.view.info("the team has no members")
|
||||||
|
return c.openDialog({
|
||||||
|
title: "Stop a member, and everything under it",
|
||||||
|
options: members.map((a) => ({ label: a.name, value: a.name, description: `${a.role} · ${a.state}` })),
|
||||||
|
onSelect: (name) => (c.view.info(teamText(c.app, `stop ${name}`)), c.refresh()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.view.info(teamText(c.app, arg))
|
||||||
|
c.refresh()
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "mode",
|
name: "mode",
|
||||||
args: `[${MODES.join("|")}]`,
|
args: `[${MODES.join("|")}]`,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function jobsWord(jobs: { state: string; unread: number; unseen?: boolean
|
|||||||
* for a moment, a flash (a copy) in their place. The jobs word sits on the right, which is never
|
* for a moment, a flash (a copy) in their place. The jobs word sits on the right, which is never
|
||||||
* truncated: at 80 columns a long project name cuts the left short, and a running job is the one
|
* truncated: at 80 columns a long project name cuts the left short, and a running job is the one
|
||||||
* thing that must stay visible. */
|
* thing that must stay visible. */
|
||||||
export function StatusBar(p: { mode: () => string; model: () => string; effort: () => string; branch?: string; voice?: () => string; flash?: () => string | undefined; remote?: () => string; spend?: () => string }) {
|
export function StatusBar(p: { mode: () => string; model: () => string; effort: () => string; branch?: string; voice?: () => string; flash?: () => string | undefined; remote?: () => string; spend?: () => string; team?: () => string }) {
|
||||||
const { theme, view, app } = useTui()
|
const { theme, view, app } = useTui()
|
||||||
const c = () => theme().colors
|
const c = () => theme().colors
|
||||||
const modeColor = () => ({ manual: c().statusText, edit: c().statusGood, auto: c().statusCritical, plan: c().info })[p.mode() as keyof typeof MODE_LABEL] ?? c().statusText
|
const modeColor = () => ({ manual: c().statusText, edit: c().statusGood, auto: c().statusCritical, plan: c().info })[p.mode() as keyof typeof MODE_LABEL] ?? c().statusText
|
||||||
@@ -50,6 +50,7 @@ export function StatusBar(p: { mode: () => string; model: () => string; effort:
|
|||||||
when={p.flash?.()}
|
when={p.flash?.()}
|
||||||
fallback={
|
fallback={
|
||||||
<text wrapMode="none">
|
<text wrapMode="none">
|
||||||
|
<span style={{ fg: c().info }}>{p.team?.() ? `${p.team()} · ` : ""}</span>
|
||||||
<span style={{ fg: jobsWord(view.state.jobs).endsWith("!") ? c().statusWarn : c().statusText }}>{jobsWord(view.state.jobs) ? `${jobsWord(view.state.jobs)} · ` : ""}</span>
|
<span style={{ fg: jobsWord(view.state.jobs).endsWith("!") ? c().statusWarn : c().statusText }}>{jobsWord(view.state.jobs) ? `${jobsWord(view.state.jobs)} · ` : ""}</span>
|
||||||
<span style={{ fg: c().statusDim }}>{view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""}</span>
|
<span style={{ fg: c().statusDim }}>{view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""}</span>
|
||||||
<span style={{ fg: c().statusText }}>{p.spend?.() ? `${p.spend()} · ` : ""}</span>
|
<span style={{ fg: c().statusText }}>{p.spend?.() ? `${p.spend()} · ` : ""}</span>
|
||||||
|
|||||||
+1
-1
@@ -54,7 +54,7 @@ describe("end to end", () => {
|
|||||||
expect(fake!.requests[0].messages[0].content).toContain("Permission mode: edit")
|
expect(fake!.requests[0].messages[0].content).toContain("Permission mode: edit")
|
||||||
// knowledge_search/get are offered once any knowledge base has documents, and the suite shares
|
// knowledge_search/get are offered once any knowledge base has documents, and the suite shares
|
||||||
// one LEMBAS_HOME: another file's base must not decide this list (found in the full run).
|
// one LEMBAS_HOME: another file's base must not decide this list (found in the full run).
|
||||||
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "job_send", "web_search", "web_fetch", "task", "plan_write", "plan_update", "ask_user", "plan_submit", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings", "recall"])
|
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "job_send", "web_search", "web_fetch", "task", "team_add", "team_send", "team_read", "team_stop", "plan_write", "plan_update", "ask_user", "plan_submit", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings", "recall"])
|
||||||
const ends = events.filter((e) => e.type === "tool_end")
|
const ends = events.filter((e) => e.type === "tool_end")
|
||||||
expect(ends.map((e) => e.type === "tool_end" && e.result.title)).toEqual(["hello.txt · 1–1 of 1", "hello.txt +1 −1"])
|
expect(ends.map((e) => e.type === "tool_end" && e.result.title)).toEqual(["hello.txt · 1–1 of 1", "hello.txt +1 −1"])
|
||||||
const sid = store.sessions(1)[0]!.id
|
const sid = store.sessions(1)[0]!.id
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ export interface Fake {
|
|||||||
/** `served`: what GET /v1/models lists, as a LLeMbas server lists its models (default two
|
/** `served`: what GET /v1/models lists, as a LLeMbas server lists its models (default two
|
||||||
* placeholders). Any other GET — the instance's own description, its personalization — is a 404,
|
* placeholders). Any other GET — the instance's own description, its personalization — is a 404,
|
||||||
* which a client reads as "this server does not have it". */
|
* which a client reads as "this server does not have it". */
|
||||||
export function fakeProvider(script: Scripted[], opts: { route?: (model: string) => Fake | undefined; served?: Record<string, unknown>[] } = {}): Fake {
|
/** `script` may be a function of the request (and its number), for requests that race: a team's
|
||||||
|
* agents ask at once, and each is answered by who is asking. */
|
||||||
|
export function fakeProvider(script: Scripted[] | ((body: any, n: number) => Scripted | undefined), opts: { route?: (model: string) => Fake | undefined; served?: Record<string, unknown>[] } = {}): Fake {
|
||||||
const requests: any[] = []
|
const requests: any[] = []
|
||||||
const calls: { path: string; headers: Record<string, string> }[] = []
|
const calls: { path: string; headers: Record<string, string> }[] = []
|
||||||
let i = 0
|
let i = 0
|
||||||
@@ -34,7 +36,7 @@ export function fakeProvider(script: Scripted[], opts: { route?: (model: string)
|
|||||||
if (to) return fetch(to.url.replace(/\/v1$/, "") + url.pathname + url.search, { method: "POST", headers: req.headers, body: JSON.stringify(body) })
|
if (to) return fetch(to.url.replace(/\/v1$/, "") + url.pathname + url.search, { method: "POST", headers: req.headers, body: JSON.stringify(body) })
|
||||||
requests.push(body)
|
requests.push(body)
|
||||||
calls.push({ path: url.pathname + url.search, headers: Object.fromEntries(req.headers.entries()) })
|
calls.push({ path: url.pathname + url.search, headers: Object.fromEntries(req.headers.entries()) })
|
||||||
const r = script[i++]
|
const r = typeof script === "function" ? script(body, i++) : script[i++]
|
||||||
if (!r) return new Response("script exhausted", { status: 500 })
|
if (!r) return new Response("script exhausted", { status: 500 })
|
||||||
if ("status" in r) return new Response(r.body, { status: r.status })
|
if ("status" in r) return new Response(r.body, { status: r.status })
|
||||||
// gapMs: a pause between chunks, to look at the screen mid-stream.
|
// gapMs: a pause between chunks, to look at the screen mid-stream.
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ test("no clock in the prompt: two requests across a date change send the same sy
|
|||||||
expect(system(1)).toBe(system(0))
|
expect(system(1)).toBe(system(0))
|
||||||
expect(system(2)).toBe(system(0))
|
expect(system(2)).toBe(system(0))
|
||||||
expect(fake!.requests[2].tools).toEqual(fake!.requests[0].tools)
|
expect(fake!.requests[2].tools).toEqual(fake!.requests[0].tools)
|
||||||
expect(lastUser(0)).toBe(`first\n${NOTE.open}\nToday is 2026-11-04.\n${NOTE.close}`)
|
// A model with no capacity said (a hosted API) offers a team: its size is said once, last (spec 3.2.0).
|
||||||
|
expect(lastUser(0)).toBe(`first\n${NOTE.open}\nToday is 2026-11-04.\n\nThe team may have 1 to 4 agents, you included.\n${NOTE.close}`)
|
||||||
expect(lastUser(1)).toBe("second")
|
expect(lastUser(1)).toBe("second")
|
||||||
expect(lastUser(2)).toBe(`third\n${NOTE.open}\nToday is 2026-11-05.\n${NOTE.close}`)
|
expect(lastUser(2)).toBe(`third\n${NOTE.open}\nToday is 2026-11-05.\n${NOTE.close}`)
|
||||||
// A note is part of its message from then on: the next request sends the first one as it was.
|
// A note is part of its message from then on: the next request sends the first one as it was.
|
||||||
@@ -140,7 +141,8 @@ test("compaction: the same system and tools, every message, then the compaction
|
|||||||
expect(store.requests(sid).map((r) => r.kind)).toEqual(["step", "compaction"])
|
expect(store.requests(sid).map((r) => r.kind)).toEqual(["step", "compaction"])
|
||||||
// After it the prompt is made again, and the next user message says the date again.
|
// After it the prompt is made again, and the next user message says the date again.
|
||||||
await app.engine.prompt("go on")
|
await app.engine.prompt("go on")
|
||||||
expect(lastUser(2)).toMatch(/^go on\n<session-note>\nToday is \d{4}-\d{2}-\d{2}\.\n<\/session-note>$/)
|
// The team's size too (spec 3.2.0): like the date, it is not in the prompt.
|
||||||
|
expect(lastUser(2)).toMatch(/^go on\n<session-note>\nToday is \d{4}-\d{2}-\d{2}\.\n\nThe team may have 1 to 4 agents, you included\.\n<\/session-note>$/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("a resumed session sends the prompt it kept, byte for byte; what changed meanwhile is a note", async () => {
|
test("a resumed session sends the prompt it kept, byte for byte; what changed meanwhile is a note", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// Teams (harness spec 3.2.0, team.md) through the whole app: the lead adds a member that runs on the
|
||||||
|
// subagent machinery, its tools that change things answer the manager's refusal before anything is
|
||||||
|
// asked, the member's final reply comes back as a marked report that wakes the idle lead, and the
|
||||||
|
// size setting is said in a note. The rules themselves are conformance/team.json's.
|
||||||
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
|
import { mkdtempSync, realpathSync, writeFileSync, mkdirSync } from "node:fs"
|
||||||
|
import { tmpdir } from "node:os"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import { createApp, type App } from "../src/app.ts"
|
||||||
|
import type { AskReply } from "../src/bus/index.ts"
|
||||||
|
import { paths } from "../src/config/paths.ts"
|
||||||
|
import { LOOP } from "../src/harness.ts"
|
||||||
|
import { teamLabel, teamText } from "../src/session/commands.ts"
|
||||||
|
import { delta, fakeProvider, toolCall, type Fake, type Scripted } from "./fake-provider.ts"
|
||||||
|
|
||||||
|
let fake: Fake | undefined
|
||||||
|
let app: App | undefined
|
||||||
|
afterEach(async () => {
|
||||||
|
await app?.close()
|
||||||
|
app = undefined
|
||||||
|
fake?.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
const T = LOOP.team
|
||||||
|
const yes = { ask: async (): Promise<AskReply> => ({ kind: "once" }) }
|
||||||
|
const say = (text: string): Scripted => ({ chunks: [delta({ content: text }, "stop")] })
|
||||||
|
const call = (id: string, name: string, args: unknown): Scripted => ({ chunks: [toolCall(0, id, name, JSON.stringify(args)), delta({}, "tool_calls")] })
|
||||||
|
const system = (body: any): string => body.messages[0].content
|
||||||
|
const isMember = (body: any, name: string) => system(body).includes(`You are "${name}", a member of a team`)
|
||||||
|
const lastUser = (body: any): string => {
|
||||||
|
const u = [...body.messages].reverse().find((m: any) => m.role === "user")
|
||||||
|
return typeof u.content === "string" ? u.content : u.content.map((p: any) => p.text ?? "").join("")
|
||||||
|
}
|
||||||
|
// A result as the model reads it, less its id line (context.md §1).
|
||||||
|
const toolResults = (body: any): string[] => body.messages.filter((m: any) => m.role === "tool").map((m: any) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content)).replace(/^\[#\d+\]\n/, ""))
|
||||||
|
|
||||||
|
function setup(answer: (body: any, n: number) => Scripted | undefined, config = "", spec = "{ context: 32768, parallel: 2 }") {
|
||||||
|
fake = fakeProvider(answer)
|
||||||
|
mkdirSync(paths.config, { recursive: true })
|
||||||
|
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n fake:\n type: webui\n url: ${fake.url}\n models:\n fake/m: ${spec}\n`, { mode: 0o600 })
|
||||||
|
writeFileSync(join(paths.config, "config.yaml"), `model: fake/m\n${config}`)
|
||||||
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), "lembas-team-")))
|
||||||
|
Bun.spawnSync(["git", "init", "-q", dir])
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
async function until(what: () => boolean, ms = 10000) {
|
||||||
|
const end = Date.now() + ms
|
||||||
|
while (!what()) {
|
||||||
|
if (Date.now() > end) throw new Error("timed out")
|
||||||
|
await Bun.sleep(20)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("a team at work (team.md)", () => {
|
||||||
|
test("the lead adds a member, is refused work, and is woken by the member's report", async () => {
|
||||||
|
let lead = 0
|
||||||
|
const dir = setup((body) => {
|
||||||
|
if (isMember(body, "parser")) return say("The parser is done: src/parse.ts.")
|
||||||
|
const n = lead++
|
||||||
|
if (n === 0) return call("a1", "team_add", { name: "parser", role: "the parser", task: "Write the parser; report the file.", goal: "a calculator" })
|
||||||
|
if (n === 1) return call("w1", "write", { path: "x.txt", content: "x" })
|
||||||
|
return say(n === 2 ? "parser is on it." : "All done.")
|
||||||
|
})
|
||||||
|
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
|
||||||
|
await app.engine.prompt("build a calculator")
|
||||||
|
// The lead's report arrives — at a step boundary, or as a turn of its own when it was idle.
|
||||||
|
await until(() => fake!.requests.some((r) => !isMember(r, "parser") && r.messages.some((m: any) => m.role === "user" && JSON.stringify(m.content).includes("[Team report from parser to lead:]"))))
|
||||||
|
await until(() => !app!.engine.busy && app!.team.team.get("lead")!.state === "idle")
|
||||||
|
|
||||||
|
const first = fake!.requests[0]
|
||||||
|
expect(first.tools.map((t: any) => t.function.name)).toEqual(expect.arrayContaining(["team_add", "team_send", "team_read", "team_stop"]))
|
||||||
|
expect(system(first)).toContain("team_add")
|
||||||
|
// The size is said once, in the note on the first message.
|
||||||
|
expect(lastUser(first)).toContain(LOOP.context.note.team.replace("{{min}}", "1").replace("{{max}}", "4"))
|
||||||
|
|
||||||
|
const leads = fake!.requests.filter((r) => !isMember(r, "parser"))
|
||||||
|
const results = toolResults(leads[leads.length - 1])
|
||||||
|
expect(results[0]).toContain("Added parser (the parser)")
|
||||||
|
expect(results[1]).toContain(T.texts.manager)
|
||||||
|
|
||||||
|
// The member: its own prompt, its brief as its first message, and the team tools a member has.
|
||||||
|
const member = fake!.requests.find((r) => isMember(r, "parser"))!
|
||||||
|
expect(system(member)).toContain("The team's goal: a calculator")
|
||||||
|
expect(system(member)).toContain('you report to "lead"')
|
||||||
|
expect(lastUser(member)).toContain("Write the parser; report the file.")
|
||||||
|
const memberTools = member.tools.map((t: any) => t.function.name)
|
||||||
|
expect(memberTools).toEqual(expect.arrayContaining(["team_send", "team_read"]))
|
||||||
|
expect(memberTools).not.toContain("team_add")
|
||||||
|
expect(memberTools).not.toContain("task")
|
||||||
|
|
||||||
|
// The room has the report; the lead manages the finished member; nothing was written.
|
||||||
|
const t = app.team.team
|
||||||
|
expect(t.room.map((m) => ({ from: m.from, to: m.to, report: m.report }))).toEqual([{ from: "parser", to: ["lead"], report: true }])
|
||||||
|
expect(t.get("parser")!.state).toBe("finished")
|
||||||
|
expect(t.isManager("lead")).toBe(true)
|
||||||
|
expect(teamLabel(t)).toBe("team 2/4")
|
||||||
|
expect(teamText(app, "")).toContain("- parser — the parser · finished")
|
||||||
|
expect(await Bun.file(join(dir, "x.txt")).exists()).toBe(false)
|
||||||
|
// A member is not a job to the model.
|
||||||
|
expect(app.jobs().every((j) => j.team === "parser")).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("max 1: the tools are listed but answer one_agent, and task too; the note says to work alone", async () => {
|
||||||
|
let n = 0
|
||||||
|
const dir = setup(() => (n++ === 0 ? call("a1", "team_add", { name: "x", role: "r", task: "t", goal: "g" }) : n === 2 ? call("t1", "task", { description: "d", prompt: "p" }) : say("alone")), "agents: { min: 1, max: 1 }\n")
|
||||||
|
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
|
||||||
|
await app.engine.prompt("go")
|
||||||
|
expect(lastUser(fake!.requests[0])).toContain(LOOP.context.note.team_one)
|
||||||
|
const results = toolResults(fake!.requests[2])
|
||||||
|
expect(results).toEqual([T.unavailable.one_agent, T.unavailable.one_agent])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("min 2: the lead is a manager from the start, and the note asks for members", async () => {
|
||||||
|
let n = 0
|
||||||
|
const dir = setup(() => (n++ === 0 ? call("e1", "bash", { command: "touch made.txt" }) : say("ok")), "agents: { min: 2, max: 4 }\n")
|
||||||
|
app = createApp({ cwd: dir, mode: "auto", asker: yes, store: false })
|
||||||
|
await app.engine.prompt("go")
|
||||||
|
expect(lastUser(fake!.requests[0])).toContain(LOOP.context.note.team_lead.replace("{{members}}", "1"))
|
||||||
|
expect(toolResults(fake!.requests[1])).toEqual([T.texts.manager])
|
||||||
|
expect(await Bun.file(join(dir, "made.txt")).exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("a model that serves one request at a time offers no team: no team tools, /team says why", async () => {
|
||||||
|
const dir = setup(() => say("ok"), "", "{ context: 32768, parallel: 1 }")
|
||||||
|
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
|
||||||
|
await app.engine.prompt("go")
|
||||||
|
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => n.startsWith("team_"))).toEqual([])
|
||||||
|
expect(lastUser(fake!.requests[0])).not.toContain("The team may have")
|
||||||
|
expect(teamText(app, "")).toContain("serves one request at a time")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("the size is refused outside its bounds, whole, and a change is said in the next note", async () => {
|
||||||
|
let n = 0
|
||||||
|
const dir = setup(() => say(`ok ${n++}`))
|
||||||
|
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
|
||||||
|
await app.engine.prompt("one")
|
||||||
|
expect(teamText(app, "size 3 2")).toContain("1 ≤ min ≤ max ≤ 32")
|
||||||
|
expect(teamText(app, "size 1 40")).toContain("1 ≤ min ≤ max ≤ 32")
|
||||||
|
expect([app.team.team.min, app.team.team.max]).toEqual([1, 4])
|
||||||
|
expect(teamText(app, "size 2 6")).toContain("2 to 6")
|
||||||
|
expect(app.settings.get("agents.max").value).toBe(6)
|
||||||
|
await app.engine.prompt("two")
|
||||||
|
expect(lastUser(fake!.requests[1])).toContain("The team may have 2 to 6 agents, you included.")
|
||||||
|
await app.engine.prompt("three")
|
||||||
|
expect(lastUser(fake!.requests[2])).toBe("three")
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user