mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
fix(runner): accept request_human_input payload version forms models send (#20588)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents ask people for decisions with the runner's `request_human_input` tool, which creates a question or confirmation card > - The tool schema required `payload.version` to be the number 1 > - The server ignores the version the model sends and always stores version 1 > - Some models send `"version": "1"` as a string, so every call failed with "/payload/version must be equal to constant" > - This pull request accepts `1` or `"1"`, and lets the model omit the version > - The benefit is that the model's natural call creates the card on the first try, and a turn does not end with no card and no reply ## Linked Issues or Issue Description Refs #19405 (adds invocation examples for the same tool; it does not change validation). Refs #20134 (aligns the same tool's text limits). **What happened?** In a chat eval run on master `f4337bb5fb` (OpenCode runner, `deepseek-v4.1-flash`), the agent called `request_human_input` up to six times. Each call failed with `Invalid tool input: /payload/version must be equal to constant`. The agent's reasoning shows it sent `"version": "1"` and could not produce the number. In one case it then made the card with `call_api`. In two other cases the turn lost the card. This happened in `questions-and-approvals.reject-and-revise`, `goals.align-existing-work` and `assistant-connections.revoke-personal-grant`. **Expected behavior** A version field that the server overwrites must not block the call. The tool must still reject an unknown version and a questions card without a question form. **Steps to reproduce** Validate `{ interactionKind: "questions", payload: { version: "1", questionSet: {...} } }` against the live `request_human_input` input schema. Before this change, validation fails at `/payload/version`. ## What Changed - `payload.version` in the tool schema accepts `1` or `"1"` and is optional. Its description says Paperclip stores version 1. - A questions card no longer requires `payload.version`. It still requires `questionSet` or `questions`. - The payload description no longer tells the model to send `version:1`. - Regenerated the semantic tool contracts, the seeded eval catalog digest and the protocol manifest with the existing generator. - Added a regression test: number, string and missing version pass for live and scenario schemas; version `2` and a payload without questions fail. ## Verification - `npx vitest run src/protocol-actions/` in `packages/paperclip-runner`: red before the fix with the exact eval error (`/payload/version must be equal to constant`), green after (47 tests). - Focused runner tests for the tool bridge, semantic tools, mock control plane, codex semantic results, catalog and semantic tools: 231 passed. - `node scripts/generate-semantic-contracts.mjs --check`, `generate-semantic-action-catalog --check`, `generate-protocol-manifest --check`, `generate-replay-goldens --check`: current. - `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`: unchanged. - Paid chat eval on a temporary branch with this change: see the comment on this PR. ## Risks - Low risk. The server already writes `version: 1` into every interaction payload created by this tool (`paperclip-runner-tool-authority.ts`), so no stored shape changes. - Previous-release story: no persisted run, session or launch state changes. Saved tool receipts keep the input they recorded. A retry with a different version form under the same idempotency key is a different input, as before. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), agentic coding with tool use in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1b7ec13acb
commit
9009fbdedb
5 files changed
+32
-7
No files matched your search
@@ -1 +1 @@
|
||||
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task only. Calling get_task does not change the active task. This is not a status or assignment audit. For progress chronology and handoff actors, use search_api to discover the company timeline or issue activity, then call_api.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.Line truncated
|
||||
[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task only. Calling get_task does not change the active task. This is not a status or assignment audit. For progress chronology and handoff actors, use search_api to discover the company timeline or issue activity, then call_api.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable document identifier, not a filename or path (for example reconciliation, not reconciliation.md). After trimming whitespace and lowercasing: 1–64 characters, starting with a letter or digit and containing only a–z, 0–9, _ or -. The raw input is limited to 120 characters.","maxLength":120,"minLength":1,"pattern":"^[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*[A-Za-z0-9\\u212a][A-Za-z0-9_\\u212a-]{0,63}[\\u0009-\\u000d\\u0020\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]*$","type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.Line truncated
|
||||
@@ -24,7 +24,7 @@
|
||||
"prpVersion": 1,
|
||||
"nativeExecutionVersion": 1,
|
||||
"catalogVersion": 1,
|
||||
"catalogSha256": "sha256:f212302b554a7788940ac0a36e98d1a3d5b31fc156b0fca47629a9b913fd5fb0",
|
||||
"catalogSha256": "sha256:56cd92878be1bde5e5fcaa07cf65f6844c812055b692451560166f6a511d44d2",
|
||||
"driverContractVersion": 1,
|
||||
"driverKind": "paperclip-deterministic",
|
||||
"driverVersion": "1.0.0"
|
||||
|
||||
@@ -185,7 +185,7 @@
|
||||
},
|
||||
{
|
||||
"path": "fixtures/evals/native-execution-seeded.json",
|
||||
"sha256": "f652a67cef1d4a838030b6ca7e390ed3ae586c685b6770df0e7762ad739eddab",
|
||||
"sha256": "85be9205c0bfaebb925b5ebf3208b47db6876e33835a1721667c7f4d79581d9b",
|
||||
"expectation": "accept",
|
||||
"compatibilityCase": "canonical"
|
||||
},
|
||||
|
||||
@@ -32,10 +32,33 @@ describe("core Paperclip protocol action contracts", () => {
|
||||
expect(ajv.validate(schema, input), JSON.stringify(ajv.errors)).toBe(true);
|
||||
}
|
||||
const questions = requestHumanInputExamples.questions;
|
||||
expect(ajv.validate(schema, { ...questions, payload: { questionSet: questions.payload.questionSet } })).toBe(false);
|
||||
expect(ajv.validate(schema, { ...questions, payload: { version: 1 } })).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts the payload version forms models naturally send, because Paperclip stores version 1 itself", () => {
|
||||
// Recorded opencode/deepseek runs sent payload.version as the string "1" (or
|
||||
// omitted it after the rejection) and every call failed with
|
||||
// "/payload/version must be equal to constant". The server overwrites the
|
||||
// stored payload version with 1, so the tool must not reject these forms.
|
||||
const action = PAPERCLIP_CORE_PROTOCOL_ACTIONS.find(action => action.id === "request_human_input")!;
|
||||
const { questions, confirmation } = requestHumanInputExamples;
|
||||
const questionSet = questions.payload.questionSet;
|
||||
for (const schema of [action.live!.descriptor.inputSchema, action.scenario!.descriptor.inputSchema]) {
|
||||
const strip = (input: Record<string, unknown>) => {
|
||||
if (schema !== action.scenario!.descriptor.inputSchema) return input;
|
||||
const { idempotencyKey: _key, ...rest } = input;
|
||||
return rest;
|
||||
};
|
||||
for (const payload of [{ version: 1, questionSet }, { version: "1", questionSet }, { questionSet }]) {
|
||||
expect(ajv.validate(schema, strip({ ...questions, payload })), JSON.stringify(ajv.errors)).toBe(true);
|
||||
}
|
||||
expect(ajv.validate(schema, strip({ ...confirmation, payload: { version: "1", supersedeOnUserComment: false } })), JSON.stringify(ajv.errors)).toBe(true);
|
||||
// An unknown protocol version and a payload without any question form still fail.
|
||||
expect(ajv.validate(schema, strip({ ...questions, payload: { version: 2, questionSet } }))).toBe(false);
|
||||
expect(ajv.validate(schema, strip({ ...questions, payload: { version: 1 } }))).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("tells agents to batch independent questions into one card instead of asking them one by one", () => {
|
||||
const action = PAPERCLIP_CORE_PROTOCOL_ACTIONS.find(action => action.id === "request_human_input")!;
|
||||
const description = action.live!.descriptor.description;
|
||||
|
||||
@@ -14,9 +14,11 @@ const canonicalQuestionSchema = {
|
||||
|
||||
export const questionPayloadSchema = {
|
||||
type: "object",
|
||||
description: "Kind-specific interaction data. For questions, send version:1 and one complete questionSet containing every text and choice question. Paperclip generates compatibility questions. Each canonical question needs id, prompt, required, and answerMode: text, single_select, or multi_select. Text questions have no options or customAnswer. Choice questions need at least two meaningful options with id/label. Use customAnswer:{enabled:true} for an optional written answer to a choice question. Legacy questions remain supported; if both representations are supplied, they must describe the same complete form. Keep IDs stable across retries. For required output quality review, confirmation payload must contain qualityReview:{key:<required deliverable key>} and target:{type:issue_document,key:<output document key>,revisionId:<saved revision>} or target:{type:issue_attachment,attachmentId,assetId,sha256}. For item_verdicts put qualityReview and target on each reviewed item. Keep supersedeOnUserComment:false inside payload. Never discard these bindings to work around a validation error; an unbound confirmation cannot satisfy required quality review.",
|
||||
description: "Kind-specific interaction data. For questions, send one complete questionSet containing every text and choice question. Paperclip generates compatibility questions. Each canonical question needs id, prompt, required, and answerMode: text, single_select, or multi_select. Text questions have no options or customAnswer. Choice questions need at least two meaningful options with id/label. Use customAnswer:{enabled:true} for an optional written answer to a choice question. Legacy questions remain supported; if both representations are supplied, they must describe the same complete form. Keep IDs stable across retries. For required output quality review, confirmation payload must contain qualityReview:{key:<required deliverable key>} and target:{type:issue_document,key:<output document key>,revisionId:<saved revision>} or target:{type:issue_attachment,attachmentId,assetId,sha256}. For item_verdicts put qualityReview and target on each reviewed item. Keep supersedeOnUserComment:false inside payload. Never discard these bindings to work around a validation error; an unbound confirmation cannot satisfy required quality review.",
|
||||
properties: {
|
||||
version: { const: 1 },
|
||||
// Paperclip stores payload version 1 itself. Models commonly send the
|
||||
// number as a string or omit it; rejecting those forms only cost retries.
|
||||
version: { enum: [1, "1"], description: "Optional. Paperclip stores version 1; you may omit it." },
|
||||
qualityReview: {
|
||||
type: "object", required: ["key"],
|
||||
properties: { key: { type: "string", pattern: "^[a-z][a-z0-9_-]{0,79}$" } },
|
||||
@@ -71,7 +73,7 @@ export const questionsPayloadRequirement = {
|
||||
then: {
|
||||
required: ["payload"],
|
||||
properties: {
|
||||
payload: { required: ["version"], anyOf: [{ required: ["questionSet"] }, { required: ["questions"] }] },
|
||||
payload: { anyOf: [{ required: ["questionSet"] }, { required: ["questions"] }] },
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
Reference in new issue
Block a user