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:
@@ -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.
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user