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:
@@ -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[],
|
||||
|
||||
@@ -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: [
|
||||
|
||||
Reference in New Issue
Block a user