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:
DottaandClaude Opus 5.5 authored and GitHub committed 2026-11-10 11:54:55 -06:00
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;