Progress phases and delegation rules (harness spec 3.4.0): the text and line of each phase, who decides a subagent's call
ci / check (push) Waiting to run
ci / check (push) Waiting to run
Conformance areas progress and delegation pass. delegation.json: own_reason marks the cases whose deny reason is each project's own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
5 files changed
+279
-7
No files matched your search
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"about": "Delegation (subagents.md §7, spec 3.4.0): a subagent's mode and request options, and who decides its calls. The project root is /project, nothing named exists on disk, and only the default rules apply (permission/defaults.json). `add` — team_add's mode check for a parent in `parent_mode` with `mode` and `request` as given → `{result, mode}`: the refusal word for word, or null and the mode the subagent gets. `call` — a subagent in `mode` with `request` options and `grants` (rules {tool, pattern} the main agent allowed) under a main agent in `main_mode`, calling `call` (`bash` with a `line`, or a file tool with a `path` relative to the root) → `{answer, text?}`: `run`, `person` (an approval card to the person), or `refused` with its text where the case gives one (a hardline's reason is each project's). `unattended`: nobody can answer. `request` — team_request for `tool` by a subagent with `request` options, its first → `{result}`. `switch` — the main agent's mode changes from `main_from` to `main_to` with these subagents (each with its parent and mode) → each one's mode after. LLeMbas: services/team.py; CLI: team/delegation.ts.",
|
||||
"about": "Delegation (subagents.md §7, spec 3.4.0): a subagent's mode and request options, and who decides its calls. The project root is /project, nothing named exists on disk, and only the default rules apply (permission/defaults.json). `add` — team_add's mode check for a parent in `parent_mode` with `mode` and `request` as given → `{result, mode}`: the refusal word for word, or null and the mode the subagent gets. `call` — a subagent in `mode` with `request` options and `grants` (rules {tool, pattern} the main agent allowed) under a main agent in `main_mode`, calling `call` (`bash` with a `line`, or a file tool with a `path` relative to the root) → `{answer, text?}`: `run`, `person` (an approval card to the person), or `refused` with its text, word for word — except where the case says `own_reason` (a hardline's, a mode's or the unattended refusal's reason is each project's), when only the answer is compared. `unattended`: nobody can answer. `request` — team_request for `tool` by a subagent with `request` options, its first → `{result}`. `switch` — the main agent's mode changes from `main_from` to `main_to` with these subagents (each with its parent and mode) → each one's mode after. LLeMbas: services/team.py; CLI: team/delegation.ts.",
|
||||
"cases": [
|
||||
{
|
||||
"about": "No mode given: the parent's.",
|
||||
@@ -315,9 +315,9 @@
|
||||
"tool": "bash",
|
||||
"line": "rm -rf build"
|
||||
},
|
||||
"own_reason": true,
|
||||
"expect": {
|
||||
"answer": "refused",
|
||||
"text": "Plan mode: nothing changes but the plan. Put what this would do into the plan with plan_write or plan_update; it can be done once the person approves the plan."
|
||||
"answer": "refused"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -342,6 +342,7 @@
|
||||
"tool": "bash",
|
||||
"line": "rm -rf /"
|
||||
},
|
||||
"own_reason": true,
|
||||
"expect": {
|
||||
"answer": "refused"
|
||||
}
|
||||
@@ -356,6 +357,7 @@
|
||||
"tool": "bash",
|
||||
"line": "git push origin main"
|
||||
},
|
||||
"own_reason": true,
|
||||
"expect": {
|
||||
"answer": "refused"
|
||||
}
|
||||
|
||||
+43
-4
@@ -128,13 +128,52 @@ export const LOOP: {
|
||||
/** Teams (spec 3.2.0, harness/team.md): the size setting, names, marks, texts. */
|
||||
team: {
|
||||
agents: { min: number; max: number; cap: number }
|
||||
names: { pattern: string; lead: string; reserved: string[] }
|
||||
marks: { message: string; report: string }
|
||||
names: { pattern: string; lead: string; reserved: string[]; parent: string; id_prefix: string }
|
||||
marks: { message: string; report: string; assignment: string; request: string; decision: string; context_full: string }
|
||||
unavailable: { one_agent: string; one_request: string }
|
||||
work: { write: string[]; execute: string[]; kept: string[] }
|
||||
read_max: number
|
||||
texts: Record<"added" | "added_waiting" | "sent" | "stopped" | "manager" | "full" | "bad_name" | "all_name" | "taken" | "no_goal" | "unknown" | "self" | "nobody" | "not_yours" | "nobody_to_stop" | "no_report" | "ended", string>
|
||||
read: { tree: string; agent: string; lead_role: string; states: Record<"working" | "waiting" | "finished" | "idle", string>; active: string; room: string; message: string; report: string; none: string; more: string }
|
||||
texts: Record<
|
||||
| "added" | "added_waiting" | "sent" | "stopped" | "manager" | "full" | "bad_name" | "all_name" | "taken" | "no_goal" | "unknown" | "self" | "nobody" | "not_yours" | "nobody_to_stop" | "no_report" | "ended"
|
||||
// Spec 3.4.0 (subagents.md §5–§13).
|
||||
| "only_main" | "not_manager" | "wider" | "request_not_narrower" | "request_kind" | "requestable" | "not_delegated" | "not_requestable" | "requested" | "no_request" | "decided" | "allowed" | "refused" | "decision_allowed" | "decision_refused" | "no_parent" | "finished" | "has_ended" | "cycle" | "assigned" | "replaced" | "replace_brief" | "no_findings" | "noted" | "not_yours_findings" | "context_full" | "gone" | "request_body",
|
||||
string
|
||||
>
|
||||
read: {
|
||||
tree: string
|
||||
agent: string
|
||||
lead_role: string
|
||||
states: Record<"working" | "waiting" | "finished" | "idle" | "paused" | "done" | "out_of_context" | "stopped", string>
|
||||
active: string
|
||||
room: string
|
||||
message: string
|
||||
report: string
|
||||
none: string
|
||||
more: string
|
||||
slots: string
|
||||
ended: string
|
||||
ended_agent: string
|
||||
context: string
|
||||
request: string
|
||||
}
|
||||
/** Spec 3.4.0. */
|
||||
main_only: { tools: string[] }
|
||||
context: { pause_at: number; final_steps: number }
|
||||
request: { kinds: string[]; id_prefix: string }
|
||||
whoami: { lines: Record<"who" | "parent" | "task" | "mode" | "request" | "model" | "limits" | "spawn_yes" | "spawn_no" | "context", string> }
|
||||
findings: { header: string; item: string }
|
||||
}
|
||||
/** Progress (spec 3.4.0, harness/progress.md): the phases, their texts, the line. */
|
||||
progress: {
|
||||
phases: string[]
|
||||
texts: Record<string, string>
|
||||
reason: Record<string, string>
|
||||
what: Record<string, string>
|
||||
stopped_reason: Record<string, string>
|
||||
line: string
|
||||
no_elapsed: string[]
|
||||
what_max: number
|
||||
pace_per_second: number
|
||||
}
|
||||
/** Context hygiene (spec 3.0.0, harness/context.md): the result ids, the texts of a read's
|
||||
* header, a stub, the elided line, recall; the output cap; what is deduplicated; the table
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
// Progress (harness spec 3.4.0, harness/progress.md): one fixed set of phases every client shows
|
||||
// the same way. The object, its text from loop.json `progress.texts` and the one line drawn for it.
|
||||
import { LOOP } from "../harness.ts"
|
||||
import { fillPrompt } from "../prompt/fill.ts"
|
||||
import { duration } from "../duration.ts"
|
||||
|
||||
const P = LOOP.progress
|
||||
|
||||
export type Phase =
|
||||
| "queued"
|
||||
| "waiting_for_model"
|
||||
| "loading_model"
|
||||
| "reading_prompt"
|
||||
| "thinking"
|
||||
| "writing"
|
||||
| "running_tool"
|
||||
| "waiting_for_person"
|
||||
| "waiting_for_subagents"
|
||||
| "compacting"
|
||||
| "retrying"
|
||||
| "stopped"
|
||||
| "failed"
|
||||
|
||||
/** The structured progress object: the phase, when it began (Unix ms), and its data. */
|
||||
export type Progress = { phase: Phase; since: number } & {
|
||||
ahead?: number
|
||||
model?: string
|
||||
reason?: string
|
||||
done?: number
|
||||
total?: number
|
||||
rate?: number
|
||||
tool?: string
|
||||
what?: string
|
||||
running?: number
|
||||
in?: number
|
||||
attempt?: number
|
||||
}
|
||||
|
||||
export const PHASES = P.phases as readonly Phase[]
|
||||
|
||||
const fill = (t: string, v: Record<string, string | number>) => fillPrompt(t, Object.fromEntries(Object.entries(v).map(([k, x]) => [k, String(x)])))
|
||||
|
||||
/** A call's short title as `what`: one line, at most what_max characters. */
|
||||
export function shortWhat(s: string | undefined): string | undefined {
|
||||
if (!s) return undefined
|
||||
const line = s.split("\n")[0]!.trim()
|
||||
if (!line) return undefined
|
||||
return line.length > P.what_max ? `${line.slice(0, P.what_max - 1)}…` : line
|
||||
}
|
||||
|
||||
/** The phase's text (progress.md §2), its fields filled; `now` for retrying's countdown. */
|
||||
export function progressText(p: Progress, now = Date.now()): string {
|
||||
const T = P.texts as Record<string, string>
|
||||
switch (p.phase) {
|
||||
case "queued":
|
||||
return p.ahead !== undefined ? fill(T["queued.ahead"]!, { ahead: p.ahead }) : T.queued!
|
||||
case "waiting_for_model":
|
||||
return fill(T.waiting_for_model!, { model: p.model ?? "", reason: (P.reason as Record<string, string>)[p.reason ?? ""] ?? p.reason ?? "" })
|
||||
case "loading_model":
|
||||
return fill(T.loading_model!, { model: p.model ?? "" })
|
||||
case "reading_prompt":
|
||||
if (p.total === undefined || p.done === undefined || p.total <= 0) return T["reading_prompt.bare"]!
|
||||
return fill(T.reading_prompt!, { done: p.done, total: p.total, percent: Math.floor((p.done * 100) / p.total) })
|
||||
case "writing":
|
||||
return p.rate !== undefined && Number.isFinite(p.rate) ? fill(T.writing!, { rate: p.rate.toFixed(1) }) : T["writing.bare"]!
|
||||
case "running_tool":
|
||||
return p.what ? fill(T.running_tool!, { tool: p.tool ?? "", what: p.what }) : fill(T["running_tool.bare"]!, { tool: p.tool ?? "" })
|
||||
case "waiting_for_person": {
|
||||
const W = P.what as Record<string, string>
|
||||
const what = p.what === "approval" ? (p.tool ? fill(W.approval!, { tool: p.tool }) : W["approval.bare"]!) : (W[p.what ?? ""] ?? p.what ?? "")
|
||||
return fill(T.waiting_for_person!, { what })
|
||||
}
|
||||
case "waiting_for_subagents":
|
||||
return fill(T.waiting_for_subagents!, { running: p.running ?? 0 })
|
||||
case "retrying": {
|
||||
const left = Math.max(0, (p.in ?? 0) - Math.floor((now - p.since) / 1000))
|
||||
return fill(T.retrying!, { in: left, reason: p.reason ?? "" })
|
||||
}
|
||||
case "stopped":
|
||||
return p.reason ? fill(T["stopped.reason"]!, { reason: (P.stopped_reason as Record<string, string>)[p.reason] ?? p.reason }) : T.stopped!
|
||||
case "failed":
|
||||
return fill(T.failed!, { reason: p.reason ?? "" })
|
||||
default:
|
||||
return T[p.phase] ?? p.phase
|
||||
}
|
||||
}
|
||||
|
||||
/** The line a client draws (progress.md §3): the text and the time since it began, or the text
|
||||
* alone for stopped and failed. */
|
||||
export function progressLine(p: Progress, now = Date.now()): string {
|
||||
const text = progressText(p, now)
|
||||
if ((P.no_elapsed as string[]).includes(p.phase)) return text
|
||||
return fill(P.line, { text, elapsed: duration(now - p.since, true) })
|
||||
}
|
||||
|
||||
/** Whether two objects are the same phase for the same thing (so `since` is kept). */
|
||||
export function samePhase(a: Progress | null | undefined, b: Omit<Progress, "since">): boolean {
|
||||
if (!a || a.phase !== b.phase) return false
|
||||
if (b.phase === "running_tool") return a.tool === b.tool && a.what === b.what
|
||||
if (b.phase === "waiting_for_model" || b.phase === "loading_model") return a.model === b.model && a.reason === b.reason
|
||||
if (b.phase === "waiting_for_person") return a.what === b.what && a.tool === b.tool
|
||||
if (b.phase === "retrying") return a.attempt === b.attempt && a.reason === b.reason
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// Delegation (harness spec 3.4.0, subagents.md §7): a subagent's mode is its parent's or stricter,
|
||||
// request options go with a stricter mode, and who decides a subagent's call — itself, the main
|
||||
// agent (through a request), or the person. Pure rules over permission/evaluate.ts.
|
||||
import { LOOP } from "../harness.ts"
|
||||
import { fillPrompt } from "../prompt/fill.ts"
|
||||
import type { Mode } from "../config/schema.ts"
|
||||
import { evaluate, type Context as PermContext, type Decision, type PermissionRequest, type Rule, type ToolClass } from "../permission/evaluate.ts"
|
||||
import MODES_JSON from "../../harness/permission/modes.json"
|
||||
|
||||
const TEXTS = LOOP.team.texts
|
||||
const fill = (t: string, v: Record<string, string>) => fillPrompt(t, v)
|
||||
|
||||
/** Loosest first (permission/modes.json `order`). */
|
||||
export const MODE_ORDER = MODES_JSON.order as Mode[]
|
||||
const rank = (m: Mode) => MODE_ORDER.indexOf(m)
|
||||
|
||||
/** a is looser than b. */
|
||||
export const looser = (a: Mode, b: Mode) => rank(a) < rank(b)
|
||||
/** a is stricter than b. */
|
||||
export const stricter = (a: Mode, b: Mode) => rank(a) > rank(b)
|
||||
/** The stricter of the two. */
|
||||
export const strictest = (a: Mode, b: Mode): Mode => (stricter(a, b) ? a : b)
|
||||
|
||||
export const REQUEST_KINDS = LOOP.team.request.kinds as ToolClass[]
|
||||
|
||||
/** team_add's / team_assign's mode and request options for a parent in `parentMode` (§7): the
|
||||
* refusal, or the mode and options the subagent gets. */
|
||||
export function checkMode(parentMode: Mode, mode: Mode | undefined, request: string[] | undefined): { error: string } | { mode: Mode; request: ToolClass[] } {
|
||||
const m = mode ?? parentMode
|
||||
if (looser(m, parentMode)) return { error: fill(TEXTS.wider, { parent_mode: parentMode }) }
|
||||
const req = request ?? []
|
||||
if (req.length && !stricter(m, parentMode)) return { error: TEXTS.request_not_narrower }
|
||||
const bad = req.find((k) => !(REQUEST_KINDS as string[]).includes(k))
|
||||
if (bad !== undefined) return { error: fill(TEXTS.request_kind, { kind: bad }) }
|
||||
return { mode: m, request: [...new Set(req)] as ToolClass[] }
|
||||
}
|
||||
|
||||
/** A grant the main agent gave (team_decide): a rule for this subagent alone. */
|
||||
export interface Grant {
|
||||
tool: string
|
||||
/** The permission key of the tool (edit and write share `edit`). */
|
||||
permission?: string
|
||||
pattern: string
|
||||
}
|
||||
|
||||
export type Verdict = { answer: "run"; decision: Decision } | { answer: "person"; decision: Decision } | { answer: "refused"; text: string; decision?: Decision }
|
||||
|
||||
/** Who decides a subagent's call (§7). `ctx` is the session's permission context (its rules and
|
||||
* approvals); `mode` the subagent's, `main` the main agent's. A grant applies only where the main
|
||||
* agent's own mode would allow the call. */
|
||||
export function whoDecides(
|
||||
req: PermissionRequest,
|
||||
o: { ctx: Omit<PermContext, "mode">; mode: Mode; main: Mode; request: readonly string[]; grants: readonly Grant[]; unattended?: boolean },
|
||||
): Verdict {
|
||||
const mainD = evaluate(req, { ...o.ctx, mode: o.main })
|
||||
const grantRules: Rule[] = mainD.action === "allow" ? o.grants.map((g) => ({ permission: g.permission ?? g.tool, pattern: g.pattern, action: "allow" as const })) : []
|
||||
const own = evaluate(req, { ...o.ctx, mode: o.mode, rules: [...o.ctx.rules, ...grantRules] })
|
||||
if (own.action === "allow") return { answer: "run", decision: own }
|
||||
if (own.action === "deny") return { answer: "refused", text: `Denied: ${own.reason}`, decision: own }
|
||||
if (mainD.action === "allow") return { answer: "refused", text: o.request.includes(req.class) ? TEXTS.requestable : TEXTS.not_delegated }
|
||||
if (mainD.action === "deny") return { answer: "refused", text: `Denied: ${mainD.reason}`, decision: mainD }
|
||||
if (o.unattended) return { answer: "refused", text: "Refused: nobody can approve this in an unattended session.", decision: own }
|
||||
return { answer: "person", decision: own }
|
||||
}
|
||||
|
||||
/** team_request's own check: the tool's risk class must be one of the options. */
|
||||
export function requestRefusal(tool: string, cls: ToolClass | undefined, request: readonly string[]): string | undefined {
|
||||
if (cls && request.includes(cls)) return undefined
|
||||
return fill(TEXTS.not_requestable, { tool, kinds: request.join(", ") || "none" })
|
||||
}
|
||||
|
||||
/** The main agent made stricter: every subagent looser than its parent is made as strict as it,
|
||||
* down the tree (§7). `agents` in tree order; returns each one's mode after. */
|
||||
export function tighten(main: Mode, agents: { name: string; parent: string; mode: Mode }[], lead = LOOP.team.names.lead): Record<string, Mode> {
|
||||
const modes: Record<string, Mode> = { [lead]: main }
|
||||
const out: Record<string, Mode> = {}
|
||||
const pending = [...agents]
|
||||
// Parents first: an agent's parent is the lead or one listed before it, but tolerate any order.
|
||||
for (let guard = 0; pending.length && guard < 1000; guard++) {
|
||||
const i = pending.findIndex((a) => modes[a.parent] !== undefined)
|
||||
if (i < 0) break
|
||||
const a = pending.splice(i, 1)[0]!
|
||||
modes[a.name] = out[a.name] = strictest(a.mode, modes[a.parent]!)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -31,6 +31,9 @@ import { LOOP, TOOL_SPECS } from "../src/harness.ts"
|
||||
import { Team, TEAM_TOOLS, type Capacity } from "../src/team/team.ts"
|
||||
import { noteBlock } from "../src/session/prefix.ts"
|
||||
import { offer, subagentModel, type ModelInfo } from "../src/team/subagents.ts"
|
||||
import { progressLine, progressText, type Progress } from "../src/session/progress.ts"
|
||||
import { checkMode, requestRefusal, tighten, whoDecides, type Grant } from "../src/team/delegation.ts"
|
||||
import { fillPrompt as fillText } from "../src/prompt/fill.ts"
|
||||
|
||||
type Case = Record<string, unknown>
|
||||
|
||||
@@ -314,6 +317,44 @@ export const AREAS: Record<string, (c: Case) => unknown> = {
|
||||
throw new Error(`team: unknown op ${String(st.op)}`)
|
||||
})
|
||||
},
|
||||
// Progress (progress.md, spec 3.4.0): the text and the line a client draws.
|
||||
progress: (c) => {
|
||||
const p = c.progress as Progress
|
||||
return { text: progressText(p, c.now as number), line: progressLine(p, c.now as number) }
|
||||
},
|
||||
// Delegation (subagents.md §7, spec 3.4.0): the mode check, who decides a call, team_request's
|
||||
// check, and tightening down the tree.
|
||||
delegation: (c) => {
|
||||
if (c.op === "add") {
|
||||
const r = checkMode(c.parent_mode as Mode, c.mode as Mode | undefined, c.request as string[] | undefined)
|
||||
return "error" in r ? { result: r.error, mode: null } : { result: null, mode: r.mode }
|
||||
}
|
||||
if (c.op === "call") {
|
||||
const call = c.call as { tool: string; line?: string; path?: string }
|
||||
const req: PermissionRequest =
|
||||
call.tool === "bash"
|
||||
? { permission: "bash", class: "execute", patterns: [call.line!], command: call.line!, paths: ["/project"] }
|
||||
: { permission: call.tool === "read" ? "read" : "edit", class: call.tool === "read" ? "read" : "write", patterns: [call.path!], paths: [`/project/${call.path}`] }
|
||||
const v = whoDecides(req, {
|
||||
ctx: { rules: toRules(DEFAULT_RULES, "default"), hardline: BUILTIN_HARDLINE, root: "/project", planDir: "/project/.agent/plans", projectDir: "/project/.agent" },
|
||||
mode: c.mode as Mode,
|
||||
main: c.main_mode as Mode,
|
||||
request: (c.request as string[] | undefined) ?? [],
|
||||
grants: (c.grants as Grant[] | undefined) ?? [],
|
||||
unattended: c.unattended === true,
|
||||
})
|
||||
// The text is compared unless the case says the reason is each project's own.
|
||||
return v.answer === "refused" && c.own_reason !== true ? { answer: v.answer, text: v.text } : { answer: v.answer }
|
||||
}
|
||||
if (c.op === "request") {
|
||||
const cls = TOOL_SPECS[c.tool as string]?.risk as "read" | "write" | "execute" | undefined
|
||||
return { result: requestRefusal(c.tool as string, cls, c.request as string[]) ?? fillText(LOOP.team.texts.requested, { id: `${LOOP.team.request.id_prefix}1` }) }
|
||||
}
|
||||
if (c.op === "switch") {
|
||||
return tighten(c.main_to as Mode, c.agents as { name: string; parent: string; mode: Mode }[])
|
||||
}
|
||||
throw new Error(`delegation: unknown op ${String(c.op)}`)
|
||||
},
|
||||
library_scope: (c) => resolveBase(c.bases as { id: string; name: string }[], c.base as string | null),
|
||||
toolcall_index: (c) => {
|
||||
// The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`.
|
||||
|
||||
Reference in new issue
Block a user