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
+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
|
||||
}
|
||||
Reference in new issue
Block a user