Bound agent context and preprocess oversized image attachments
This commit is contained in:
@@ -163,11 +163,21 @@ impl From<ProviderRunProtocolError> for ProviderRunCoordinatorError {
|
||||
pub(crate) struct ProviderRunProfile {
|
||||
pub(crate) runtime: Arc<dyn AgentRuntime>,
|
||||
pub(crate) request: TurnRequest,
|
||||
pub(crate) max_context_tokens: Option<u32>,
|
||||
}
|
||||
|
||||
impl ProviderRunProfile {
|
||||
pub(crate) fn new(runtime: Arc<dyn AgentRuntime>, request: TurnRequest) -> Self {
|
||||
Self { runtime, request }
|
||||
Self {
|
||||
runtime,
|
||||
request,
|
||||
max_context_tokens: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn with_max_context_tokens(mut self, max_context_tokens: Option<u32>) -> Self {
|
||||
self.max_context_tokens = max_context_tokens;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,23 +193,19 @@ pub(crate) struct ProviderRunCoordinator {
|
||||
impl ProviderRunCoordinator {
|
||||
pub(crate) fn from_request(
|
||||
run_id: impl Into<galaxy_agent_core::ProviderRunId>,
|
||||
runtime: Arc<dyn AgentRuntime>,
|
||||
request: TurnRequest,
|
||||
base_profile: ProviderRunProfile,
|
||||
tool_result_archive: Vec<galaxy_agent_core::ConversationMessage>,
|
||||
limits: ProviderRunLimits,
|
||||
) -> Result<Self, ProviderRunCoordinatorError> {
|
||||
let mut run = ProviderRun::new(
|
||||
run_id,
|
||||
request.messages.clone(),
|
||||
base_profile.request.messages.clone(),
|
||||
BASE_PROVIDER_PROFILE,
|
||||
limits,
|
||||
);
|
||||
run.replace_tool_result_archive(tool_result_archive);
|
||||
let mut profiles = BTreeMap::new();
|
||||
profiles.insert(
|
||||
BASE_PROVIDER_PROFILE.to_string(),
|
||||
ProviderRunProfile::new(runtime, request),
|
||||
);
|
||||
profiles.insert(BASE_PROVIDER_PROFILE.to_string(), base_profile);
|
||||
Self::new(run, profiles)
|
||||
}
|
||||
|
||||
@@ -266,13 +272,11 @@ impl ProviderRunCoordinator {
|
||||
pub(crate) fn insert_profile(
|
||||
&mut self,
|
||||
profile: impl Into<String>,
|
||||
runtime: Arc<dyn AgentRuntime>,
|
||||
request: TurnRequest,
|
||||
config: ProviderRunProfile,
|
||||
) -> Result<(), ProviderRunCoordinatorError> {
|
||||
let profile = profile.into();
|
||||
validate_profile_runtime(&profile, runtime.as_ref())?;
|
||||
self.profiles
|
||||
.insert(profile, ProviderRunProfile::new(runtime, request));
|
||||
validate_profile_runtime(&profile, config.runtime.as_ref())?;
|
||||
self.profiles.insert(profile, config);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -461,13 +465,15 @@ impl ProviderRunCoordinator {
|
||||
}
|
||||
|
||||
fn request_needs_context_compaction(&self, call: &ProviderModelCall) -> bool {
|
||||
let Some(max_context_tokens) = self.max_context_tokens else {
|
||||
return false;
|
||||
};
|
||||
let Some(profile) = self.profiles.get(call.profile.as_str()) else {
|
||||
return false;
|
||||
};
|
||||
let request = request_for_model_call(profile.request.clone(), call);
|
||||
let Some(max_context_tokens) = profile.max_context_tokens.or(self.max_context_tokens)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let request =
|
||||
request_for_model_call(profile.request.clone(), call, Some(max_context_tokens));
|
||||
// Provider tokenizers and request envelopes differ. Compact before the
|
||||
// estimate reaches the advertised limit, leaving room for that uncertainty.
|
||||
let safe_context_tokens = u64::from(max_context_tokens) * 9 / 10;
|
||||
@@ -547,7 +553,11 @@ impl ProviderRunCoordinator {
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let request = request_for_model_call(profile.request.clone(), &call);
|
||||
let request = request_for_model_call(
|
||||
profile.request.clone(),
|
||||
&call,
|
||||
profile.max_context_tokens.or(self.max_context_tokens),
|
||||
);
|
||||
let started_at = Instant::now();
|
||||
let (attempt_sender, attempt_control) = turn_control();
|
||||
let start_future = profile.runtime.start_turn(request, attempt_control).fuse();
|
||||
@@ -1132,9 +1142,18 @@ fn validate_profile_runtime(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn request_for_model_call(mut template: TurnRequest, call: &ProviderModelCall) -> TurnRequest {
|
||||
fn request_for_model_call(
|
||||
mut template: TurnRequest,
|
||||
call: &ProviderModelCall,
|
||||
max_context_tokens: Option<u32>,
|
||||
) -> TurnRequest {
|
||||
template.prompt = None;
|
||||
template.messages = call.messages.clone();
|
||||
for message in &mut template.messages {
|
||||
message.truncate_tool_results_for_provider_request_with_context(max_context_tokens);
|
||||
message
|
||||
.truncate_tagged_terminal_output_for_provider_request_with_context(max_context_tokens);
|
||||
}
|
||||
add_exploration_reminder(&mut template);
|
||||
template
|
||||
}
|
||||
@@ -1206,33 +1225,92 @@ fn consecutive_discovery_batches(messages: &[ConversationMessage]) -> usize {
|
||||
|
||||
const ESTIMATED_BYTES_PER_TOKEN: u64 = 3;
|
||||
const ESTIMATED_REQUEST_OVERHEAD_TOKENS: u64 = 512;
|
||||
const ESTIMATED_IMAGE_TOKENS: u64 = 1_600;
|
||||
|
||||
/// Conservative estimate, not an exact tokenizer count. UTF-8 bytes account for
|
||||
/// non-ASCII content better than character counts; the caller also leaves headroom.
|
||||
/// Heuristic estimate, not an exact tokenizer count or provider-specific bound.
|
||||
/// UTF-8 bytes account for non-ASCII content better than character counts; raw
|
||||
/// image payload bytes are excluded from text accounting and charged separately.
|
||||
fn estimate_turn_request_tokens(request: &TurnRequest) -> u64 {
|
||||
let mut bytes = request
|
||||
let mut text_bytes = request
|
||||
.system_prompt
|
||||
.as_deref()
|
||||
.map_or(0, |text| text.len());
|
||||
bytes += request.prompt.as_ref().map_or(0, |prompt| {
|
||||
serde_json::to_string(prompt).unwrap_or_default().len()
|
||||
});
|
||||
bytes += request
|
||||
text_bytes += request
|
||||
.prompt
|
||||
.as_ref()
|
||||
.map_or(0, estimate_message_content_bytes);
|
||||
text_bytes += request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| serde_json::to_string(message).unwrap_or_default().len())
|
||||
.map(estimate_conversation_message_bytes)
|
||||
.sum::<usize>();
|
||||
bytes += request
|
||||
text_bytes += request
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| serde_json::to_string(tool).unwrap_or_default().len())
|
||||
.sum::<usize>();
|
||||
let input_tokens = (bytes as u64).div_ceil(ESTIMATED_BYTES_PER_TOKEN);
|
||||
|
||||
let image_tokens = request
|
||||
.prompt
|
||||
.as_ref()
|
||||
.map_or(0, count_images_in_content)
|
||||
.saturating_add(
|
||||
request
|
||||
.messages
|
||||
.iter()
|
||||
.map(|message| count_images_in_content(&message.content))
|
||||
.sum::<u64>(),
|
||||
)
|
||||
.saturating_mul(ESTIMATED_IMAGE_TOKENS);
|
||||
let input_tokens = (text_bytes as u64).div_ceil(ESTIMATED_BYTES_PER_TOKEN);
|
||||
input_tokens
|
||||
.saturating_add(image_tokens)
|
||||
.saturating_add(ESTIMATED_REQUEST_OVERHEAD_TOKENS)
|
||||
.saturating_add(request.max_output_tokens.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn estimate_conversation_message_bytes(message: &ConversationMessage) -> usize {
|
||||
let mut sanitized_message = message.clone();
|
||||
remove_image_payloads_from_content(&mut sanitized_message.content);
|
||||
serde_json::to_string(&sanitized_message)
|
||||
.unwrap_or_default()
|
||||
.len()
|
||||
}
|
||||
|
||||
fn estimate_message_content_bytes(content: &MessageContent) -> usize {
|
||||
let mut sanitized_content = content.clone();
|
||||
remove_image_payloads_from_content(&mut sanitized_content);
|
||||
serde_json::to_string(&sanitized_content)
|
||||
.unwrap_or_default()
|
||||
.len()
|
||||
}
|
||||
|
||||
fn remove_image_payloads_from_content(content: &mut MessageContent) {
|
||||
if let MessageContent::MultiPart(parts) = content {
|
||||
for part in parts {
|
||||
match part {
|
||||
ContentPart::Image { data, .. } => data.clear(),
|
||||
ContentPart::Text(_)
|
||||
| ContentPart::Reasoning { .. }
|
||||
| ContentPart::ToolUse { .. }
|
||||
| ContentPart::ToolResult { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn count_images_in_content(content: &MessageContent) -> u64 {
|
||||
match content {
|
||||
MessageContent::Text(_)
|
||||
| MessageContent::ToolUse { .. }
|
||||
| MessageContent::ToolResult { .. } => 0,
|
||||
MessageContent::MultiPart(parts) => parts
|
||||
.iter()
|
||||
.filter(|part| matches!(part, ContentPart::Image { .. }))
|
||||
.count() as u64,
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_event_call_id(event: &ToolEvent) -> Result<&str, ProviderRunCoordinatorError> {
|
||||
match event {
|
||||
ToolEvent::Proposed { call } => Ok(&call.id),
|
||||
|
||||
@@ -190,6 +190,34 @@ fn estimates_turn_request_with_system_tools_messages_and_output() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimates_image_payloads_without_counting_raw_bytes_as_text() {
|
||||
let image_bytes = vec![255; 3_750_000];
|
||||
let serialized_text_tokens = serde_json::to_string(&image_bytes)
|
||||
.unwrap()
|
||||
.len()
|
||||
.div_ceil(ESTIMATED_BYTES_PER_TOKEN as usize) as u64;
|
||||
let request = TurnRequest::new(
|
||||
"test-model",
|
||||
vec![ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::MultiPart(vec![
|
||||
ContentPart::Text("what is in this screenshot?".to_string()),
|
||||
ContentPart::Image {
|
||||
data: image_bytes,
|
||||
mime_type: "image/jpeg".to_string(),
|
||||
},
|
||||
]),
|
||||
}],
|
||||
);
|
||||
|
||||
let estimate = estimate_turn_request_tokens(&request);
|
||||
|
||||
assert!(estimate < 10_000);
|
||||
assert!(estimate > ESTIMATED_REQUEST_OVERHEAD_TOKENS + ESTIMATED_IMAGE_TOKENS);
|
||||
assert!(serialized_text_tokens > estimate);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proactively_compacts_before_starting_an_oversized_request() {
|
||||
let runtime = Arc::new(ScriptedRuntime::new(vec![answer_turn()]));
|
||||
@@ -276,8 +304,7 @@ fn answer_turn() -> ScriptedTurn {
|
||||
fn coordinator(runtime: Arc<dyn AgentRuntime>) -> ProviderRunCoordinator {
|
||||
ProviderRunCoordinator::from_request(
|
||||
"run-1",
|
||||
runtime,
|
||||
request(),
|
||||
ProviderRunProfile::new(runtime, request()),
|
||||
Vec::new(),
|
||||
ProviderRunLimits::default(),
|
||||
)
|
||||
@@ -1357,8 +1384,7 @@ fn session_runtime_is_rejected_before_any_turn_can_start() {
|
||||
});
|
||||
let error = ProviderRunCoordinator::from_request(
|
||||
"run-1",
|
||||
runtime,
|
||||
request(),
|
||||
ProviderRunProfile::new(runtime, request()),
|
||||
Vec::new(),
|
||||
ProviderRunLimits::default(),
|
||||
)
|
||||
@@ -1379,8 +1405,7 @@ fn context_estimate_tracks_live_transcript_and_reserves_fixed_context() {
|
||||
request.max_output_tokens = Some(4_000);
|
||||
let mut coordinator = ProviderRunCoordinator::from_request(
|
||||
"context-budget",
|
||||
runtime,
|
||||
request,
|
||||
ProviderRunProfile::new(runtime, request),
|
||||
Vec::new(),
|
||||
ProviderRunLimits::default(),
|
||||
)
|
||||
@@ -1402,6 +1427,140 @@ fn context_estimate_tracks_live_transcript_and_reserves_fixed_context() {
|
||||
assert_eq!(fixed_before, fixed_after);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn monitor_request_uses_its_profile_context_limit() {
|
||||
let base_runtime = Arc::new(ScriptedRuntime::new(vec![answer_turn()]));
|
||||
let cli_runtime = Arc::new(ScriptedRuntime::new(vec![answer_turn()]));
|
||||
let original_content = format!("start:{}:end", "x".repeat(20_000));
|
||||
let large_result = ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::ToolResult {
|
||||
tool_use_id: "large-result".to_string(),
|
||||
content: original_content.clone(),
|
||||
is_error: false,
|
||||
},
|
||||
};
|
||||
let run = ProviderRun::new(
|
||||
"profile-limit-run",
|
||||
vec![large_result],
|
||||
CLI_MONITOR_PROVIDER_PROFILE,
|
||||
ProviderRunLimits::default(),
|
||||
);
|
||||
let mut profiles = BTreeMap::new();
|
||||
profiles.insert(
|
||||
BASE_PROVIDER_PROFILE.to_string(),
|
||||
ProviderRunProfile::new(base_runtime, request()).with_max_context_tokens(Some(100_000)),
|
||||
);
|
||||
profiles.insert(
|
||||
CLI_MONITOR_PROVIDER_PROFILE.to_string(),
|
||||
ProviderRunProfile::new(cli_runtime.clone(), request())
|
||||
.with_max_context_tokens(Some(8_000)),
|
||||
);
|
||||
let mut coordinator = ProviderRunCoordinator::new(run, profiles).unwrap();
|
||||
coordinator.set_max_context_tokens(Some(100_000));
|
||||
|
||||
let (_sender, control) = turn_control();
|
||||
let block = coordinator
|
||||
.drive_until_blocked(control, |_| Ok(()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(block, ProviderRunBlock::AwaitingDriver { .. }));
|
||||
let requests = cli_runtime.requests();
|
||||
let MessageContent::ToolResult { content, .. } = &requests[0].messages[0].content else {
|
||||
panic!("expected bounded monitor tool result");
|
||||
};
|
||||
assert!(content.chars().count() <= galaxy_agent_core::provider_output_char_limit(Some(8_000)));
|
||||
assert!(content.contains("tool result truncated"));
|
||||
let MessageContent::ToolResult { content, .. } = &coordinator.run().transcript()[0].content
|
||||
else {
|
||||
panic!("expected original monitor tool result");
|
||||
};
|
||||
assert_eq!(content, &original_content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn final_model_request_bounds_restored_tagged_terminal_text() {
|
||||
let runtime = Arc::new(ScriptedRuntime::new(vec![answer_turn()]));
|
||||
let original_output = format!("first:{}:last", "x".repeat(20_000));
|
||||
let restored_message = ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text(format!(
|
||||
"[Running command: verbose]\n[Terminal output:\n{original_output}\n]\nContinue"
|
||||
)),
|
||||
};
|
||||
let mut turn_request = request();
|
||||
turn_request.messages = vec![restored_message];
|
||||
let mut coordinator = ProviderRunCoordinator::from_request(
|
||||
"restored-terminal-run",
|
||||
ProviderRunProfile::new(runtime.clone(), turn_request).with_max_context_tokens(Some(8_000)),
|
||||
Vec::new(),
|
||||
ProviderRunLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (_sender, control) = turn_control();
|
||||
let block = coordinator
|
||||
.drive_until_blocked(control, |_| Ok(()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(block, ProviderRunBlock::AwaitingDriver { .. }));
|
||||
let requests = runtime.requests();
|
||||
let MessageContent::Text(content) = &requests[0].messages[0].content else {
|
||||
panic!("expected bounded restored terminal text");
|
||||
};
|
||||
assert!(content.contains("terminal output truncated for model context"));
|
||||
assert!(!content.contains(&original_output));
|
||||
let MessageContent::Text(content) = &coordinator.run().transcript()[0].content else {
|
||||
panic!("expected original restored terminal text");
|
||||
};
|
||||
assert!(content.contains(&original_output));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn final_model_request_bounds_new_tool_results_to_the_active_context() {
|
||||
let runtime = Arc::new(ScriptedRuntime::new(vec![answer_turn()]));
|
||||
let original_content = format!("start:{}:end", "界".repeat(20_000));
|
||||
let large_result = ConversationMessage {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::ToolResult {
|
||||
tool_use_id: "large-result".to_string(),
|
||||
content: original_content.clone(),
|
||||
is_error: false,
|
||||
},
|
||||
};
|
||||
let mut turn_request = request();
|
||||
turn_request.messages = vec![large_result];
|
||||
let mut coordinator = ProviderRunCoordinator::from_request(
|
||||
"large-result-run",
|
||||
ProviderRunProfile::new(runtime.clone(), turn_request),
|
||||
Vec::new(),
|
||||
ProviderRunLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
coordinator.set_max_context_tokens(Some(8_000));
|
||||
|
||||
let (_sender, control) = turn_control();
|
||||
let block = coordinator
|
||||
.drive_until_blocked(control, |_| Ok(()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(block, ProviderRunBlock::AwaitingDriver { .. }));
|
||||
let requests = runtime.requests();
|
||||
let MessageContent::ToolResult { content, .. } = &requests[0].messages[0].content else {
|
||||
panic!("expected bounded tool result");
|
||||
};
|
||||
assert!(content.chars().count() <= galaxy_agent_core::provider_output_char_limit(Some(8_000)));
|
||||
assert!(content.contains("tool result truncated"));
|
||||
let MessageContent::ToolResult { content, .. } = &coordinator.run().transcript()[0].content
|
||||
else {
|
||||
panic!("expected original tool result");
|
||||
};
|
||||
assert_eq!(content, &original_content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preflight_leaves_headroom_before_the_advertised_context_limit() {
|
||||
let runtime = Arc::new(ScriptedRuntime::new(vec![answer_turn()]));
|
||||
|
||||
@@ -129,11 +129,6 @@ pub(crate) async fn prepare_provider_run(
|
||||
let (supported_tools, supported_cli_agent_tools) =
|
||||
crate::ai::agent::api::prepare_direct_provider_params(&mut params);
|
||||
let skill_path_origin = params.session_context.skill_path_origin();
|
||||
let max_context_tokens = provider_context_window_tokens(
|
||||
&base_provider_config,
|
||||
params.model.as_str(),
|
||||
params.context_window_limit,
|
||||
);
|
||||
let mut cli_params = params.clone();
|
||||
let cli_model_is_placeholder = params.cli_agent_model.as_str().trim().is_empty()
|
||||
|| params
|
||||
@@ -178,9 +173,13 @@ pub(crate) async fn prepare_provider_run(
|
||||
Some(RigRequestMode::Cli),
|
||||
)
|
||||
.await?;
|
||||
let cli_monitor_profile = Some(ProviderRunProfile::new(cli_runtime, cli_prepared.request));
|
||||
let cli_monitor_profile = Some(
|
||||
ProviderRunProfile::new(cli_runtime, cli_prepared.request)
|
||||
.with_max_context_tokens(cli_prepared.max_context_tokens),
|
||||
);
|
||||
|
||||
let PreparedRigTurn {
|
||||
max_context_tokens: prepared_context_tokens,
|
||||
task_id,
|
||||
needs_create_task,
|
||||
user_query,
|
||||
@@ -203,12 +202,13 @@ pub(crate) async fn prepare_provider_run(
|
||||
needs_create_task,
|
||||
user_query,
|
||||
model_id: request.model.as_str().to_string(),
|
||||
max_context_tokens,
|
||||
max_context_tokens: prepared_context_tokens,
|
||||
capabilities: base_runtime.descriptor().capabilities.clone(),
|
||||
empty_output_message: None,
|
||||
};
|
||||
Ok(PreparedProviderRun {
|
||||
base_profile: ProviderRunProfile::new(base_runtime, request),
|
||||
base_profile: ProviderRunProfile::new(base_runtime, request)
|
||||
.with_max_context_tokens(prepared_context_tokens),
|
||||
cli_monitor_profile,
|
||||
tool_result_archive,
|
||||
messages_sent,
|
||||
|
||||
@@ -6,9 +6,10 @@ use ai::skills::SkillReference;
|
||||
use base64::engine::general_purpose;
|
||||
use base64::Engine as _;
|
||||
use galaxy_agent_core::{
|
||||
ContentPart, ConversationMessage, MessageContent, MessageRole, ToolDefinition, ToolResult,
|
||||
TurnRequest,
|
||||
bounded_terminal_output_for_provider_request, ContentPart, ConversationMessage, MessageContent,
|
||||
MessageRole, ToolDefinition, ToolResult, TurnRequest,
|
||||
};
|
||||
use galaxy_bedrock_model_catalog::model_metadata;
|
||||
use sha2::{Digest as _, Sha256};
|
||||
use uuid::Uuid;
|
||||
use warp_multi_agent_api as api;
|
||||
@@ -26,6 +27,7 @@ use crate::ai::provider::request_translator::{
|
||||
use crate::ai::provider::types::flatten_tool_history_for_no_tools_turn;
|
||||
|
||||
pub(crate) struct PreparedRigTurn {
|
||||
pub max_context_tokens: Option<u32>,
|
||||
pub task_id: String,
|
||||
pub needs_create_task: bool,
|
||||
pub user_query: Option<String>,
|
||||
@@ -135,9 +137,12 @@ pub(crate) fn prepare_rig_turn(
|
||||
supported_tools: Vec<ToolType>,
|
||||
supported_cli_agent_tools: Vec<ToolType>,
|
||||
) -> PreparedRigTurn {
|
||||
let max_context_tokens =
|
||||
effective_context_limit(config.max_input_tokens, params.context_window_limit);
|
||||
prepare_rig_turn_for_provider(
|
||||
config.model.clone(),
|
||||
config.max_output_tokens.map(u64::from),
|
||||
max_context_tokens,
|
||||
RigRequestSanitizer::OpenAICompatible,
|
||||
params,
|
||||
supported_tools,
|
||||
@@ -153,9 +158,12 @@ pub(crate) fn prepare_rig_turn_for_mode(
|
||||
supported_cli_agent_tools: Vec<ToolType>,
|
||||
mode: RigRequestMode,
|
||||
) -> PreparedRigTurn {
|
||||
let max_context_tokens =
|
||||
effective_context_limit(config.max_input_tokens, params.context_window_limit);
|
||||
prepare_rig_turn_for_provider(
|
||||
config.model.clone(),
|
||||
config.max_output_tokens.map(u64::from),
|
||||
max_context_tokens,
|
||||
RigRequestSanitizer::OpenAICompatible,
|
||||
params,
|
||||
supported_tools,
|
||||
@@ -189,9 +197,12 @@ pub(crate) fn prepare_bedrock_rig_turn_for_mode(
|
||||
supported_cli_agent_tools: Vec<ToolType>,
|
||||
mode: Option<RigRequestMode>,
|
||||
) -> PreparedRigTurn {
|
||||
let provider_limit = model_metadata(&model).and_then(|metadata| metadata.context_window_tokens);
|
||||
let max_context_tokens = effective_context_limit(provider_limit, params.context_window_limit);
|
||||
prepare_rig_turn_for_provider(
|
||||
Some(model),
|
||||
max_output_tokens,
|
||||
max_context_tokens,
|
||||
RigRequestSanitizer::Bedrock,
|
||||
params,
|
||||
supported_tools,
|
||||
@@ -206,9 +217,22 @@ enum RigRequestSanitizer {
|
||||
Bedrock,
|
||||
}
|
||||
|
||||
fn effective_context_limit(
|
||||
provider_limit: Option<u32>,
|
||||
configured_limit: Option<u32>,
|
||||
) -> Option<u32> {
|
||||
match (provider_limit, configured_limit) {
|
||||
(Some(provider), Some(configured)) => Some(provider.min(configured)),
|
||||
(provider, configured) => provider.or(configured),
|
||||
}
|
||||
}
|
||||
|
||||
// Keep provider limits and tool sets explicit at this shared request adapter boundary.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn prepare_rig_turn_for_provider(
|
||||
model_override: Option<String>,
|
||||
max_output_tokens: Option<u64>,
|
||||
max_context_tokens: Option<u32>,
|
||||
sanitizer: RigRequestSanitizer,
|
||||
params: RequestParams,
|
||||
supported_tools: Vec<ToolType>,
|
||||
@@ -269,11 +293,13 @@ fn prepare_rig_turn_for_provider(
|
||||
}
|
||||
let mut system_prompt = build_system_prompt(&input, &tools, &global_rules, mode);
|
||||
|
||||
let mut new_messages = input_messages(input, tool_results);
|
||||
let mut new_messages = input_messages(input, tool_results, max_context_tokens);
|
||||
let mut persistent_messages = message_history;
|
||||
persistent_messages.append(&mut new_messages);
|
||||
for message in &mut persistent_messages {
|
||||
message.truncate_tool_results_for_provider_request();
|
||||
message.truncate_tool_results_for_provider_request_with_context(max_context_tokens);
|
||||
message
|
||||
.truncate_tagged_terminal_output_for_provider_request_with_context(max_context_tokens);
|
||||
}
|
||||
match sanitizer {
|
||||
RigRequestSanitizer::OpenAICompatible => {
|
||||
@@ -311,6 +337,7 @@ fn prepare_rig_turn_for_provider(
|
||||
request.max_output_tokens = max_output_tokens;
|
||||
|
||||
PreparedRigTurn {
|
||||
max_context_tokens,
|
||||
task_id,
|
||||
needs_create_task,
|
||||
user_query,
|
||||
@@ -338,6 +365,7 @@ fn model_workflow_guidance(model_id: &str) -> &'static str {
|
||||
fn input_messages(
|
||||
inputs: Vec<AIAgentInput>,
|
||||
tool_results: Vec<ToolResult>,
|
||||
max_context_tokens: Option<u32>,
|
||||
) -> Vec<ConversationMessage> {
|
||||
let mut messages = Vec::new();
|
||||
if !tool_results.is_empty() {
|
||||
@@ -375,11 +403,18 @@ fn input_messages(
|
||||
});
|
||||
}
|
||||
|
||||
messages.extend(inputs.into_iter().filter_map(input_message));
|
||||
messages.extend(
|
||||
inputs
|
||||
.into_iter()
|
||||
.filter_map(|input| input_message(input, max_context_tokens)),
|
||||
);
|
||||
messages
|
||||
}
|
||||
|
||||
fn input_message(input: AIAgentInput) -> Option<ConversationMessage> {
|
||||
fn input_message(
|
||||
input: AIAgentInput,
|
||||
max_context_tokens: Option<u32>,
|
||||
) -> Option<ConversationMessage> {
|
||||
let (text, images) = match input {
|
||||
AIAgentInput::UserQuery {
|
||||
query,
|
||||
@@ -388,9 +423,13 @@ fn input_message(input: AIAgentInput) -> Option<ConversationMessage> {
|
||||
..
|
||||
} => {
|
||||
let text = if let Some(command) = running_command {
|
||||
let output = bounded_terminal_output_for_provider_request(
|
||||
&command.grid_contents,
|
||||
max_context_tokens,
|
||||
);
|
||||
format!(
|
||||
"[Running command: {}]\n[Command ID: {}]\n[Terminal output:\n{}\n]\n{}",
|
||||
command.command, command.block_id, command.grid_contents, query
|
||||
command.command, command.block_id, output, query
|
||||
)
|
||||
} else {
|
||||
query
|
||||
@@ -401,16 +440,19 @@ fn input_message(input: AIAgentInput) -> Option<ConversationMessage> {
|
||||
prompt,
|
||||
context,
|
||||
completed_command,
|
||||
} => (
|
||||
format!(
|
||||
"[Completed command: {}]\n[Command ID: {}]\n[Final terminal output:\n{}\n]\n{}",
|
||||
completed_command.command,
|
||||
completed_command.block_id,
|
||||
completed_command.grid_contents,
|
||||
prompt
|
||||
),
|
||||
image_parts(&context),
|
||||
),
|
||||
} => {
|
||||
let output = bounded_terminal_output_for_provider_request(
|
||||
&completed_command.grid_contents,
|
||||
max_context_tokens,
|
||||
);
|
||||
(
|
||||
format!(
|
||||
"[Completed command: {}]\n[Command ID: {}]\n[Final terminal output:\n{}\n]\n{}",
|
||||
completed_command.command, completed_command.block_id, output, prompt
|
||||
),
|
||||
image_parts(&context),
|
||||
)
|
||||
}
|
||||
AIAgentInput::ActionResult { .. } => return None,
|
||||
AIAgentInput::AutoCodeDiffQuery { query, .. } => (query, Vec::new()),
|
||||
AIAgentInput::ResumeConversation { .. } => (
|
||||
|
||||
@@ -437,6 +437,38 @@ fn completed_command_assessment_uses_root_history_without_tools_or_monitor_instr
|
||||
assert_eq!(prepared.request.messages, prepared.persistent_messages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_command_assessment_bounds_only_the_provider_payload() {
|
||||
let full_output = format!("first\n{}\nlast", "x".repeat(100_000));
|
||||
let block_id: galaxy_terminal::model::BlockId = "large-completed-command".to_string().into();
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.context_window_limit = Some(8_000);
|
||||
params.input = vec![AIAgentInput::CommandCompletionAssessment {
|
||||
prompt: "Report the final result to the user.".to_string(),
|
||||
context: Arc::from([]),
|
||||
completed_command: crate::ai::agent::RunningCommand {
|
||||
command: "verbose-command".to_string(),
|
||||
block_id,
|
||||
grid_contents: full_output.clone(),
|
||||
cursor: String::new(),
|
||||
requested_command_id: None,
|
||||
is_alt_screen_active: false,
|
||||
},
|
||||
}];
|
||||
|
||||
let prepared = prepare_rig_turn(&config(), params, Vec::new(), Vec::new());
|
||||
let MessageContent::Text(provider_text) = &prepared.request.messages[0].content else {
|
||||
panic!("expected text command assessment");
|
||||
};
|
||||
|
||||
assert!(provider_text.contains("terminal output truncated for model context"));
|
||||
assert!(provider_text.contains("full output remains available in the terminal"));
|
||||
assert!(provider_text.contains("first"));
|
||||
assert!(provider_text.contains("last"));
|
||||
assert!(!provider_text.contains(&full_output));
|
||||
assert_eq!(full_output.chars().count(), 100_011);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forced_cli_profile_uses_monitor_prompt_and_tools_for_an_initial_query() {
|
||||
let mut params = RequestParams::new_for_test();
|
||||
@@ -801,7 +833,7 @@ fn normalized_tool_outcomes_are_the_only_action_results_sent_to_rig() {
|
||||
})
|
||||
.collect();
|
||||
|
||||
let messages = input_messages(Vec::new(), tool_results);
|
||||
let messages = input_messages(Vec::new(), tool_results, Some(128_000));
|
||||
|
||||
assert_eq!(messages.len(), 1);
|
||||
let MessageContent::MultiPart(parts) = &messages[0].content else {
|
||||
|
||||
Reference in New Issue
Block a user