From 6f045207bb0347dbde952886f0da8bb287cdc208 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:10:58 -0500 Subject: [PATCH] fix(runner): explain rejected API response windows (#16889) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agents use the API fallback when dedicated tools cannot express an operation. > - Large responses can be read in bounded text windows. > - The declared schema rejects windows larger than 24576 bytes. > - The runner hid the rejected field behind a generic semantic tool error. > - This pull request returns safe schema locations and the accepted byte range. > - Agents can correct a call without guessing or changing route permissions. ## Linked Issues or Issue Description Refs: #16868, which added repair guidance for task monitor arguments. **What happened?** A native call_api request with responseText.limitBytes set to 60000 returned invalid_tool_call with no field or size guidance. The declared 4..24576 byte range already matches the server validator. The missing repair guidance is in the runner bridge. **Expected behavior** Reject invalid input before dispatch. Show bounded schema locations and the accepted window range. Do not echo submitted values or unknown field names. A corrected request can reuse the rejected call ID. **Steps to reproduce** 1. Use the generated call_api tool contract to request a GET text window with limitBytes 60000. 2. Read the semantic tool error. Before this fix, it contains only a generic rejection. 3. Retry with limitBytes 16000. The corrected call must be admitted. **Paperclip version or commit** Reproduced on master 5739a53f5f. **Deployment mode** Built from source. This affects the native runner bridge. ## What Changed - Add call_api to the existing provider-safe schema guidance path. - Name the text-window byte range and pagination field. - Exercise the actual generated production schema through the provider bridge and its error result. - Keep private values, unknown property names, authorization errors and response-size limits unchanged. - Document the response window and repair behavior. ## Verification - Red: api_window_rejection_explains_limits_and_allows_corrected_call failed on master because the provider error did not name responseText.limitBytes. - Green: the same regression passes with a corrected same-ID call, unknown-key privacy checks and unchanged authorization rejection. - Passed: the existing runner-api.test.ts suite, 1935 tests, including server window validation before dispatch. - Passed: all 394 Runner core Rust unit tests, cargo fmt --check and the generated protocol manifest check. The focused test also passed after extraction into its own Rust test module. - Greptile: 5/5 with no actionable issues on the final head 95d65e16469a5ae4ac104be1d15db6257dae5e59. - The first CI release-registry run found that a compile-time test fixture was outside the release-cache input set. The fixture now reads the same generated schema at test runtime. The Rust regression and all 10 runner-release-crate-cache tests pass after that repair. - This pure error mapping adds no dependency calls, provider retries or network work. No live provider or staging deployment was needed. - Full current-head CI passed, including repository typecheck, tests, build, Runner verification, canary and browser suites. At 95d65e16469a5ae4ac104be1d15db6257dae5e59 there are 56 successful and four skipped checks. The separate Paperclip Review bot completed with a neutral check and a 5/5 static assessment with no findings. It did not run current-head Rust, full repository or live-provider tests; its older local cache run is not counted as current-head test evidence. No check remains pending or failed. No local full-suite or full-build pass is claimed. ## Risks - Feedback must not disclose submitted values. The regression checks private values and unknown field names, a 512-byte bound and unchanged generic authorization errors. - Guidance names the current byte range. The regression uses the generated production schema so a contract change must keep this repair guidance accurate. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, tool use and code execution. The exact runtime model suffix and context window are not exposed to this session. ## 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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/execution-semantics.md | 6 ++ .../runner-core/src/provider_backend.rs | 4 + .../crates/runner-core/src/provider_bridge.rs | 2 + .../src/tests/provider_api_validation.rs | 90 +++++++++++++++++++ 4 files changed, 102 insertions(+) create mode 100644 packages/paperclip-runner/runner/crates/runner-core/src/tests/provider_api_validation.rs diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index d846c688622..bd6e53976b2 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -453,6 +453,12 @@ without submitted values. Correct the arguments and retry; the rejection is not evidence that the scheduling service is unavailable. Confirm the persisted receipt before reporting a scheduled wait. +Native `call_api` text windows use `responseText.limitBytes` from 4 through +24576 bytes, with a default of 24576. Follow `nextOffsetBytes` until it is null. +Rejected API fallback arguments return the same bounded schema locations as +monitor calls, with no submitted values. Correct the arguments before retrying; +validation does not dispatch an HTTP request or relax route permissions. + A native provider may omit `continuation` (or send `null`) when it reports `yielded` after saving a monitor. The controller reads current authorized task context and fills the canonical `monitor` continuation only if that same task, diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs index 8a8a5935997..660042ee790 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_backend.rs @@ -6539,3 +6539,7 @@ mod tests { fs::remove_dir_all(directory).unwrap(); } } + +#[cfg(test)] +#[path = "tests/provider_api_validation.rs"] +mod api_validation_tests; diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs index 94b5d825780..09cb2fe08da 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_bridge.rs @@ -43,6 +43,7 @@ const COMPLETION_INPUT_SCHEMA_HINT: &str = "Invalid paperclip_finish arguments. const QUESTION_INPUT_SCHEMA_HINT: &str = "Invalid request_human_input arguments. Required fields: idempotencyKey, interactionKind, title, prompt, and continuationPolicy. For questions, use payload.version=1 and a complete payload.questionSet matching the authorized schema. Preserve the supplied question and option IDs."; const BLOCK_INPUT_SCHEMA_HINT: &str = "Invalid paperclip_block arguments. Required fields: reportedWorkDisposition=blocked, summary, completionClaim, evidence, verification, and blocker. blocker must include reasonCode, owner, unblockAction, and scope."; const MONITOR_INPUT_SCHEMA_HINT: &str = "Invalid set_task_monitor arguments. Supply idempotencyKey and monitor: {nextCheckAt: , notes: }. Put all scheduling fields inside monitor, or use monitor: null to clear. Omit taskId for the current task. Correct the arguments and retry."; +const API_INPUT_SCHEMA_HINT: &str = "Invalid call_api arguments. For responseText, use GET with offsetBytes >= 0 and responseText.limitBytes from 4 through 24576 bytes (default 24576). Follow nextOffsetBytes; null means complete. Correct the arguments and retry."; #[derive(Clone, Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -246,6 +247,7 @@ impl ProviderBridgeError { "paperclip_block" => Some(BLOCK_INPUT_SCHEMA_HINT), "request_human_input" => Some(QUESTION_INPUT_SCHEMA_HINT), "set_task_monitor" => Some(MONITOR_INPUT_SCHEMA_HINT), + "call_api" => Some(API_INPUT_SCHEMA_HINT), _ => None, }; Self { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/tests/provider_api_validation.rs b/packages/paperclip-runner/runner/crates/runner-core/src/tests/provider_api_validation.rs new file mode 100644 index 00000000000..6c911f45962 --- /dev/null +++ b/packages/paperclip-runner/runner/crates/runner-core/src/tests/provider_api_validation.rs @@ -0,0 +1,90 @@ +use super::*; + +#[test] +fn api_window_rejection_explains_limits_and_allows_corrected_call() { + let contract_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../../generated/capability/semantic-tool-contracts.json"); + let contracts: Value = + serde_json::from_str(&fs::read_to_string(contract_path).unwrap()).unwrap(); + let contract = contracts + .as_array() + .unwrap() + .iter() + .find(|contract| contract["name"] == "call_api") + .unwrap(); + let operation = crate::provider_bridge::AuthorizedTool { + operation_id: "call_api".to_owned(), + version: 1, + description: contract["description"].as_str().unwrap().to_owned(), + input_schema: contract["inputSchema"].clone(), + response_schema: contract["outputSchema"].clone(), + }; + let mut bridge = ProviderToolBridge::default(); + bridge + .prepare(AuthorizedToolSet { + schema: TOOL_SET_SCHEMA.to_owned(), + schema_version: 1, + catalog_digest: authorized_tool_catalog_digest(std::slice::from_ref(&operation)) + .unwrap(), + operations: vec![operation], + }) + .unwrap(); + let mut input = json!({ + "operationId": "GET /api/assets/{assetId}/content", + "pathParams": {"assetId": "PRIVATE_ASSET"}, + "responseText": {"limitBytes": 60000} + }); + let error = bridge + .begin_call( + "api-window".to_owned(), + "call_api".to_owned(), + input.clone(), + ) + .unwrap_err(); + let result = invalid_tool_call_result("api-window".to_owned(), "call_api".to_owned(), &error); + let message = result.result["error"]["message"].as_str().unwrap(); + assert!(message.contains("responseText.limitBytes")); + let window_schema = + &contract["inputSchema"]["properties"]["responseText"]["properties"]["limitBytes"]; + assert!(message.contains(&format!( + "from {} through {} bytes (default {})", + window_schema["minimum"], window_schema["maximum"], window_schema["default"] + ))); + assert!(message.contains("/properties/responseText/properties/limitBytes/maximum")); + assert!(!message.contains("PRIVATE_ASSET")); + assert!(message.len() <= 512); + assert!(!message.chars().any(char::is_control)); + assert_eq!(result.result["error"]["code"], "invalid_tool_call"); + input["responseText"]["limitBytes"] = json!(16000); + let mut unknown_field = input.clone(); + unknown_field["responseText"]["PRIVATE_FIELD"] = json!("PRIVATE_VALUE"); + let unknown_error = bridge + .begin_call( + "unknown-api-window".to_owned(), + "call_api".to_owned(), + unknown_field, + ) + .unwrap_err(); + let unknown_result = invalid_tool_call_result( + "unknown-api-window".to_owned(), + "call_api".to_owned(), + &unknown_error, + ); + let unknown_message = unknown_result.result["error"]["message"].as_str().unwrap(); + assert!(unknown_message.contains("/additionalProperties")); + for private in ["PRIVATE_FIELD", "PRIVATE_VALUE", "PRIVATE_ASSET"] { + assert!(!unknown_result.result.to_string().contains(private)); + } + assert!(unknown_message.len() <= 512); + let denied = ProviderToolBridge::default() + .begin_call("denied-api".to_owned(), "call_api".to_owned(), json!({})) + .unwrap_err(); + assert_eq!( + invalid_tool_call_result("denied-api".to_owned(), "call_api".to_owned(), &denied).result + ["error"]["message"], + GENERIC_INVALID_TOOL_CALL_MESSAGE + ); + assert!(bridge + .begin_call("api-window".to_owned(), "call_api".to_owned(), input) + .is_ok()); +}