mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
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 master5739a53f5f. **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 head95d65e1646. - 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. At95d65e1646there 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:
1 parent
24067a16b5
commit
6f045207bb
4 files changed
+102
No files matched your search
@@ -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 {
|
||||
|
||||
+90
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user