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()); +}