Preserve task prose at the ACPX tool execution boundary
This commit is contained in:
parent
9e20cc132e
commit
d68ed3ae11
|
|
@ -4,7 +4,7 @@ use serde_json::Value;
|
|||
|
||||
use crate::acpx_event_scope::AcpxEventScope;
|
||||
use crate::acpx_sidecar_transport::AcpxSidecarEvent;
|
||||
use crate::durable::{redact_text, sanitize_value};
|
||||
use crate::durable::{redact_text, sanitize_semantic_tool_input, sanitize_value};
|
||||
use crate::generated_acpx_sidecar_contract::{
|
||||
classify_generated_acpx_tool_operation, GeneratedAcpxSidecarEventType,
|
||||
};
|
||||
|
|
@ -133,11 +133,19 @@ pub fn decode_acpx_event(
|
|||
"ACPX tool call input must be an object",
|
||||
));
|
||||
}
|
||||
let operation_id = required_id(&event.payload, "operationId", "tool operation")?;
|
||||
// This input is dispatched as a mutation, not merely displayed in
|
||||
// the event feed. Use the same declared-prose policy as native
|
||||
// semantic_tool.input before any generic diagnostic scrub can
|
||||
// irreversibly change the task's requirements.
|
||||
let safe_input = sanitize_semantic_tool_input(&operation_id, &input)
|
||||
.map_err(|error| LocalRunnerError::invalid(error.to_string()))?;
|
||||
Ok(AcpxEventPayload::ToolCalled {
|
||||
call_id: required_id(&event.payload, "callId", "tool call")?,
|
||||
operation_id: required_id(&event.payload, "operationId", "tool operation")?,
|
||||
operation_id,
|
||||
// Keep the original digest for the sidecar's result binding.
|
||||
input_digest: semantic_value_digest(&input),
|
||||
input: sanitize_value(&input),
|
||||
input: safe_input,
|
||||
})
|
||||
}
|
||||
GeneratedAcpxSidecarEventType::RuntimeTurnTerminal => {
|
||||
|
|
|
|||
|
|
@ -495,3 +495,116 @@ fn terminal_events_clear_pending_requests_and_reject_late_turn_events() {
|
|||
))
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mutation_prose_survives_sidecar_decode_pending_state_and_semantic_projection() {
|
||||
let mut state = AcpxProviderState::new("run-1").unwrap();
|
||||
state.begin_turn("turn-1").unwrap();
|
||||
let plan = format!(
|
||||
"{}\nThe token CHAT8322bda781b81 must be included in the document.",
|
||||
"Relevant context. ".repeat(400)
|
||||
);
|
||||
let input = json!({
|
||||
"title": "Write project description",
|
||||
"description": "The document must contain the token CHAT8322bda781b81.",
|
||||
"initialPlan": plan,
|
||||
"idempotencyKey": "CHAT8322bda781b81-task",
|
||||
"apiToken": "actual-credential",
|
||||
});
|
||||
let mut expected = input.clone();
|
||||
expected["apiToken"] = json!("[REDACTED]");
|
||||
let emitted = state
|
||||
.accept_event(&event(
|
||||
1,
|
||||
GeneratedAcpxSidecarEventType::RuntimeToolCalled,
|
||||
Some("turn-1"),
|
||||
json!({"callId": "call-1", "operationId": "create_task", "input": input}),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(state.pending_tool("call-1").unwrap().input, expected);
|
||||
let projected = project_acpx_state_event(
|
||||
&AcpxEventProjectionContext {
|
||||
run_id: "run-1".to_owned(),
|
||||
normalized_session_id: "session-1".to_owned(),
|
||||
turn_id: "turn-1".to_owned(),
|
||||
provider_turn_id: None,
|
||||
item_id: "call-1".to_owned(),
|
||||
},
|
||||
&emitted[0],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(projected[0].event_type, "semantic_tool.input");
|
||||
assert_eq!(projected[0].payload["semantic_tool"]["input"], expected);
|
||||
assert_eq!(
|
||||
projected[0].payload["semantic_tool"]["content"]["digest"],
|
||||
json!(paperclip_runner_core::provider_bridge::semantic_value_digest(&expected))
|
||||
);
|
||||
|
||||
for (operation, field, prose, preserved) in [
|
||||
(
|
||||
"write_document",
|
||||
"body",
|
||||
"Include the token CHAT8322bda781b81.",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"create_project",
|
||||
"description",
|
||||
"Include the token CHAT8322bda781b81.",
|
||||
true,
|
||||
),
|
||||
(
|
||||
"get_task_context",
|
||||
"description",
|
||||
"Include the token CHAT8322bda781b81.",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"mcp__untrusted__create_task",
|
||||
"description",
|
||||
"Include the token CHAT8322bda781b81.",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"create_task",
|
||||
"description",
|
||||
"Authorization: Bearer actual-credential",
|
||||
false,
|
||||
),
|
||||
(
|
||||
"create_task",
|
||||
"initialPlan",
|
||||
"access token actual-credential",
|
||||
false,
|
||||
),
|
||||
] {
|
||||
state
|
||||
.complete_tool(
|
||||
"call-1",
|
||||
state
|
||||
.pending_tool("call-1")
|
||||
.unwrap()
|
||||
.operation_id
|
||||
.clone()
|
||||
.as_str(),
|
||||
)
|
||||
.unwrap();
|
||||
let emitted = state
|
||||
.accept_event(&event(
|
||||
2,
|
||||
GeneratedAcpxSidecarEventType::RuntimeToolCalled,
|
||||
Some("turn-1"),
|
||||
json!({"callId": "call-1", "operationId": operation, "input": {field: prose}}),
|
||||
))
|
||||
.unwrap();
|
||||
let AcpxProviderStateEvent::ToolCall { input, .. } = &emitted[0] else {
|
||||
panic!("expected tool call");
|
||||
};
|
||||
assert_eq!(
|
||||
input[field] == json!(prose),
|
||||
preserved,
|
||||
"{operation}: {prose}"
|
||||
);
|
||||
assert!(!input.to_string().contains("actual-credential"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue