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
95d65e1646.
- 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
95d65e1646 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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-19 16:10:58 -05:00
1 parent 24067a16b5
commit 6f045207bb
4 files changed
+102

No files matched your search

+6
View File
@@ -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,
@@ -6539,3 +6539,7 @@ mod tests {
fs::remove_dir_all(directory).unwrap();
}
}
#[cfg(test)]
#[path = "tests/provider_api_validation.rs"]
mod api_validation_tests;
@@ -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: <future UTC timestamp>, notes: <next check>}. 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 {
@@ -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());
}