feat(coding-agent): introduced configurable soft request budget steering notices

- Introduced the `task.softRequestBudgetNotice` boolean setting to opt into budget steering notices.
- Disabled the wrap-up steering notice by default when a subagent crosses its soft request budget.
- Maintained the 1.5x graceful abort safety guard regardless of whether the steering notice option is enabled.
- Updated the settings schema to document the conditional steering notice behavior.
This commit is contained in:
can1357
2026-07-02 02:40:07 +02:00
parent 36656766e4
commit b4cb78304c
4 changed files with 111 additions and 14 deletions
+8 -1
View File
@@ -5,6 +5,7 @@
### Added
- Added `providers.anthropic.serverSideFallback` (default off; UI in the "Model → Retry & Fallback" group). When enabled, Claude Fable 5 / Mythos 5 requests carry `fallbacks: [{ model: "claude-opus-4-8" }]` via Anthropic's server-side-fallback beta chain so classifier-blocked turns are retried on Opus 4.8 without breaking the current call. Opt-in only — leaving it off preserves the pre-fallback behavior. ([#4177](https://github.com/can1357/oh-my-pi/issues/4177))
- Added `task.softRequestBudgetNotice` (default off) to opt into the subagent soft-budget wrap-up steering notice while keeping the 1.5x graceful abort guard active.
### Changed
@@ -15,6 +16,10 @@
### Fixed
- Fixed collab teardown (`/collab stop`, unrecoverable relay drop) cancelling a hook selector/editor the host user was actively typing in: teardown resolved pending guest asks the same way as a guest cancel, so the mirrored race dismissed the local dialog and dropped its input. Teardown now settles guest asks as `unavailable`, and the local dialog keeps running and wins with its eventual answer.
- Fixed RPC mode deferred shutdown (`pi.shutdown()`) killing the process while a background-dispatched `bash` command was still running: the response frame is now written before exit, and a shutdown requested mid-bash fires once the command settles even when no further client frames arrive.
- Fixed collab-guest transcript viewer rendering host-delivered errors raw: multi-line stacks broke the frame's row accounting and absolute host paths leaked to guests; errors are now collapsed to one sanitized, truncated row.
- Fixed streaming tool-arg previews capturing string fields (e.g. `content`) from nested objects and injecting them as top-level args mid-stream; only top-level keys are read incrementally now.
- Fixed task.maxConcurrency being breachable when a queued spawn was cancelled: the spawn path could release a semaphore permit it never acquired, letting a later task start while the cap was saturated.
- Fixed session exit diagnostics recording signal and crash exits (SIGTERM, SIGHUP, uncaught exceptions) as a normal "dispose": the postmortem teardown now threads the real reason into session disposal.
- Fixed the subagent yield-label guard ignoring JTD discriminator (oneOf) output schemas, which let stale incremental labels pass into successful results when final validation was skipped after retries.
@@ -22,7 +27,7 @@
- Fixed model discovery ignoring `NODE_EXTRA_CA_CERTS`: the model registry's default fetch now applies the extra-CA wrapper, so `/models` probes work behind private-CA gateways like provider chat requests.
- Fixed ctrl+p role-model cycling getting stuck on one transition and skipping every other role: a session-branch traversal regression returned entries leaf-to-root, so the cycle (and session model restore) read the oldest recorded model change instead of the newest.
- Fixed ctrl+p cycling from a stale slot after the model was switched through another surface (alt+m, /model, retry fallback): the recorded role is now trusted only while its resolved model is still the active model, falling back to matching by model.
- Fixed the apply_patch tool to prevent silently overwriting pre-existing files during creation or renaming, rejecting upfront with an error instead.
- Fixed the `apply_patch` envelope to reject `*** Add File` / `*** Move to` targeting a pre-existing file upfront instead of silently overwriting it. The JSON `patch` mode's `op: "create"` intentionally remains the documented full-file overwrite (rename stays non-overwriting in both modes).
- Fixed multi-file apply_patch to stop at the first failing file, surface applied vs. skipped paths, and correctly report the error to the agent loop.
- Fixed process termination (SIGTERM, SIGHUP, uncaught exceptions) skipping editor draft saves, session shutdown events, and background job cleanup.
- Fixed /quit and /exit commands blocking session closure by introducing a shutdown budget and backgrounding remaining tasks.
@@ -42,6 +47,8 @@
- Fixed search and AST tools accepting external read URLs by materializing fetched URL text through the read cache before path resolution.
- Fixed Tavily web search to retry without recency filters if no content is returned.
- Fixed extension validation failures for omp install pi-lean-ctx by exposing legacy tool factories.
- Fixed legacy `createReadTool`/`createReadToolDefinition` ignoring `autoResizeImages`; the option now maps onto the `images.autoResize` setting of the underlying read tool.
- Fixed legacy `createGrepTool`/`createGrepToolDefinition` silently ignoring options: an explicit `context` parameter is now forwarded to the built-in grep (as symmetric before/after context), the unsupported `limit` parameter is no longer advertised in the tool schema, and supplying legacy `operations` throws a descriptive error at creation time instead of silently searching the local filesystem.
- Fixed visibility of the focused option in the multi-select ask picker on certain color themes.
- Fixed TUI row overlapping and duplication in the eval tool's live subagent progress tree under heavy concurrency.
- Fixed session resumes after silent exits by recording pre-tool start markers and shutdown diagnostics.
@@ -4145,7 +4145,7 @@ export const SETTINGS_SCHEMA = {
group: "Subagents",
label: "Soft Subagent Request Budget",
description:
"Soft per-subagent request budget (assistant requests per run). Crossing it injects one steering notice asking the subagent to wrap up; at 1.5x the budget the run is aborted gracefully, salvaging partial output. 0 disables the guard. Bundled explore/sonic agents use a lower built-in budget.",
"Soft per-subagent request budget (assistant requests per run). Crossing it can inject a steering notice when task.softRequestBudgetNotice is enabled; at 1.5x the budget the run is aborted gracefully, salvaging partial output. 0 disables the guard. Bundled explore/sonic agents use a lower built-in budget.",
options: [
{ value: "0", label: "Disabled" },
{ value: "40", label: "40 requests" },
@@ -4155,6 +4155,18 @@ export const SETTINGS_SCHEMA = {
},
},
"task.softRequestBudgetNotice": {
type: "boolean",
default: false,
ui: {
tab: "tasks",
group: "Subagents",
label: "Soft Request Budget Notice",
description:
"Inject one steering notice when a subagent crosses its soft request budget. Off by default; enabling it asks the child to wrap up before the 1.5x graceful abort guard.",
},
},
"task.disabledAgents": {
type: "array",
default: [] as string[],
+24 -7
View File
@@ -80,10 +80,12 @@ const MCP_CALL_TIMEOUT_MS = 60_000;
/**
* Soft per-agent request budgets (assistant requests per run). When a subagent
* crosses its budget it receives ONE steering notice asking it to wrap up; at
* 1.5x the budget the run is aborted gracefully so partial output is salvaged.
* The `default` key applies to agents without an explicit entry and can be
* overridden via the `task.softRequestBudget` setting (0 disables the guard).
* crosses its budget it can receive an optional steering notice asking it to
* wrap up; at 1.5x the budget the run is aborted gracefully so partial output is
* salvaged. The `default` key applies to agents without an explicit entry and
* can be overridden via the `task.softRequestBudget` setting (0 disables the
* guard). The notice is off by default and controlled separately by
* `task.softRequestBudgetNotice`.
*/
export const SOFT_REQUEST_BUDGET: Record<string, number> = {
explore: 40,
@@ -91,7 +93,7 @@ export const SOFT_REQUEST_BUDGET: Record<string, number> = {
default: 90,
};
/** Steering notice injected once when a subagent crosses its soft request budget. */
/** Optional steering notice injected when a subagent crosses its soft request budget. */
export function buildBudgetNotice(requests: number): string {
return `[budget notice] You have used ${requests} requests in this run. Wrap up now: finish the current step and yield your final report.`;
}
@@ -798,6 +800,8 @@ interface RunMonitorArgs {
sessionFile?: string;
/** Soft assistant-request budget; 0 disables the guard. */
softRequestBudget: number;
/** Whether crossing the soft budget injects a wrap-up steering notice. */
softRequestBudgetNotice: boolean;
/** Wall-clock cap in ms; 0 disables the timer. */
maxRuntimeMs: number;
}
@@ -840,7 +844,18 @@ interface SubagentRunMonitor {
}
function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
const { index, id, agent, task, assignment, signal, onProgress, softRequestBudget, maxRuntimeMs } = args;
const {
index,
id,
agent,
task,
assignment,
signal,
onProgress,
softRequestBudget,
softRequestBudgetNotice,
maxRuntimeMs,
} = args;
const startTime = Date.now();
const progress: AgentProgress = {
@@ -1237,7 +1252,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
if (softRequestBudget > 0 && !abortSent) {
if (progress.requests >= softRequestBudget * 1.5) {
requestAbort("budget");
} else if (!budgetSteerSent && progress.requests >= softRequestBudget) {
} else if (softRequestBudgetNotice && !budgetSteerSent && progress.requests >= softRequestBudget) {
budgetSteerSent = true;
const steerSession = activeSession;
if (steerSession) {
@@ -1873,6 +1888,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
);
const softRequestBudget =
configuredDefaultBudget === 0 ? 0 : (SOFT_REQUEST_BUDGET[agent.name] ?? configuredDefaultBudget);
const softRequestBudgetNotice = settings.get("task.softRequestBudgetNotice") ?? false;
const parentDepth = options.taskDepth ?? 0;
const childDepth = parentDepth + 1;
const atMaxDepth = maxRecursionDepth >= 0 && childDepth >= maxRecursionDepth;
@@ -1932,6 +1948,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
detached: options.detached,
sessionFile: subtaskSessionFile,
softRequestBudget,
softRequestBudgetNotice,
maxRuntimeMs,
});
const progress = monitor.progress;
@@ -167,7 +167,11 @@ describe("runSubprocess request guards", () => {
it("injects exactly one steering notice when the soft budget is crossed", async () => {
// Budget 4: steer fires at request 4 and must not repeat at request 5
// (still below the 1.5x hard stop of 6).
const settings = Settings.isolated({ "task.maxRuntimeMs": 0, "task.softRequestBudget": 4 });
const settings = Settings.isolated({
"task.maxRuntimeMs": 0,
"task.softRequestBudget": 4,
"task.softRequestBudgetNotice": true,
});
const handle = createFakeSession({
events: [
assistantMessageEnd("1"),
@@ -190,10 +194,67 @@ describe("runSubprocess request guards", () => {
expect(handle.steerCalls[0].options?.deliverAs).toBe("steer");
});
it("aborts the run gracefully at 1.5x the soft budget", async () => {
// Budget 2: steer at 2, hard stop at 3. The session hangs so only the
// budget abort can release it.
const settings = Settings.isolated({ "task.maxRuntimeMs": 0, "task.softRequestBudget": 2 });
it("does not inject a steering notice by default when the soft request budget is crossed", async () => {
// Budget 4 is crossed at request 4, but notices default off; the run is
// still below the 1.5x hard stop of 6 and should complete without steer.
const settings = Settings.isolated({
"task.maxRuntimeMs": 0,
"task.softRequestBudget": 4,
});
const handle = createFakeSession({
events: [
assistantMessageEnd("1"),
assistantMessageEnd("2"),
assistantMessageEnd("3"),
assistantMessageEnd("4"),
assistantMessageEnd("5"),
yieldToolEnd(),
],
});
mockCreateAgentSession(handle.session);
const result = await runSubprocess({ ...baseOptions, id: "subagent-steer-disabled", settings });
expect(result.requests).toBe(5);
expect(result.aborted).toBe(false);
expect(handle.steerCalls).toEqual([]);
});
it("still aborts at 1.5x the soft budget when budget notices are disabled", async () => {
// Budget 2: notice would normally fire at 2, but the hard stop at 3 must
// remain active even with the notice disabled.
const settings = Settings.isolated({
"task.maxRuntimeMs": 0,
"task.softRequestBudget": 2,
"task.softRequestBudgetNotice": false,
});
const handle = createFakeSession({
hang: true,
events: [
assistantMessageEnd("", { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15 }),
assistantMessageEnd("", { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15 }),
assistantMessageEnd("", { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, totalTokens: 15 }),
],
});
mockCreateAgentSession(handle.session);
const result = await runSubprocess({ ...baseOptions, id: "subagent-hard-stop-notice-disabled", settings });
expect(result.aborted).toBe(true);
expect(result.exitCode).toBe(1);
expect(result.abortReason).toContain("request budget exceeded");
expect(handle.abortCalls()).toBeGreaterThanOrEqual(1);
expect(handle.steerCalls).toEqual([]);
});
it("aborts the run gracefully at 1.5x the soft budget with notices enabled", async () => {
// Budget 2: with notices enabled, steer at 2 and hard stop at 3. The
// session hangs so only the budget abort can release it.
const settings = Settings.isolated({
"task.maxRuntimeMs": 0,
"task.softRequestBudget": 2,
"task.softRequestBudgetNotice": true,
});
const handle = createFakeSession({
hang: true,
events: [