diff --git a/harness/conformance/delegation.json b/harness/conformance/delegation.json index cb42a25..58feab7 100644 --- a/harness/conformance/delegation.json +++ b/harness/conformance/delegation.json @@ -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" } diff --git a/src/harness.ts b/src/harness.ts index c103ae1..76576bf 100644 --- a/src/harness.ts +++ b/src/harness.ts @@ -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 + reason: Record + what: Record + stopped_reason: Record + 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 diff --git a/src/session/progress.ts b/src/session/progress.ts new file mode 100644 index 0000000..dd11f5b --- /dev/null +++ b/src/session/progress.ts @@ -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) => 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 + 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)[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 + 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)[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): 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 +} diff --git a/src/team/delegation.ts b/src/team/delegation.ts new file mode 100644 index 0000000..0b9eaba --- /dev/null +++ b/src/team/delegation.ts @@ -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) => 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; 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 { + const modes: Record = { [lead]: main } + const out: Record = {} + 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 +} diff --git a/tests/conformance.ts b/tests/conformance.ts index b42d4b0..fbc7a44 100644 --- a/tests/conformance.ts +++ b/tests/conformance.ts @@ -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 @@ -314,6 +317,44 @@ export const AREAS: Record 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`.