Allow explicit absent continuation in provider completion tools

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-02 17:47:32 -05:00
1 parent 304af99040
commit 88fbc2c702
5 files changed
+67 -3

No files matched your search

+6
View File
@@ -66,6 +66,12 @@ effects, audit, retries, conflicts, redaction, continuation, and terminal
decisions. The production adapter stays App-owned and invokes Paperclip's real
route/service authorities; it does not copy those rules into this package.
Provider-facing `paperclip_finish` accepts an omitted or `null` continuation for
`done`, `completed`, and `needs_review`. This supports gateways that require all
declared tool properties to be present. Normalization removes only `null`;
non-yielding tool calls still reject a continuation object, and `yielded` still
requires a complete `response_wake` object.
## Quick start
Native provider debug-trace correlation uses an incremental index owned by its transport. Pending
@@ -131,6 +131,19 @@ describe("provider-neutral completion result schema", () => {
expect(validate(response)).toBe(false);
});
it.each(["done", "needs_review", "completed"])("accepts explicit no-continuation at the provider boundary for %s", (disposition) => {
const providerValidate = new Ajv2020({ allErrors: true, strict: false })
.compile(PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA);
const response = {
...structuredClone(baseResult),
reportedWorkDisposition: disposition,
continuation: null,
};
expect(providerValidate(response)).toBe(true);
// The canonical output remains strict; normalization removes only null.
expect(validate(response)).toBe(false);
});
it("exposes concrete completion fields while retaining response-wake validation", () => {
// The live Codex code-mode renderer reduced a conditional-only root allOf
// to `args: unknown`. Keep this tool object-shaped for provider discovery.
@@ -154,6 +167,7 @@ describe("provider-neutral completion result schema", () => {
};
expect(providerValidate(yielded)).toBe(true);
expect(providerValidate({ ...yielded, continuation: undefined })).toBe(false);
expect(providerValidate({ ...yielded, continuation: null })).toBe(false);
expect(providerValidate({ ...yielded, continuation: { kind: "response_wake" } })).toBe(false);
expect(providerValidate({
...yielded, continuation: { ...yielded.continuation, kind: "same_agent" },
@@ -355,15 +355,23 @@ export const PRP_COMPLETION_RESULT_PROVIDER_INPUT_SCHEMA = {
reportedWorkDisposition: { enum: ["done", "needs_review", "yielded", "completed"] },
verification: providerVerificationCompatibilitySchema,
attentionRequests: providerAttentionCompatibilitySchema,
continuation: responseWakeContinuationSchema,
// Responses-compatible gateways can require every declared property in
// tool calls. Give non-yielding results an explicit absence value instead
// of forcing callers to invent a response-wake continuation.
continuation: {
...responseWakeContinuationSchema,
type: ["object", "null"],
description:
"Use null or omit this field for done, completed, or needs_review. Only yielded requires a response-wake object with kind, summary, and idempotencyKey.",
},
},
// Keep the provider-facing root a concrete object. Codex code-mode renders a
// root allOf containing only an if/then constraint as `args: unknown`, hiding
// every required field from the model. This equivalent direct conditional
// preserves validation without obscuring the object-shaped tool signature.
if: { properties: { reportedWorkDisposition: { const: "yielded" } }, required: ["reportedWorkDisposition"] },
then: { required: ["continuation"] },
else: { not: { required: ["continuation"] } },
then: { required: ["continuation"], properties: { continuation: { type: "object" } } },
else: { properties: { continuation: { type: "null" } } },
} as const;
export const PRP_BLOCK_RESULT_PROVIDER_INPUT_SCHEMA = {
@@ -6,6 +6,36 @@ import {
import { validatePrpStructuredRunResult } from "./replay-contract.js";
describe("normalizePrpResultSignals", () => {
it("normalizes an explicit null continuation without weakening completion rules", () => {
const providerResult = {
reportedWorkDisposition: "done",
summary: "Renamed the task to bobathan.",
completionClaim: {
contractRevision: "1",
objectiveSatisfied: true,
criteria: [{ criterionId: "objective", status: "satisfied", evidenceRefs: [] }],
remainingWork: [],
},
evidence: [],
verification: [],
continuation: null,
};
const validated = validatePrpStructuredRunResult(providerResult);
expect(validated).toMatchObject({ ok: true, result: { reportedWorkDisposition: "done" } });
if (validated.ok) expect(validated.result).not.toHaveProperty("continuation");
expect(providerResult).toHaveProperty("continuation", null);
expect(validatePrpStructuredRunResult({
...providerResult,
reportedWorkDisposition: "yielded",
})).toMatchObject({ ok: false });
expect(normalizeLegacyPrpStructuredRunResult({
...providerResult,
continuation: { kind: "response_wake", summary: "Contradictory wait.", idempotencyKey: "wait-1" },
})).toHaveProperty("continuation", {
kind: "response_wake", summary: "Contradictory wait.", idempotencyKey: "wait-1",
});
});
it("accepts legacy completion aliases as one canonical PRP result", () => {
const legacy = {
schema: "paperclip.run_result.v1",
@@ -119,6 +119,12 @@ export function normalizeLegacyPrpStructuredRunResult(value: unknown): unknown {
const source = record(value);
if (Object.keys(source).length === 0) return value;
const normalized: Record<string, unknown> = { ...source };
// Some provider transports require all tool properties to be present. Null
// explicitly means no continuation; preserve every non-null value so strict
// canonical validation still rejects contradictory or malformed waits.
if (normalized.continuation === null) {
delete normalized.continuation;
}
// Provider tool-callers do not consistently echo constant discriminator
// fields even when they are present in the advertised JSON schema. The
// transport already selected the semantic-result tool, so adding its