Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/signalboxd/src/model_adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ mod tests {
Some(signalbox_model_runtime::AssistantPart::Thinking { .. })
| Some(signalbox_model_runtime::AssistantPart::RedactedThinking { .. })
| Some(signalbox_model_runtime::AssistantPart::ToolCall(_))
| Some(signalbox_model_runtime::AssistantPart::SuppressedToolCall)
| None => None,
},
TerminalEvidence::Refused(_)
Expand Down
29 changes: 27 additions & 2 deletions crates/application/src/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,19 @@ enum PassTaskOutcome<PassError> {
OccupancyExpired { bound: SchedulerPassOccupancyBound },
}

type ErasedPassExecution<PassError> =
Pin<Box<dyn Future<Output = Result<(), PassError>> + Send + 'static>>;

fn erased_pass_execution<Pass>(
pass: &mut Pass,
session: SessionId,
) -> ErasedPassExecution<Pass::Error>
where
Pass: EligibilityPass,
{
Box::pin(pass.run(session))
}

fn spawn_pass<Pass>(
passes: &mut JoinSet<PassTaskOutcome<Pass::Error>>,
pass: &mut Pass,
Expand All @@ -989,7 +1002,10 @@ fn spawn_pass<Pass>(
Pass::Error: Send + 'static,
{
let expiry_handler = pass.occupancy_expiry_handler();
let execution = pass.run(session);
// Heap-erasing the adapter future before composing the task keeps the
// scheduler's deeply nested concrete adapter type off Tokio's worker
// stack at the spawn boundary.
let execution = erased_pass_execution(pass, session);
let task = passes.spawn(
async move {
match bound.get() {
Expand Down Expand Up @@ -1184,7 +1200,7 @@ mod tests {
EligibilitySweep, EligibilitySweepBatch, EligibilityWorkSource, GoalAwareEligibilityPass,
GoalAwareEligibilityPassError, GoalPassDisposition, InProcessEligibilityWorkSource,
InvalidReconciliationSweepInterval, ReconciliationSweepInterval, SchedulerLoop,
SchedulerLoopExit, SchedulerPassOccupancyBound,
SchedulerLoopExit, SchedulerPassOccupancyBound, erased_pass_execution,
};
use crate::{
OperatorFailureClass, StartEligibleTurnIdGenerator, StartEligibleTurnOutcome,
Expand Down Expand Up @@ -1691,6 +1707,15 @@ mod tests {
}
}

#[tokio::test]
async fn scheduler_heap_erases_pass_execution_before_task_construction() {
let admitted = session(57);
let (shutdown, _shutdown_receiver) = oneshot::channel();
let mut pass = FakePass::failing_once(session(58), 1, shutdown);

assert_eq!(erased_pass_execution(&mut pass, admitted).await, Ok(()));
}

#[derive(Clone, Copy, Debug)]
struct GoalFixturePass {
result: Result<(), FakeSweepError>,
Expand Down
3 changes: 2 additions & 1 deletion crates/model-provider-runtime/src/context_compaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,8 @@ where
AssistantPart::Thinking { text, .. } if text.is_empty() => {}
AssistantPart::Thinking { .. }
| AssistantPart::RedactedThinking { .. }
| AssistantPart::ToolCall(_) => {
| AssistantPart::ToolCall(_)
| AssistantPart::SuppressedToolCall => {
return Err(ContextCompactionModelError::NonTextSummary);
}
}
Expand Down
40 changes: 36 additions & 4 deletions crates/model-provider-runtime/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1973,6 +1973,12 @@ fn classify_terminal(
DomainToolCallProposal::new(name, arguments),
));
}
AssistantPart::SuppressedToolCall => {
return classify(
ModelCallTerminalObservation::KnownFailed,
ModelCallCauseCode::UnrepresentableToolMaterial,
);
}
// Claude 5-family models run adaptive thinking by
// default and, with the default omitted display, return
// thinking blocks whose text is empty: the block carries
Expand Down Expand Up @@ -2147,10 +2153,10 @@ mod tests {
use uuid::Uuid;

use super::{
AcceptanceObservations, InvalidRuntimeToolSchema, ModelCallTelemetry, ProviderTextDelta,
ProviderTextDeltaContext, ProviderTextDeltaSink, RuntimeInputTokenCountError,
RuntimeModelCallProviderError, RuntimeModelCatalog, RuntimeModelCatalogError,
RuntimeModelDefinition, RuntimeModelDefinitionError,
AcceptanceObservations, InvalidRuntimeToolSchema, ModelCallCauseCode, ModelCallTelemetry,
ProviderTextDelta, ProviderTextDeltaContext, ProviderTextDeltaSink,
RuntimeInputTokenCountError, RuntimeModelCallProviderError, RuntimeModelCatalog,
RuntimeModelCatalogError, RuntimeModelDefinition, RuntimeModelDefinitionError,
classify_terminal as classify_terminal_with_limit, decode_checked_raw_json,
provider_reported_token_usage, render_runtime_messages, runtime_delivery_definitions,
runtime_model_settings,
Expand Down Expand Up @@ -2995,6 +3001,32 @@ mod tests {
);
}

/// A CLI-redacted argument object is not an executable tool request. The
/// completed provider call closes as the existing unrepresentable-material
/// failure instead of entering the tool loop with sentinel JSON.
#[test]
fn fully_suppressed_tool_arguments_close_as_known_failure() {
let classified = classify_terminal(
completion_with_finish(
"model-exact",
CompletionFinish::ToolUse,
vec![AssistantPart::SuppressedToolCall],
),
&[],
&configured("model-exact"),
)
.expect("suppressed tool material has a bounded terminal classification");

assert_eq!(
classified.observation,
ModelCallTerminalObservation::KnownFailed
);
assert_eq!(
classified.cause,
ModelCallCauseCode::UnrepresentableToolMaterial
);
}

#[test]
fn checked_tool_json_decoding_is_stack_guarded_beyond_serde_default_depth() {
let depth = 512;
Expand Down
69 changes: 39 additions & 30 deletions crates/model-runtime-claude-cli/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ use signalbox_model_runtime::{
DiscardedField, ExchangeFacts, FinishReason, LossCause, NativeErrorFacts, Observation,
ObservationFact, ObservationSink, ProviderErrorEvidence, ProviderErrorKind, ProviderMessageId,
ProviderReportedModel, ProviderRequestId, REDACTED, RedactingSink, RefusalEvidence,
TerminalEvidence, TerminalTextCapture, TokenUsage, ToolCallId, ToolCallProposal,
ToolCallsAtLoss, ToolName, provider_json_has_duplicate_members, redact_json, redact_text,
validate_provider_json_nesting,
TerminalEvidence, TerminalTextCapture, TokenUsage, ToolArgumentRedaction, ToolCallId,
ToolCallProposal, ToolCallsAtLoss, ToolName, provider_json_has_duplicate_members, redact_json,
redact_text, validate_provider_json_nesting,
};

use crate::SUPPORTED_CLAUDE_CLI_VERSION;
Expand Down Expand Up @@ -494,34 +494,42 @@ impl<C: Clone> EventDecoder<C> {
}
let index = self.take_part_index()?;
let arguments = sink.redact_tool_arguments("", raw_arguments);
// The proposal id leaves the adapter in `ToolCallProposed` and in
// the retained assistant content, so later text sits beside it for
// the same reason the message id does: an id ending `api_` next to
// a following text block opening `key=value` reconstructs the
// credential across the two emitted fields.
let sanitized_id = sink.redact_provider_id("", &id);
sink.add_emitted_identifier(&sanitized_id);
let proposal_id = self.unique_tool_id(&id, sanitized_id);
let proposal = ToolCallProposal {
id: ToolCallId::new(proposal_id),
name: ToolName::new(name),
arguments_json: arguments.clone(),
};
self.proposal_indexes.insert(id, self.content.len());
self.content.push(AssistantPart::ToolCall(proposal.clone()));
if self.delivery == DeliveryMode::Streamed {
sink.observe(Observation {
correlation: self.correlation.clone(),
fact: ObservationFact::ToolArgumentsDelta {
index,
fragment: arguments,
},
});
self.proposal_indexes.insert(id.clone(), self.content.len());
match arguments {
ToolArgumentRedaction::Admitted(arguments) => {
// The proposal id leaves the adapter in `ToolCallProposed`
// and in the retained assistant content, so later text sits
// beside it for the same reason the message id does: an id
// ending `api_` next to a following text block opening
// `key=value` reconstructs the credential across the two
// emitted fields.
let sanitized_id = sink.redact_provider_id("", &id);
sink.add_emitted_identifier(&sanitized_id);
let proposal_id = self.unique_tool_id(&id, sanitized_id);
let proposal = ToolCallProposal {
id: ToolCallId::new(proposal_id),
name: ToolName::new(name),
arguments_json: arguments.clone(),
};
self.content.push(AssistantPart::ToolCall(proposal.clone()));
if self.delivery == DeliveryMode::Streamed {
sink.observe(Observation {
correlation: self.correlation.clone(),
fact: ObservationFact::ToolArgumentsDelta {
index,
fragment: arguments,
},
});
}
sink.observe(Observation {
correlation: self.correlation.clone(),
fact: ObservationFact::ToolCallProposed(proposal),
});
}
ToolArgumentRedaction::Suppressed => {
self.content.push(AssistantPart::SuppressedToolCall);
}
}
sink.observe(Observation {
correlation: self.correlation.clone(),
fact: ObservationFact::ToolCallProposed(proposal),
});
}
AssistantContent::Other => {
return Err(DecodeFailure::stream_protocol(
Expand Down Expand Up @@ -879,6 +887,7 @@ impl<C: Clone> EventDecoder<C> {
call.arguments_json = redact_json(&call.arguments_json);
Some(AssistantPart::ToolCall(call))
}
AssistantPart::SuppressedToolCall => Some(AssistantPart::SuppressedToolCall),
})
.collect()
}
Expand Down
16 changes: 16 additions & 0 deletions crates/model-runtime-claude-cli/tests/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,22 @@ async fn tool_arguments_preserve_the_provider_json_lexeme() {
assert_eq!(result.spawns, 1);
}

/// A whole-object credential suppression remains typed and never emits an
/// executable tool proposal or argument delta.
#[tokio::test]
async fn fully_suppressed_tool_arguments_are_non_executable() {
let result = execute_scenario("suppressed_tool_arguments", OperationShape::Tool).await;
let completion = completed(&result.evidence);

assert_eq!(completion.content, vec![AssistantPart::SuppressedToolCall]);
assert!(!result.observations.iter().any(|observation| matches!(
observation.fact,
signalbox_model_runtime::ObservationFact::ToolCallProposed(_)
| signalbox_model_runtime::ObservationFact::ToolArgumentsDelta { .. }
)));
assert_eq!(result.spawns, 1);
}

#[tokio::test]
async fn named_tool_choice_rejects_an_extra_declared_proposal() {
let result = execute_scenario("named_choice_extra_tool", OperationShape::NamedTool).await;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
tool_result(fixtures::TOOL_ID)?;
success("tool_use", Some(fixtures::NONCANONICAL_TOOL_ARGUMENTS))?;
}
"suppressed_tool_arguments" => {
assistant_tool_with_raw_arguments(
fixtures::TOOL_ID,
fixtures::TOOL_NAME,
fixtures::SUPPRESSED_TOOL_ARGUMENTS,
)?;
tool_result(fixtures::TOOL_ID)?;
success("tool_use", Some(fixtures::SUPPRESSED_TOOL_ARGUMENTS))?;
}
"refusal" => {
assistant_text(fixtures::REFUSAL)?;
success("refusal", Some(fixtures::REFUSAL))?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ pub const CREDENTIAL_TOOL_ID_ONE: &str = "api_key=synthetic-tool-one";
pub const CREDENTIAL_TOOL_ID_TWO: &str = "api_key=synthetic-tool-two";
pub const TOOL_ARGUMENTS: &str = r#"{"subject":"synthetic"}"#;
pub const NONCANONICAL_TOOL_ARGUMENTS: &str = r#"{"z":1, "a":2}"#;
pub const SUPPRESSED_TOOL_ARGUMENTS: &str = r#"{"sk-opaque-token-key":"safe"}"#;
pub const FINISH_TOKEN_SECRET: &str = "api_key=synthetic-finish-secret";
pub const ERROR_TOKEN_SECRET: &str = "api_key=synthetic-error-secret";
pub const REASONING_SECRET_PREFIX: &str = "sk-";
Expand Down
35 changes: 22 additions & 13 deletions crates/model-runtime-codex-cli/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,9 @@ use signalbox_model_runtime::{
ExchangeFacts, FinishReason, LossCause, NativeErrorFacts, Observation, ObservationFact,
ObservationSink, ProviderErrorEvidence, ProviderErrorKind, ProviderMessageId,
ProviderRequestId, REDACTED, RedactingSink, RefusalEvidence, TerminalEvidence, TokenUsage,
ToolCallId, ToolCallProposal, ToolCallsAtLoss, ToolName, provider_json_has_duplicate_members,
redact_text, trailing_credential_context, validate_provider_json_nesting,
ToolArgumentRedaction, ToolCallId, ToolCallProposal, ToolCallsAtLoss, ToolName,
provider_json_has_duplicate_members, redact_text, trailing_credential_context,
validate_provider_json_nesting,
};

use crate::status::{classify_error, retry_after};
Expand Down Expand Up @@ -690,16 +691,22 @@ impl<C: Clone> EventDecoder<C> {
} else {
next_redacted_call_id(&mut redacted_id_cursor, &clean_ids)
};
content.push(AssistantPart::ToolCall(ToolCallProposal {
id: ToolCallId::new(id),
name: ToolName::new(call.name.clone()),
// The arguments consult the held cross-fragment lookbehind
// before the stateless JSON-aware redaction, and this same
// sanitized value feeds the streamed argument delta and the
// terminal proposal, so a credential whose marker arrived in
// an earlier fragment cannot escape through tool arguments.
arguments_json: sink.redact_tool_arguments(final_text_context, &call.arguments),
}));
// The arguments consult the held cross-fragment lookbehind before
// stateless JSON-aware redaction. A whole-object suppression is
// typed separately so no executable sentinel request can cross
// the adapter boundary and churn through tool rounds.
match sink.redact_tool_arguments(final_text_context, &call.arguments) {
ToolArgumentRedaction::Admitted(arguments_json) => {
content.push(AssistantPart::ToolCall(ToolCallProposal {
id: ToolCallId::new(id),
name: ToolName::new(call.name.clone()),
arguments_json,
}));
}
ToolArgumentRedaction::Suppressed => {
content.push(AssistantPart::SuppressedToolCall);
}
}
}
if let Some(contract_name) = &self.output_contract_name {
if !envelope
Expand Down Expand Up @@ -764,7 +771,9 @@ impl<C: Clone> EventDecoder<C> {
fragment: call.arguments_json.clone(),
},
}),
AssistantPart::Thinking { .. } | AssistantPart::RedactedThinking { .. } => {}
AssistantPart::Thinking { .. }
| AssistantPart::RedactedThinking { .. }
| AssistantPart::SuppressedToolCall => {}
}
}
self.next_part_index += content_len;
Expand Down
13 changes: 11 additions & 2 deletions crates/model-runtime-codex-cli/tests/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use signalbox_model_runtime::{
AssistantPart, CancellationSignal, CompletionFinish, ConversationMessage, ConversationRole,
CredentialReference, DeliveryMode, LossCause, MessagePart, ModelOperation, ModelRuntime,
Observation, ObservationFact, PreparationFailure, PreparationOutcome, ProviderErrorKind,
RequestedTarget, ResolvedTarget, StreamInterruption, StructuredDecodeFailure,
REDACTED, RequestedTarget, ResolvedTarget, StreamInterruption, StructuredDecodeFailure,
StructuredOutputContract, TerminalEvidence, TokenUsage, ToolCallId, ToolCallProposal,
ToolCallsAtLoss, ToolChoice, ToolDefinition, ToolName, decode_structured,
};
Expand Down Expand Up @@ -157,6 +157,7 @@ fn collect_assistant_parts(parts: &[AssistantPart], material: &mut Vec<String>)
}
AssistantPart::RedactedThinking { data } => material.push(data.clone()),
AssistantPart::ToolCall(proposal) => collect_tool_proposal(proposal, material),
AssistantPart::SuppressedToolCall => {}
}
}
}
Expand Down Expand Up @@ -599,6 +600,13 @@ async fn inv_035_buffered_reasoning_marker_suppresses_tool_arguments() {

assert!(!diagnostic.contains(fixtures::SENSITIVE_SPLIT_AUTHORIZATION));
assert!(diagnostic.contains("[redacted]"));
assert_eq!(
completed(&result.evidence).content,
vec![
AssistantPart::Text(REDACTED.to_string()),
AssistantPart::SuppressedToolCall,
]
);
assert_eq!(result.spawns, 1);
}

Expand Down Expand Up @@ -4698,7 +4706,8 @@ fn tool_ids(content: &[AssistantPart]) -> Vec<&str> {
AssistantPart::ToolCall(proposal) => Some(proposal.id.as_str()),
AssistantPart::Text(_)
| AssistantPart::Thinking { .. }
| AssistantPart::RedactedThinking { .. } => None,
| AssistantPart::RedactedThinking { .. }
| AssistantPart::SuppressedToolCall => None,
})
.collect()
}
Expand Down
Loading
Loading