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

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:
HomerandClaude Opus 5.5 committed 2026-11-10 08:12:29 +00:00
1 parent 4b8786b7f6
commit 6199028358
5 files changed
+279 -7

No files matched your search

+5 -3
View File
@@ -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": [ "cases": [
{ {
"about": "No mode given: the parent's.", "about": "No mode given: the parent's.",
@@ -315,9 +315,9 @@
"tool": "bash", "tool": "bash",
"line": "rm -rf build" "line": "rm -rf build"
}, },
"own_reason": true,
"expect": { "expect": {
"answer": "refused", "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."
} }
}, },
{ {
@@ -342,6 +342,7 @@
"tool": "bash", "tool": "bash",
"line": "rm -rf /" "line": "rm -rf /"
}, },
"own_reason": true,
"expect": { "expect": {
"answer": "refused" "answer": "refused"
} }
@@ -356,6 +357,7 @@
"tool": "bash", "tool": "bash",
"line": "git push origin main" "line": "git push origin main"
}, },
"own_reason": true,
"expect": { "expect": {
"answer": "refused" "answer": "refused"
} }
+43 -4
View File
@@ -128,13 +128,52 @@ export const LOOP: {
/** Teams (spec 3.2.0, harness/team.md): the size setting, names, marks, texts. */ /** Teams (spec 3.2.0, harness/team.md): the size setting, names, marks, texts. */
team: { team: {
agents: { min: number; max: number; cap: number } agents: { min: number; max: number; cap: number }
names: { pattern: string; lead: string; reserved: string[] } names: { pattern: string; lead: string; reserved: string[]; parent: string; id_prefix: string }
marks: { message: string; report: string } marks: { message: string; report: string; assignment: string; request: string; decision: string; context_full: string }
unavailable: { one_agent: string; one_request: string } unavailable: { one_agent: string; one_request: string }
work: { write: string[]; execute: string[]; kept: string[] } work: { write: string[]; execute: string[]; kept: string[] }
read_max: number 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> texts: Record<
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 } | "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 /** 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 * header, a stub, the elided line, recall; the output cap; what is deduplicated; the table
+104
View File
@@ -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
}
+86
View File
@@ -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
}
+41
View File
@@ -31,6 +31,9 @@ import { LOOP, TOOL_SPECS } from "../src/harness.ts"
import { Team, TEAM_TOOLS, type Capacity } from "../src/team/team.ts" import { Team, TEAM_TOOLS, type Capacity } from "../src/team/team.ts"
import { noteBlock } from "../src/session/prefix.ts" import { noteBlock } from "../src/session/prefix.ts"
import { offer, subagentModel, type ModelInfo } from "../src/team/subagents.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> 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)}`) 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), library_scope: (c) => resolveBase(c.bases as { id: string; name: string }[], c.base as string | null),
toolcall_index: (c) => { toolcall_index: (c) => {
// The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`. // The deltas as the OpenAI stream carries them: `function.name` and `function.arguments`.