Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 7 additions & 4 deletions src/crates/assembly/core/src/agentic/execution/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ For cross-provider channels, run the real factory/round mock and replay-binding
checks with the same minimal features:

```bash
cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git,subscription-auth --lib multi_channel_pool
cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git,subscription-auth --lib -- multi_channel_pool manual_model_switch_
cargo test --locked -p openbitfun-core --no-default-features --features agent-runtime,git,subscription-auth --lib infrastructure::ai::channel_client_source::tests
```

Expand Down Expand Up @@ -99,9 +99,12 @@ signed reasoning text or opaque replay; text, tool calls and results stay).
Every account or channel switch other than same-account authentication recovery
uses portable history. Session records keep the originals. Anthropic requests
whose last assistant tool turn lacks a signed thinking block run without
thinking (adapter-owned). Fixed accounts and unrouted models keep the original
replay binding and its `recovery_constraint` diagnostics; configuration/storage
errors remain strict.
thinking (adapter-owned). Fixed accounts and unrouted models select the configured
destination without pinning it to historical account provenance. Before every
send, project incompatible or unproven signed/opaque state per message against
the actual channel fingerprint and account, then validate remaining replay state.
Compatible state and durable history remain intact; legacy account-only provenance
can still replay to its proven seat. Configuration/storage errors remain strict.


Native turn initialization and manual compression scaffold preparation resolve
Expand Down
196 changes: 190 additions & 6 deletions src/crates/assembly/core/src/agentic/execution/round_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ use crate::util::elapsed_ms_u64;
use crate::util::errors::{OpenBitFunError, OpenBitFunResult};
use crate::util::types::Message as AIMessage;
use crate::util::types::ToolDefinition;
use log::{debug, error, warn};
use log::{debug, error, info, warn};
use openbitfun_agent_runtime::turn_cancellation::DialogTurnCancellationTokenStore;
use openbitfun_agent_tools::{parse_call_deferred_tool_input, CALL_DEFERRED_TOOL_NAME};
use openbitfun_ai_adapters::{
Expand Down Expand Up @@ -762,7 +762,8 @@ impl RoundExecutor {
// Routed rounds may switch account or channel, so signed reasoning is
// a cache preference, not a binding: keep the latest producer's state
// and degrade it to portable history whenever that producer is not
// available. Fixed rounds keep the original replay contract.
// available. Fixed rounds select the configured destination first;
// all attempts project private replay state before sending.
let routed = channels.is_some()
|| account_source.as_ref().is_some_and(|source| {
matches!(
Expand All @@ -779,8 +780,13 @@ impl RoundExecutor {
ai_messages = crate::infrastructure::ai::channel_client_source::ChannelClientSource::portable_history(&ai_messages);
}
}
let mut replay_origin =
crate::infrastructure::ai::account_client_source::opaque_replay_origin(&ai_messages);
let mut replay_origin = if routed {
crate::infrastructure::ai::account_client_source::opaque_replay_origin(&ai_messages)
} else {
// A manual model/account selection must not be pinned back to the
// producer of old history. Its private state is projected below.
Ok(None)
};
let mut has_opaque_replay = crate::infrastructure::ai::channel_client_source::ChannelClientSource::has_nonportable_history(&ai_messages);
if let Some(channels) = &channels {
tokio::select! {
Expand Down Expand Up @@ -990,13 +996,26 @@ impl RoundExecutor {
}
}

crate::infrastructure::ai::channel_client_source::ChannelClientSource::validate_replay_binding(&ai_messages, &context.model_config_id, &ai_client, channels.is_some())
.map_err(|error| OpenBitFunError::AIClient(error.to_string()).with_channel_bound_replay())?;
let account = selected_origin.clone().or_else(|| {
ai_client
.subscription_provider_key()
.and_then(|provider| ai_client.subscription_account_origin(provider))
});
let stripped = crate::infrastructure::ai::channel_client_source::ChannelClientSource::project_replay_for_target(
&mut ai_messages, &context.model_config_id, &ai_client, account.as_ref(),
);
if stripped > 0 {
has_opaque_replay = crate::infrastructure::ai::channel_client_source::ChannelClientSource::has_nonportable_history(&ai_messages);
replay_origin =
crate::infrastructure::ai::account_client_source::opaque_replay_origin(
&ai_messages,
)
.ok()
.flatten();
info!("Projected incompatible model history: session_id={}, model_config_id={}, stripped_messages={}, reason=unproven_or_changed_replay_identity", context.session_id, context.model_config_id, stripped);
}
crate::infrastructure::ai::channel_client_source::ChannelClientSource::validate_replay_binding(&ai_messages, &context.model_config_id, &ai_client, channels.is_some())
.map_err(|error| OpenBitFunError::AIClient(error.to_string()).with_channel_bound_replay())?;
let mut usage_context =
openbitfun_services_core::token_usage::types::ModelRequestUsageContext {
version: 1,
Expand Down Expand Up @@ -6817,6 +6836,171 @@ pub(super) mod tests {
}
}

#[tokio::test]
async fn manual_model_switch_projects_history_before_sending() {
for format in ["responses", "anthropic", "gemini", "fixed-account"] {
let events = match format {
"anthropic" => vec![
json!({"type":"message_start","message":{"id":"continued","type":"message","role":"assistant","model":"target","usage":{"input_tokens":20,"output_tokens":0}}}),
json!({"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}),
json!({"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Continued"}}),
json!({"type":"content_block_stop","index":0}),
json!({"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}),
json!({"type":"message_stop"}),
],
"gemini" => vec![
json!({"candidates":[{"content":{"role":"model","parts":[{"text":"Continued"}]},"finishReason":"STOP"}]}),
],
_ => vec![
json!({"type":"response.output_text.delta","delta":"Continued"}),
json!({"type":"response.completed","response":{"id":"continued","output":[]}}),
],
};
let reply = (
200,
events
.iter()
.map(|event| {
format!(
"event: {}\ndata: {event}\n\n",
event["type"].as_str().unwrap_or("message")
)
})
.collect::<String>(),
);
let server =
RetryTestServer::new(vec![reply; if format == "responses" { 2 } else { 1 }]);
let mut client = (*server.client()).clone();
client.config.format = if format == "fixed-account" {
"responses"
} else {
format
}
.into();
client.config.model = if format == "gemini" {
"gemini-3-pro"
} else {
"target-model"
}
.into();
let client = Arc::new(client);
let mut context = test_round_context();
let mut original_client = (*client).clone();
original_client.config.format = "responses".into();
original_client.config.model = "original-model".into();
let mut previous =
super::AIMessage::assistant_with_tools(vec![openbitfun_core_types::ToolCall {
id: "already-executed".into(),
name: "write_file".into(),
arguments: json!({"path":"result.txt", "content":"saved once"}),
raw_arguments: None,
}]);
previous.content = Some("Saved the requested file".into());
previous.reasoning_content = Some("Original private reasoning".into());
previous.thinking_signature = Some("original-signature".into());
previous.model_response_replay = Some(openbitfun_core_types::ModelResponseReplay {
protocol: "openai_responses".into(),
channel_origin: Some(context.model_config_id.clone()),
channel_fingerprint: Some(original_client.channel_replay_fingerprint()),
account_origin: None,
items: vec![
openbitfun_core_types::ModelResponseReplayItem::OpaqueReasoning {
item_id: Some("original-reasoning".into()),
summary: vec![],
opaque_state: "original-encrypted-state".into(),
},
openbitfun_core_types::ModelResponseReplayItem::AssistantMessage,
openbitfun_core_types::ModelResponseReplayItem::FunctionCall {
call_id: "already-executed".into(),
},
],
});
if format == "fixed-account" {
let source = test_account_source(vec![client.clone()], true);
let mut old_account = source.clients[0].0.clone();
old_account.account_id = "previous-account".into();
old_account.upstream_account_id = Some("previous-seat".into());
previous
.model_response_replay
.as_mut()
.unwrap()
.account_origin = Some(old_account);
context.account_client_source = Some(source);
}
let mut result =
super::AIMessage::user("Saved result.txt; checksum=manual-switch".into());
result.role = "tool".into();
result.name = Some("write_file".into());
result.tool_call_id = Some("already-executed".into());
let history = vec![
super::AIMessage::user("Save the file".into()),
previous,
result,
super::AIMessage::user("Continue".into()),
];
// Exercise the persisted shape without changing the original record.
let before = serde_json::to_value(&history).unwrap();
let restored = serde_json::from_value(before.clone()).unwrap();
let result = tokio::time::timeout(
Duration::from_secs(5),
test_round_executor().execute_round(client, context, restored, None, None),
)
.await
.unwrap()
.unwrap();
assert!(result.had_assistant_text, "{format}");
assert!(
result.tool_result_messages.is_empty(),
"completed tools must not execute again"
);
assert_eq!(serde_json::to_value(&history).unwrap(), before);
let requests = server.requests.lock().unwrap();
assert_eq!(requests.len(), 1, "{format}");
let sent = requests[0].to_string();
for secret in [
"original-signature",
"original-encrypted-state",
"Original private reasoning",
] {
assert!(!sent.contains(secret), "{format}: {secret}");
}
for retained in [
"Saved the requested file",
"write_file",
"saved once",
"checksum=manual-switch",
] {
assert!(sent.contains(retained), "{format}: {retained}");
}
if format == "gemini" {
assert!(sent.contains("skip_thought_signature_validator"));
}
drop(requests);
if format == "responses" {
// Switching back projects the original durable history again;
// compatible encrypted reasoning must still reach its producer.
let result = tokio::time::timeout(
Duration::from_secs(5),
test_round_executor().execute_round(
Arc::new(original_client),
test_round_context(),
history.clone(),
None,
None,
),
)
.await
.unwrap()
.unwrap();
assert!(result.had_assistant_text);
let requests = server.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert!(requests[1].to_string().contains("original-encrypted-state"));
assert_eq!(serde_json::to_value(&history).unwrap(), before);
}
}
}

#[tokio::test]
async fn routed_opaque_history_prefers_its_producer_and_degrades_on_switch() {
for has_origin in [false, true] {
Expand Down
Loading
Loading