fix(coding-agent): expose extra.title in resolve schema, requeue on apply throw

- The plan-approval gate required extra.title but the wire schema only
  declared an opaque additionalProperties record so codex/gpt-5.x could
  not discover the field. Schema now declares title with a description
  while still allowing passthrough for future per-context keys.
- resolve.md replaces the truncated "Schema depends on context:" line
  with the actual enumeration.
- runResolveInvocation wraps apply() in try/catch; a thrown apply (e.g.
  ast_edit overlap) requeues the resolve directive so the model can
  discard or fix-and-retry instead of losing the preview.
This commit is contained in:
can1357
2026-05-19 19:18:48 +09:00
committed by Can Bölük
parent b191e6c5ff
commit e26a17f3fc
2 changed files with 67 additions and 24 deletions
@@ -3,7 +3,7 @@ Resolves a pending action by either applying or discarding it.
- `"apply"` persists / submits the pending action.
- `"discard"` rejects the pending action.
- `reason` is required: one short complete sentence explaining why, starting with a capital letter and ending with a period.
- `extra` (optional) is free-form metadata passed to the resolving tool. Schema depends on context:
- `extra` (optional) is free-form metadata passed to the resolving tool. When the pending action is a plan-approval gate, supply `extra.title` (kebab/PascalCase slug for the approved plan filename). For preview-style pending actions (e.g. `ast_edit`), `extra` is unused.
Valid whenever a pending action exists — either a preview-style staging (e.g. `ast_edit`) or a long-lived approval gate.
Call fails with an error when no pending action exists.
+66 -23
View File
@@ -14,7 +14,18 @@ import { ToolError } from "./tool-errors";
const resolveSchema = z.object({
action: z.enum(["apply", "discard"]),
reason: z.string().describe("reason for action"),
extra: z.record(z.string(), z.unknown()).optional().describe("free-form metadata"),
extra: z
.object({
title: z
.string()
.optional()
.describe(
"Plan title slug; required when resolving a plan-approval gate. Used as the approved-plan filename stem.",
),
})
.passthrough()
.optional()
.describe("free-form metadata; schema depends on context (see prompt)"),
});
type ResolveParams = z.infer<typeof resolveSchema>;
@@ -51,28 +62,43 @@ export function queueResolveHandler(
const forced = session.buildToolChoice?.("resolve");
if (!queue || !forced || typeof forced === "string") return;
queue.pushOnce(forced, {
label: `pending-action:${options.sourceToolName}`,
now: true,
onRejected: () => "requeue",
onInvoked: async (input: unknown) =>
runResolveInvocation(input as ResolveParams, {
sourceToolName: options.sourceToolName,
label: options.label,
apply: options.apply,
reject: options.reject,
}),
});
const steerReminder = (): void => {
session.steer?.({
customType: "resolve-reminder",
content: [
"<system-reminder>",
"This is a preview. Call the `resolve` tool to apply or discard these changes.",
"</system-reminder>",
].join("\n"),
details: { toolName: options.sourceToolName },
});
};
session.steer?.({
customType: "resolve-reminder",
content: [
"<system-reminder>",
"This is a preview. Call the `resolve` tool to apply or discard these changes.",
"</system-reminder>",
].join("\n"),
details: { toolName: options.sourceToolName },
});
const pushDirective = (): void => {
queue.pushOnce(forced, {
label: `pending-action:${options.sourceToolName}`,
now: true,
onRejected: () => "requeue",
onInvoked: async (input: unknown) =>
runResolveInvocation(input as ResolveParams, {
sourceToolName: options.sourceToolName,
label: options.label,
apply: options.apply,
reject: options.reject,
onApplyError: () => {
// Apply threw (e.g. ast_edit overlapping replacements). Re-push the
// same directive so the preview remains pending and the model can
// `discard` or fix-and-retry on the next turn instead of being
// stranded with no pending action to address.
pushDirective();
steerReminder();
},
}),
});
};
pushDirective();
steerReminder();
}
/**
@@ -89,6 +115,11 @@ export async function runResolveInvocation(
label: string;
apply(reason: string, extra?: Record<string, unknown>): Promise<AgentToolResult<unknown>>;
reject?(reason: string, extra?: Record<string, unknown>): Promise<AgentToolResult<unknown> | undefined>;
/** Invoked synchronously when `apply()` throws, before the error is rethrown.
* The queued caller uses this to re-push the resolve directive so the
* pending preview survives a failed apply (e.g. overlapping ast_edit
* replacements) and the model can `discard` or fix-and-retry. */
onApplyError?(error: unknown): void;
},
): Promise<AgentToolResult<ResolveToolDetails>> {
const baseDetails: ResolveToolDetails = {
@@ -99,7 +130,19 @@ export async function runResolveInvocation(
...(params.extra != null ? { extra: params.extra } : {}),
};
if (params.action === "apply") {
const result = await options.apply(params.reason, params.extra);
let result: AgentToolResult<unknown>;
try {
result = await options.apply(params.reason, params.extra);
} catch (error) {
try {
options.onApplyError?.(error);
} catch {
// Requeue hook must not mask the original apply failure.
}
if (error instanceof ToolError) throw error;
const message = error instanceof Error ? error.message : String(error);
throw new ToolError(`Apply failed: ${message}`);
}
return {
...result,
details: {