From 391ed2da237d1616c3ae59ddc245218b90de325f Mon Sep 17 00:00:00 2001 From: Geoffrey Picron Date: Fri, 30 Oct 2026 13:46:17 +0100 Subject: [PATCH 1/2] fix(subagent): show supervisor questions above the answer field --- extensions/subagent.ts | 5 ++--- src/ask.ts | 5 +++-- test/subagent.test.ts | 17 ++++++++++++++--- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/extensions/subagent.ts b/extensions/subagent.ts index 9696537..c217571 100644 --- a/extensions/subagent.ts +++ b/extensions/subagent.ts @@ -41,7 +41,6 @@ import { ASK_REASONS, ASK_TOOL_DESCRIPTION, ASK_TOOL_NAME, - askBody, askTitle, formatAnswer, type AskReason, @@ -291,8 +290,8 @@ export default function subagent(pi: ExtensionAPI) { questionsLeft--; const answer = await withUiLock(() => ctx.ui.input( - askTitle(def.name, params.reason), - askBody(params.question, params.context).slice(0, 500), + askTitle(def.name, params.reason, params.question, params.context), + "Your answer", ), ); return { diff --git a/src/ask.ts b/src/ask.ts index a2c4d8f..0798438 100644 --- a/src/ask.ts +++ b/src/ask.ts @@ -36,13 +36,14 @@ export const ASK_TOOL_DESCRIPTION = [ ].join(" "); /** How the question is shown, and what the child gets back. */ -export function askTitle(agent: string, reason: AskReason): string { +export function askTitle(agent: string, reason: AskReason, question: string, context?: string): string { const label: Record = { need_decision: "needs a decision", clarify_scope: "needs the scope clarified", missing_access: "is missing access", }; - return `Subagent ${agent} ${label[reason]}`; + // ui.input's placeholder is not a message; keep the question in its visible title. + return `Subagent ${agent} ${label[reason]}\n\n${askBody(question, context)}`; } export function askBody(question: string, context: string | undefined): string { diff --git a/test/subagent.test.ts b/test/subagent.test.ts index bdf8a8a..f7c5b70 100644 --- a/test/subagent.test.ts +++ b/test/subagent.test.ts @@ -412,9 +412,12 @@ test("v0.6 a finished run with no answer is never reported as still running", () }); test("v0.6 ask_supervisor asks the smallest question, and handles a refusal", () => { - assert.equal(askTitle("reviewer", "need_decision"), "Subagent reviewer needs a decision"); - assert.equal(askTitle("scout", "clarify_scope"), "Subagent scout needs the scope clarified"); - assert.equal(askTitle("worker", "missing_access"), "Subagent worker is missing access"); + assert.equal(askTitle("reviewer", "need_decision", "Which database?"), + "Subagent reviewer needs a decision\n\nWhich database?"); + assert.equal(askTitle("scout", "clarify_scope", " Which files? ", " Sources only. "), + "Subagent scout needs the scope clarified\n\nWhich files?\n\nSources only."); + assert.equal(askTitle("worker", "missing_access", "Which credential?", " "), + "Subagent worker is missing access\n\nWhich credential?"); assert.equal(askBody("Which database?", " "), "Which database?"); assert.equal(askBody(" Which database? ", "Postgres is already a dependency."), @@ -435,6 +438,14 @@ test("v0.6 ask_supervisor asks the smallest question, and handles a refusal", () assert.ok(ASK_EXHAUSTED.includes(String(ASK_BUDGET))); }); +test("ask_supervisor keeps long, multiline questions and context in the visible title", () => { + const question = `Which resource?\n${"Details about the missing access. ".repeat(30)}END OF QUESTION`; + const context = "Already checked:\n- local files\n- agent tools\nEND OF CONTEXT"; + assert.ok(question.length > 500); + assert.equal(askTitle("scout", "missing_access", question, context), + `Subagent scout is missing access\n\n${question}\n\n${context}`); +}); + test("v0.6 the ask tool must be on the allowlist, or it is silently dropped", () => { // pi's `tools` option is an allowlist and it filters customTools too: a // custom tool missing from it is registered and then removed, and the child From 346e0d27a4db2aaafdd01782147f2b708a8a7146 Mon Sep 17 00:00:00 2001 From: Geoffrey Picron Date: Fri, 30 Oct 2026 14:06:28 +0100 Subject: [PATCH 2/2] fix(subagent): report access blockers without permission prompts --- README.md | 8 +++++--- src/ask.ts | 12 ++++++++++-- src/prompts.ts | 7 +++++-- test/subagent.test.ts | 24 +++++++++++++++++++----- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 48bf5a9..cb5f277 100644 --- a/README.md +++ b/README.md @@ -137,13 +137,15 @@ For CI, set `PIFY_TRUST_PROJECT=1`. It is an environment variable rather than a ## A child can ask instead of guessing -A scoped child that hits a decision it should not be making — an unstated product, API or scope choice, or missing access — calls `ask_supervisor`, and the question reaches **you** through the usual dialog. Only when there is a UI to ask through: a headless run has nobody to answer, so the tool is deliberately not offered there — measured in `test/live/ask-wire.mjs` (the tool absent headless, `agent_run` still present). The offered-when-interactive half needs a TUI, which a scripted `-p` probe cannot see into; it is exercised by using it, not by a test that fakes it. +A scoped child that hits a decision it should not be making — an unstated product, API or scope choice, or a choice of an authorised file or resource — calls `ask_supervisor`, and the question reaches **you** through the usual dialog. The question and context appear above the answer field, with a reminder that replying does not add tools or change access permissions. Only when there is a UI to ask through: a headless run has nobody to answer, so the tool is deliberately not offered there — measured in `test/live/ask-wire.mjs` (the tool absent headless, `agent_run` still present). The offered-when-interactive half needs a TUI, which a scripted `-p` probe cannot see into; it is exercised by using it, not by a test that fakes it. -It reaches you rather than the parent agent for a structural reason: the parent is blocked inside the tool call that spawned the child, so it could not answer anyway. And the decision is yours regardless. +Missing tools, unavailable credentials and denied operations are execution blockers, not decisions for this dialog. The child is instructed to finish what the brief authorises and report the resource, required tool, exact error (redacting secrets) and remaining work to the parent, without retrying or bypassing the denial. This is model guidance, not a new permission mechanism or a guarantee that a model will never ask unnecessarily. + +A genuine decision reaches you rather than the parent agent for a structural reason: the parent is blocked inside the tool call that spawned the child, so it could not answer anyway. And the decision is yours regardless. A declined question is not permission to guess. The child is told to finish what the brief authorises and report what it could not decide. Three questions per run, and there is deliberately no progress channel — a child's progress belongs in its report, not in an interruption. -Measured across qwen3-235b, gpt-5.5 and claude-sonnet-4.5: six runs out of six asked rather than inventing an answer. +The original decision-question framing was measured across qwen3-235b, gpt-5.5 and claude-sonnet-4.5: six runs out of six asked rather than inventing an answer. That measurement does not cover the revised access-blocker guidance. ## `@agent` at the prompt diff --git a/src/ask.ts b/src/ask.ts index 0798438..712a4f0 100644 --- a/src/ask.ts +++ b/src/ask.ts @@ -24,12 +24,20 @@ export type AskReason = (typeof ASK_REASONS)[number]; export const ASK_TOOL_NAME = "ask_supervisor"; +export const ACCESS_BOUNDARY = [ + "If a required tool or credential is unavailable, or an operation is denied, do not ask the user to grant access.", + "Complete what the brief authorises and report the resource, required tool, exact error (redacting secrets), and remaining work to the caller.", + "Do not retry or bypass an access denial.", + "A human reply does not add tools or change access permissions.", +].join(" "); + export const ASK_TOOL_DESCRIPTION = [ "Ask the person supervising this delegated task, and wait for their answer.", "Use it when continuing would mean inventing a decision that is theirs:", "need_decision (a product, API, or scope choice with no defensible default),", "clarify_scope (the brief admits two readings that lead to different work),", - "missing_access (a file, credential, or permission the task needs is not available).", + "missing_access (a human choice of an authorised file or resource).", + ACCESS_BOUNDARY, "Do not use it to report progress, to confirm something you can verify yourself,", "or to ask permission for work the brief already authorised. Each question costs", "the user an interruption, so ask the smallest question that unblocks you.", @@ -43,7 +51,7 @@ export function askTitle(agent: string, reason: AskReason, question: string, con missing_access: "is missing access", }; // ui.input's placeholder is not a message; keep the question in its visible title. - return `Subagent ${agent} ${label[reason]}\n\n${askBody(question, context)}`; + return `Subagent ${agent} ${label[reason]}\n\n${askBody(question, context)}\n\nReplying does not add tools or change access permissions.`; } export function askBody(question: string, context: string | undefined): string { diff --git a/src/prompts.ts b/src/prompts.ts index 1ddff53..3b15165 100644 --- a/src/prompts.ts +++ b/src/prompts.ts @@ -1,4 +1,5 @@ import type { AgentDef, RunState } from "./types.ts"; +import { ACCESS_BOUNDARY } from "./ask.ts"; import { outcomeLine } from "./outcome.ts"; import { UNTRUSTED_REPORT_NOTE, neutralizeReport } from "./pending.ts"; @@ -50,6 +51,7 @@ const CHILD_BASE = [ "end the report with a line reading exactly `OUTCOME: blocked` (a decision, access or", "information you do not have) or `OUTCOME: failed` (you tried and it does not work),", "so the caller does not have to infer it from your prose. Say nothing if it went fine.", + ACCESS_BOUNDARY, ].join(" "); /** @@ -58,7 +60,8 @@ const CHILD_BASE = [ * ask_supervisor tool is unreachable in practice: the base framing forbids * asking, and a model follows the prompt over a tool description. * - * Measured across qwen3-235b, gpt-5.5 and claude-sonnet-4.5 on two framings + * The original decision exception was measured across qwen3-235b, gpt-5.5 + * and claude-sonnet-4.5 on two framings * of an underspecified task: with the tool registered and this line present, * six runs out of six asked instead of inventing the answer. */ @@ -67,7 +70,7 @@ export function childFraming(canAsk: boolean): string { return [ CHILD_BASE, "The one exception: if continuing would mean inventing a decision that belongs to the", - "supervisor — an unstated product/API/scope choice, or missing access — call ask_supervisor", + "supervisor — an unstated product/API/scope choice, or a choice of an authorised file or resource — call ask_supervisor", "and wait for the answer instead of guessing. Ask the smallest question that unblocks you.", ].join(" "); } diff --git a/test/subagent.test.ts b/test/subagent.test.ts index f7c5b70..403e7e1 100644 --- a/test/subagent.test.ts +++ b/test/subagent.test.ts @@ -413,11 +413,11 @@ test("v0.6 a finished run with no answer is never reported as still running", () test("v0.6 ask_supervisor asks the smallest question, and handles a refusal", () => { assert.equal(askTitle("reviewer", "need_decision", "Which database?"), - "Subagent reviewer needs a decision\n\nWhich database?"); + "Subagent reviewer needs a decision\n\nWhich database?\n\nReplying does not add tools or change access permissions."); assert.equal(askTitle("scout", "clarify_scope", " Which files? ", " Sources only. "), - "Subagent scout needs the scope clarified\n\nWhich files?\n\nSources only."); - assert.equal(askTitle("worker", "missing_access", "Which credential?", " "), - "Subagent worker is missing access\n\nWhich credential?"); + "Subagent scout needs the scope clarified\n\nWhich files?\n\nSources only.\n\nReplying does not add tools or change access permissions."); + assert.equal(askTitle("worker", "missing_access", "Which input file?", " "), + "Subagent worker is missing access\n\nWhich input file?\n\nReplying does not add tools or change access permissions."); assert.equal(askBody("Which database?", " "), "Which database?"); assert.equal(askBody(" Which database? ", "Postgres is already a dependency."), @@ -443,7 +443,21 @@ test("ask_supervisor keeps long, multiline questions and context in the visible const context = "Already checked:\n- local files\n- agent tools\nEND OF CONTEXT"; assert.ok(question.length > 500); assert.equal(askTitle("scout", "missing_access", question, context), - `Subagent scout is missing access\n\n${question}\n\n${context}`); + `Subagent scout is missing access\n\n${question}\n\n${context}\n\nReplying does not add tools or change access permissions.`); +}); + +test("access blockers are reported to the caller, not escalated as permission requests", () => { + for (const instructions of [ASK_TOOL_DESCRIPTION, childFraming(true), childFraming(false)]) { + assert.match(instructions, /tool or credential is unavailable/); + assert.match(instructions, /operation is denied/); + assert.match(instructions, /do not ask the user to grant access/); + assert.match(instructions, /report the resource, required tool, exact error \(redacting secrets\), and remaining work to the caller/); + assert.match(instructions, /Do not retry or bypass an access denial/); + assert.match(instructions, /reply does not add tools or change access permissions/); + } + assert.ok(ASK_TOOL_DESCRIPTION.includes("missing_access (a human choice of an authorised file or resource)")); + assert.ok(childFraming(true).includes("a choice of an authorised file or resource")); + assert.ok(!childFraming(true).includes("or missing access — call")); }); test("v0.6 the ask tool must be on the allowlist, or it is silently dropped", () => {