Route Bedrock through Rig and improve agent observability

This commit is contained in:
2026-08-22 12:02:45 -05:00
parent 1f1d0737a9
commit f291cfe803
28 changed files with 519 additions and 938 deletions
Generated
+1
View File
@@ -6269,6 +6269,7 @@ dependencies = [
"log", "log",
"log-panics", "log-panics",
"tempfile", "tempfile",
"uuid",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "web-sys",
"zip 2.4.2", "zip 2.4.2",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

+27 -3
View File
@@ -17,6 +17,7 @@ use super::launch::{acp_provider_selection_identity, acp_selection_identity};
use super::prompt::{prompt_content, GalaxyTerminalTools}; use super::prompt::{prompt_content, GalaxyTerminalTools};
use crate::ai::agent::api::{self, RequestParams}; use crate::ai::agent::api::{self, RequestParams};
use crate::ai::agent::EntrypointType; use crate::ai::agent::EntrypointType;
use crate::ai::model_output_logging;
use crate::ai::runtime::{RuntimeResponseConfig, RuntimeResponseTranslator}; use crate::ai::runtime::{RuntimeResponseConfig, RuntimeResponseTranslator};
use crate::persistence::model::AcpConversationData; use crate::persistence::model::AcpConversationData;
use crate::server::server_api::AIApiError; use crate::server::server_api::AIApiError;
@@ -113,12 +114,21 @@ pub(crate) async fn acp_output_stream(
runtime_config.auto_approve_permissions = auto_approve_permissions; runtime_config.auto_approve_permissions = auto_approve_permissions;
runtime_config.permission_policy = permission_policy; runtime_config.permission_policy = permission_policy;
let runtime = AcpAgentRuntime::new(manager, runtime_config).with_state(session_metadata); let runtime = AcpAgentRuntime::new(manager, runtime_config).with_state(session_metadata);
let mut request = TurnRequest::new(runtime_id, Vec::new()).with_prompt(prompt); let mut request = TurnRequest::new(runtime_id.clone(), Vec::new()).with_prompt(prompt);
request.conversation_id = Some(conversation_id); request.conversation_id = Some(conversation_id.clone());
let (control_sender, control) = turn_control(); let (control_sender, control) = turn_control();
let events = match runtime.start_turn(request, control).await { let events = match runtime.start_turn(request, control).await {
Ok(events) => events, Ok(events) => events,
Err(error) => return translated_startup_error_stream(translator, &error.to_string()), Err(error) => {
model_output_logging::log(serde_json::json!({
"event": "model_error",
"source": "acp",
"conversation_id": conversation_id,
"runtime_id": runtime_id,
"payload": &error,
}));
return translated_startup_error_stream(translator, &error.to_string());
}
}; };
let turn_control_guard = AcpTurnControlGuard::new(turn_control_slot, control_sender.clone()); let turn_control_guard = AcpTurnControlGuard::new(turn_control_slot, control_sender.clone());
@@ -140,6 +150,13 @@ pub(crate) async fn acp_output_stream(
let event = match event { let event = match event {
Ok(event) => event, Ok(event) => event,
Err(error) => { Err(error) => {
model_output_logging::log(serde_json::json!({
"event": "model_error",
"source": "acp",
"conversation_id": conversation_id,
"runtime_id": runtime_id,
"payload": &error,
}));
yield Err(Arc::new(AIApiError::Stream { yield Err(Arc::new(AIApiError::Stream {
stream_type: "acp", stream_type: "acp",
source: anyhow::anyhow!(error), source: anyhow::anyhow!(error),
@@ -147,6 +164,13 @@ pub(crate) async fn acp_output_stream(
break; break;
} }
}; };
model_output_logging::log(serde_json::json!({
"event": "model_output",
"source": "acp",
"conversation_id": conversation_id,
"runtime_id": runtime_id,
"payload": &event,
}));
match translator.translate(event) { match translator.translate(event) {
Ok(response_events) => { Ok(response_events) => {
for response_event in response_events { for response_event in response_events {
+6 -17
View File
@@ -5,14 +5,12 @@ use aws_config::BehaviorVersion;
use aws_credential_types::provider::ProvideCredentials; use aws_credential_types::provider::ProvideCredentials;
use aws_sdk_bedrockruntime::config::Region; use aws_sdk_bedrockruntime::config::Region;
use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient; use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient;
use galaxy_agent_core::AgentError;
use super::convert::{build_converse_request, CachingConfig, ConversationMessage, ToolDefinition}; use super::convert::{build_converse_request, CachingConfig, ConversationMessage, ToolDefinition};
use super::diagnostic::BedrockDiagnosticLogger; use super::diagnostic::BedrockDiagnosticLogger;
use super::external_config::ExternalBedrockConfig; use super::external_config::ExternalBedrockConfig;
use super::models::apply_cross_region_prefix; use super::models::apply_cross_region_prefix;
use super::response_translator::bedrock_stream_to_response_events; use super::response_translator::bedrock_stream_to_response_events;
use super::runtime::BedrockAgentRuntime;
use crate::ai::agent::api::LegacyResponseStream; use crate::ai::agent::api::LegacyResponseStream;
use crate::settings::ai::BedrockAuthMethod; use crate::settings::ai::BedrockAuthMethod;
@@ -171,21 +169,12 @@ impl BedrockClient {
}) })
} }
pub(crate) fn agent_runtime( pub(crate) fn runtime_client(&self) -> BedrockRuntimeClient {
&self, self.runtime_client.clone()
model: String, }
cross_region_inference: bool,
max_output_tokens: Option<u64>, pub(crate) fn region(&self) -> &str {
caching_config: CachingConfig, &self.region
) -> Result<BedrockAgentRuntime, AgentError> {
BedrockAgentRuntime::new(
self.runtime_client.clone(),
model,
self.region.clone(),
cross_region_inference,
max_output_tokens,
caching_config,
)
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
-1
View File
@@ -7,7 +7,6 @@ pub mod external_config;
pub mod models; pub mod models;
pub mod request_translator; pub mod request_translator;
pub mod response_translator; pub mod response_translator;
pub mod runtime;
pub mod settings_view; pub mod settings_view;
#[cfg(test)] #[cfg(test)]
-486
View File
@@ -1,486 +0,0 @@
use std::collections::BTreeMap;
use async_trait::async_trait;
use aws_sdk_bedrockruntime::operation::converse_stream::ConverseStreamOutput;
use aws_sdk_bedrockruntime::types::{
ContentBlockDelta, ContentBlockStart, ConverseStreamOutput as AwsStreamEvent,
ReasoningContentBlockDelta, StopReason as AwsStopReason,
};
use aws_sdk_bedrockruntime::Client as AwsBedrockClient;
use futures::{FutureExt, StreamExt};
use galaxy_agent_core::{
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, AgentRuntime, RuntimeCapabilities,
RuntimeDescriptor, RuntimeKind, StopReason, ToolCall, ToolEvent, TurnCommand, TurnControl,
TurnRequest, Usage,
};
use uuid::Uuid;
use super::convert::{build_converse_request, CachingConfig, ConvertedRequest};
const DEFAULT_MAX_OUTPUT_TOKENS: u64 = 64_000;
#[derive(Clone)]
pub(crate) struct BedrockAgentRuntime {
client: AwsBedrockClient,
resolved_model: String,
max_output_tokens: Option<u64>,
caching_config: CachingConfig,
descriptor: RuntimeDescriptor,
}
impl BedrockAgentRuntime {
pub(crate) fn new(
client: AwsBedrockClient,
configured_model: String,
region: String,
cross_region_inference: bool,
max_output_tokens: Option<u64>,
caching_config: CachingConfig,
) -> Result<Self, AgentError> {
let resolved_model = galaxy_agent_rig::resolve_bedrock_model_id(
&configured_model,
&region,
cross_region_inference,
)?;
let descriptor = RuntimeDescriptor {
id: format!("bedrock:{resolved_model}"),
display_name: format!("Bedrock / {resolved_model}"),
kind: RuntimeKind::Provider,
capabilities: RuntimeCapabilities::provider(),
};
Ok(Self {
client,
resolved_model,
max_output_tokens,
caching_config,
descriptor,
})
}
}
#[async_trait]
impl AgentRuntime for BedrockAgentRuntime {
fn descriptor(&self) -> &RuntimeDescriptor {
&self.descriptor
}
async fn start_turn(
&self,
request: TurnRequest,
control: TurnControl,
) -> Result<AgentEventStream, AgentError> {
let converted =
convert_turn_request(request, self.max_output_tokens, self.caching_config.clone());
let mut request = self
.client
.converse_stream()
.model_id(&self.resolved_model)
.set_system(Some(converted.system))
.set_messages(Some(converted.messages))
.inference_config(converted.inference_config);
if let Some(tool_config) = converted.tool_config {
request = request.tool_config(tool_config);
}
let runtime_request_id = Uuid::new_v4().to_string();
let send_future = request.send().fuse();
let initial_control = control.clone();
let control_future = initial_control.receive().fuse();
futures::pin_mut!(send_future, control_future);
let output = futures::select_biased! {
command = control_future => match command {
Ok(TurnCommand::Cancel) => {
return Ok(stopped_before_stream(runtime_request_id));
}
Ok(TurnCommand::Steer { .. }) | Err(_) => {
send_future.await.map_err(map_bedrock_error)?
}
},
result = send_future => result.map_err(map_bedrock_error)?,
};
Ok(translate_bedrock_stream(
output,
runtime_request_id,
control,
))
}
}
fn convert_turn_request(
request: TurnRequest,
configured_max_output_tokens: Option<u64>,
caching_config: CachingConfig,
) -> ConvertedRequest {
let max_output_tokens = request
.max_output_tokens
.or(configured_max_output_tokens)
.unwrap_or(DEFAULT_MAX_OUTPUT_TOKENS)
.min(i32::MAX as u64) as i32;
build_converse_request(
request.messages,
request.system_prompt,
None,
request.tools,
max_output_tokens,
None,
None,
None,
caching_config,
)
}
fn translate_bedrock_stream(
mut output: ConverseStreamOutput,
runtime_request_id: String,
control: TurnControl,
) -> AgentEventStream {
let events = async_stream::stream! {
yield Ok(AgentEvent::TurnStarted {
runtime_request_id,
});
let mut translator = BedrockStreamTranslator::default();
let mut control_open = true;
loop {
let next_event = output.stream.recv().fuse();
let next_command = if control_open {
futures::future::Either::Left(control.receive())
} else {
futures::future::Either::Right(futures::future::pending())
}
.fuse();
futures::pin_mut!(next_event, next_command);
let event = futures::select_biased! {
command = next_command => {
match command {
Ok(TurnCommand::Cancel) => {
yield Ok(AgentEvent::TurnStopped {
reason: StopReason::Cancelled,
});
return;
}
Ok(TurnCommand::Steer { .. }) => continue,
Err(_) => {
control_open = false;
continue;
}
}
}
event = next_event => event,
};
match event {
Ok(Some(event)) => match translator.translate(event) {
Ok(events) => {
for event in events {
yield Ok(event);
}
}
Err(error) => {
yield Err(error);
return;
}
},
Ok(None) => match translator.finish() {
Ok(events) => {
for event in events {
yield Ok(event);
}
return;
}
Err(error) => {
yield Err(error);
return;
}
},
Err(error) => {
yield Err(map_bedrock_error(error));
return;
}
}
}
};
Box::pin(events)
}
#[derive(Default)]
struct BedrockStreamTranslator {
content_blocks: BTreeMap<i32, PendingContentBlock>,
stop_reason: Option<StopReason>,
}
impl BedrockStreamTranslator {
fn translate(&mut self, event: AwsStreamEvent) -> Result<Vec<AgentEvent>, AgentError> {
match event {
AwsStreamEvent::MessageStart(_) => Ok(Vec::new()),
AwsStreamEvent::ContentBlockStart(start) => {
let Some(block_start) = start.start() else {
return Ok(Vec::new());
};
let ContentBlockStart::ToolUse(tool) = block_start else {
return Err(protocol_error(
"Bedrock started an unsupported output content block",
));
};
let index = start.content_block_index();
if self
.content_blocks
.insert(
index,
PendingContentBlock::Tool {
id: tool.tool_use_id().to_string(),
name: tool.name().to_string(),
input: String::new(),
},
)
.is_some()
{
return Err(protocol_error(format!(
"Bedrock started content block {index} more than once"
)));
}
Ok(Vec::new())
}
AwsStreamEvent::ContentBlockDelta(delta) => {
let Some(delta_value) = delta.delta() else {
return Err(protocol_error("Bedrock emitted an empty content delta"));
};
let index = delta.content_block_index();
match delta_value {
ContentBlockDelta::Text(text) => {
Ok(vec![AgentEvent::TextDelta { text: text.clone() }])
}
ContentBlockDelta::ReasoningContent(reasoning) => {
let block = self.content_blocks.entry(index).or_insert_with(|| {
PendingContentBlock::Reasoning {
text: String::new(),
signature: None,
}
});
let PendingContentBlock::Reasoning { text, signature } = block else {
return Err(protocol_error(format!(
"Bedrock mixed reasoning and tool data in content block {index}"
)));
};
match reasoning {
ReasoningContentBlockDelta::Text(delta) => {
text.push_str(delta);
Ok(vec![AgentEvent::ReasoningDelta {
text: delta.clone(),
}])
}
ReasoningContentBlockDelta::Signature(delta) => {
signature.get_or_insert_with(String::new).push_str(delta);
Ok(Vec::new())
}
ReasoningContentBlockDelta::RedactedContent(_) => Ok(Vec::new()),
_ => Err(protocol_error("Bedrock emitted an unknown reasoning delta")),
}
}
ContentBlockDelta::ToolUse(tool_delta) => {
let Some(PendingContentBlock::Tool { input, .. }) =
self.content_blocks.get_mut(&index)
else {
return Err(protocol_error(format!(
"Bedrock emitted tool input before starting content block {index}"
)));
};
input.push_str(tool_delta.input());
Ok(Vec::new())
}
ContentBlockDelta::Citation(_) => Ok(Vec::new()),
ContentBlockDelta::Image(_) => {
Err(protocol_error("Bedrock emitted unsupported image output"))
}
ContentBlockDelta::ToolResult(_) => Err(protocol_error(
"Bedrock emitted an unexpected tool-result delta",
)),
_ => Err(protocol_error("Bedrock emitted an unknown content delta")),
}
}
AwsStreamEvent::ContentBlockStop(stop) => {
let index = stop.content_block_index();
let Some(block) = self.content_blocks.remove(&index) else {
return Ok(Vec::new());
};
match block {
PendingContentBlock::Tool { id, name, input } => {
let arguments = serde_json::from_str(&input).map_err(|error| {
protocol_error(format!(
"Bedrock returned invalid JSON for tool '{name}' ({id}): {error}"
))
})?;
Ok(vec![AgentEvent::Tool {
event: ToolEvent::Proposed {
call: ToolCall {
id,
name,
arguments,
},
},
}])
}
PendingContentBlock::Reasoning { text, signature } => {
Ok(vec![AgentEvent::ReasoningCompleted { text, signature }])
}
}
}
AwsStreamEvent::MessageStop(stop) => {
if self.stop_reason.is_some() {
return Err(protocol_error(
"Bedrock emitted more than one message-stop event",
));
}
self.stop_reason = Some(map_stop_reason(stop.stop_reason()));
Ok(Vec::new())
}
AwsStreamEvent::Metadata(metadata) => {
let Some(usage) = metadata.usage() else {
return Ok(Vec::new());
};
let output_tokens = nonnegative_tokens(usage.output_tokens());
let cached_input_tokens =
nonnegative_tokens(usage.cache_read_input_tokens().unwrap_or(0));
let cache_creation_input_tokens =
nonnegative_tokens(usage.cache_write_input_tokens().unwrap_or(0));
let reported_total_tokens = nonnegative_tokens(usage.total_tokens());
let total_input_tokens = if reported_total_tokens > 0 {
reported_total_tokens.saturating_sub(output_tokens)
} else {
nonnegative_tokens(usage.input_tokens())
.saturating_add(cached_input_tokens)
.saturating_add(cache_creation_input_tokens)
};
Ok(vec![AgentEvent::UsageUpdated {
usage: Usage {
input_tokens: total_input_tokens
.saturating_sub(cached_input_tokens)
.saturating_sub(cache_creation_input_tokens),
output_tokens,
cached_input_tokens,
cache_creation_input_tokens,
},
}])
}
_ => Err(protocol_error("Bedrock emitted an unknown stream event")),
}
}
fn finish(self) -> Result<Vec<AgentEvent>, AgentError> {
if !self.content_blocks.is_empty() {
return Err(protocol_error(
"Bedrock stream ended with incomplete content blocks",
));
}
let reason = self
.stop_reason
.ok_or_else(|| protocol_error("Bedrock stream ended before the message-stop event"))?;
Ok(vec![AgentEvent::TurnStopped { reason }])
}
}
#[derive(Debug)]
enum PendingContentBlock {
Tool {
id: String,
name: String,
input: String,
},
Reasoning {
text: String,
signature: Option<String>,
},
}
fn map_stop_reason(reason: &AwsStopReason) -> StopReason {
match reason {
AwsStopReason::EndTurn | AwsStopReason::StopSequence | AwsStopReason::ToolUse => {
StopReason::Completed
}
AwsStopReason::MaxTokens => StopReason::MaxTokens,
AwsStopReason::ModelContextWindowExceeded => StopReason::ContextWindowExceeded,
AwsStopReason::ContentFiltered | AwsStopReason::GuardrailIntervened => StopReason::Refusal,
AwsStopReason::MalformedModelOutput | AwsStopReason::MalformedToolUse => {
StopReason::Other(reason.as_str().to_string())
}
other => StopReason::Other(other.as_str().to_string()),
}
}
fn nonnegative_tokens(value: i32) -> u64 {
u64::try_from(value).unwrap_or_default()
}
fn stopped_before_stream(runtime_request_id: String) -> AgentEventStream {
Box::pin(futures::stream::iter([
Ok(AgentEvent::TurnStarted { runtime_request_id }),
Ok(AgentEvent::TurnStopped {
reason: StopReason::Cancelled,
}),
]))
}
fn map_bedrock_error(error: impl std::fmt::Display + std::fmt::Debug) -> AgentError {
let display = error.to_string();
let debug = format!("{error:?}");
let message = if debug.len() > display.len() {
debug
} else {
display
};
let normalized = message.to_ascii_lowercase();
let kind = if normalized.contains("accessdenied")
|| normalized.contains("access denied")
|| normalized.contains("unauthorized")
|| normalized.contains("credential")
{
AgentErrorKind::Authentication
} else if normalized.contains("throttl") || normalized.contains("rate limit") {
AgentErrorKind::RateLimited
} else if normalized.contains("context window")
|| normalized.contains("too many tokens")
|| normalized.contains("modelcontextwindowexceeded")
{
AgentErrorKind::ContextWindowExceeded
} else if normalized.contains("validation")
|| normalized.contains("resource not found")
|| normalized.contains("resourcenotfound")
{
AgentErrorKind::InvalidRequest
} else if normalized.contains("timeout")
|| normalized.contains("dispatchfailure")
|| normalized.contains("connection")
{
AgentErrorKind::Transport
} else {
AgentErrorKind::Provider
};
let mut error = AgentError::new(kind, message);
error.recoverable = matches!(
kind,
AgentErrorKind::RateLimited | AgentErrorKind::Transport
) || (kind == AgentErrorKind::Provider
&& is_transient_provider_error(&normalized));
error
}
fn is_transient_provider_error(normalized: &str) -> bool {
normalized.contains("modelnotready")
|| normalized.contains("model not ready")
|| normalized.contains("serviceunavailable")
|| normalized.contains("service unavailable")
|| normalized.contains("internalserver")
|| normalized.contains("internal server")
|| normalized.contains("temporarily unavailable")
|| normalized.contains("overloaded")
}
fn protocol_error(message: impl Into<String>) -> AgentError {
AgentError::new(AgentErrorKind::Protocol, message)
}
#[cfg(test)]
#[path = "runtime_tests.rs"]
mod tests;
-362
View File
@@ -1,362 +0,0 @@
use aws_sdk_bedrockruntime::types::{
CacheTtl, ContentBlock, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStart,
ContentBlockStartEvent, ContentBlockStopEvent, ConverseStreamMetadataEvent,
ConverseStreamOutput as AwsStreamEvent, MessageStopEvent, ReasoningContentBlockDelta,
StopReason as AwsStopReason, SystemContentBlock, TokenUsage, Tool, ToolUseBlockDelta,
ToolUseBlockStart,
};
use galaxy_agent_core::{
AgentErrorKind, AgentEvent, ConversationMessage, MessageContent, MessageRole, StopReason,
ToolDefinition, ToolEvent, TurnRequest, Usage,
};
use serde_json::json;
use super::*;
fn turn_request() -> TurnRequest {
let mut request = TurnRequest::new(
"anthropic.claude-test",
vec![
ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("first".to_string()),
},
ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::Text("response".to_string()),
},
ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("continue".to_string()),
},
],
);
request.system_prompt = Some("system".to_string());
request.tools = vec![ToolDefinition {
name: "read_files".to_string(),
description: "Read files".to_string(),
input_schema: json!({"type": "object"}),
}];
request
}
fn cache_ttls(converted: &ConvertedRequest) -> Vec<Option<CacheTtl>> {
let mut ttls = Vec::new();
for message in &converted.messages {
for block in message.content() {
if let ContentBlock::CachePoint(point) = block {
ttls.push(point.ttl().cloned());
}
}
}
for block in &converted.system {
if let SystemContentBlock::CachePoint(point) = block {
ttls.push(point.ttl().cloned());
}
}
if let Some(tool_config) = &converted.tool_config {
for tool in tool_config.tools() {
if let Tool::CachePoint(point) = tool {
ttls.push(point.ttl().cloned());
}
}
}
ttls
}
#[test]
fn one_turn_transport_preserves_disabled_default_and_one_hour_cache_modes() {
let disabled = convert_turn_request(
turn_request(),
Some(4096),
CachingConfig {
enabled: false,
extended_ttl_requested: false,
},
);
assert!(cache_ttls(&disabled).is_empty());
let default = convert_turn_request(turn_request(), Some(4096), CachingConfig::default());
assert_eq!(cache_ttls(&default), vec![None, None, None]);
let one_hour = convert_turn_request(
turn_request(),
Some(4096),
CachingConfig {
enabled: true,
extended_ttl_requested: true,
},
);
assert_eq!(
cache_ttls(&one_hour),
vec![
Some(CacheTtl::OneHour),
Some(CacheTtl::OneHour),
Some(CacheTtl::OneHour),
]
);
}
#[test]
fn one_turn_transport_prefers_request_output_limit() {
let mut request = turn_request();
request.max_output_tokens = Some(8192);
let converted = convert_turn_request(request, Some(4096), CachingConfig::default());
assert_eq!(converted.inference_config.max_tokens(), Some(8192));
}
fn tool_start(index: i32, id: &str, name: &str) -> AwsStreamEvent {
AwsStreamEvent::ContentBlockStart(
ContentBlockStartEvent::builder()
.content_block_index(index)
.start(ContentBlockStart::ToolUse(
ToolUseBlockStart::builder()
.tool_use_id(id)
.name(name)
.build()
.unwrap(),
))
.build()
.unwrap(),
)
}
fn ordinary_start(index: i32) -> AwsStreamEvent {
AwsStreamEvent::ContentBlockStart(
ContentBlockStartEvent::builder()
.content_block_index(index)
.build()
.unwrap(),
)
}
fn content_delta(index: i32, delta: ContentBlockDelta) -> AwsStreamEvent {
AwsStreamEvent::ContentBlockDelta(
ContentBlockDeltaEvent::builder()
.content_block_index(index)
.delta(delta)
.build()
.unwrap(),
)
}
fn content_stop(index: i32) -> AwsStreamEvent {
AwsStreamEvent::ContentBlockStop(
ContentBlockStopEvent::builder()
.content_block_index(index)
.build()
.unwrap(),
)
}
fn message_stop(reason: AwsStopReason) -> AwsStreamEvent {
AwsStreamEvent::MessageStop(
MessageStopEvent::builder()
.stop_reason(reason)
.build()
.unwrap(),
)
}
fn metadata(usage: Usage) -> AwsStreamEvent {
AwsStreamEvent::Metadata(
ConverseStreamMetadataEvent::builder()
.usage(
TokenUsage::builder()
.input_tokens(
(usage.input_tokens
+ usage.cached_input_tokens
+ usage.cache_creation_input_tokens) as i32,
)
.output_tokens(usage.output_tokens as i32)
.total_tokens(usage.total_tokens() as i32)
.cache_read_input_tokens(usage.cached_input_tokens as i32)
.cache_write_input_tokens(usage.cache_creation_input_tokens as i32)
.build()
.unwrap(),
)
.build(),
)
}
#[test]
fn stream_translator_accepts_ordinary_content_block_starts() {
let mut translator = BedrockStreamTranslator::default();
assert!(translator.translate(ordinary_start(0)).unwrap().is_empty());
assert_eq!(
translator
.translate(content_delta(
0,
ContentBlockDelta::Text("response".to_string()),
))
.unwrap(),
vec![AgentEvent::TextDelta {
text: "response".to_string(),
}]
);
assert!(translator.translate(content_stop(0)).unwrap().is_empty());
}
#[test]
fn stream_translator_correlates_tools_by_content_index() {
let mut translator = BedrockStreamTranslator::default();
translator
.translate(tool_start(2, "call-2", "grep"))
.unwrap();
translator
.translate(tool_start(1, "call-1", "read_files"))
.unwrap();
translator
.translate(content_delta(
1,
ContentBlockDelta::ToolUse(
ToolUseBlockDelta::builder()
.input("{\"files\":[\"Cargo.toml\"]}")
.build()
.unwrap(),
),
))
.unwrap();
translator
.translate(content_delta(
2,
ContentBlockDelta::ToolUse(
ToolUseBlockDelta::builder()
.input("{\"query\":\"ProviderRun\"}")
.build()
.unwrap(),
),
))
.unwrap();
let first = translator.translate(content_stop(1)).unwrap();
let second = translator.translate(content_stop(2)).unwrap();
assert!(matches!(
first.as_slice(),
[AgentEvent::Tool {
event: ToolEvent::Proposed { call }
}] if call.id == "call-1"
&& call.name == "read_files"
&& call.arguments == json!({"files": ["Cargo.toml"]})
));
assert!(matches!(
second.as_slice(),
[AgentEvent::Tool {
event: ToolEvent::Proposed { call }
}] if call.id == "call-2"
&& call.name == "grep"
&& call.arguments == json!({"query": "ProviderRun"})
));
}
#[test]
fn stream_translator_defers_stop_until_usage_metadata_arrives() {
let mut translator = BedrockStreamTranslator::default();
assert!(translator
.translate(message_stop(AwsStopReason::EndTurn))
.unwrap()
.is_empty());
let expected_usage = Usage {
input_tokens: 10,
output_tokens: 4,
cached_input_tokens: 7,
cache_creation_input_tokens: 3,
};
assert_eq!(
translator
.translate(metadata(expected_usage.clone()))
.unwrap(),
vec![AgentEvent::UsageUpdated {
usage: expected_usage,
}]
);
assert_eq!(
translator.finish().unwrap(),
vec![AgentEvent::TurnStopped {
reason: StopReason::Completed,
}]
);
}
#[test]
fn stream_translator_preserves_reasoning_text_and_signature() {
let mut translator = BedrockStreamTranslator::default();
assert_eq!(
translator
.translate(content_delta(
0,
ContentBlockDelta::ReasoningContent(ReasoningContentBlockDelta::Text(
"inspect".to_string(),
)),
))
.unwrap(),
vec![AgentEvent::ReasoningDelta {
text: "inspect".to_string(),
}]
);
translator
.translate(content_delta(
0,
ContentBlockDelta::ReasoningContent(ReasoningContentBlockDelta::Signature(
"signature".to_string(),
)),
))
.unwrap();
assert_eq!(
translator.translate(content_stop(0)).unwrap(),
vec![AgentEvent::ReasoningCompleted {
text: "inspect".to_string(),
signature: Some("signature".to_string()),
}]
);
}
#[test]
fn stream_translator_rejects_invalid_tool_json() {
let mut translator = BedrockStreamTranslator::default();
translator
.translate(tool_start(0, "call", "read_files"))
.unwrap();
translator
.translate(content_delta(
0,
ContentBlockDelta::ToolUse(
ToolUseBlockDelta::builder()
.input("not-json")
.build()
.unwrap(),
),
))
.unwrap();
let error = translator.translate(content_stop(0)).unwrap_err();
assert_eq!(error.kind, AgentErrorKind::Protocol);
}
#[test]
fn bedrock_stop_reasons_map_to_domain_reasons() {
assert_eq!(
map_stop_reason(&AwsStopReason::ToolUse),
StopReason::Completed
);
assert_eq!(
map_stop_reason(&AwsStopReason::MaxTokens),
StopReason::MaxTokens
);
assert_eq!(
map_stop_reason(&AwsStopReason::ModelContextWindowExceeded),
StopReason::ContextWindowExceeded
);
assert_eq!(
map_stop_reason(&AwsStopReason::GuardrailIntervened),
StopReason::Refusal
);
}
#[test]
fn transient_bedrock_provider_failure_is_recoverable() {
let error = map_bedrock_error("ServiceUnavailableException: model temporarily unavailable");
assert_eq!(error.kind, AgentErrorKind::Provider);
assert!(error.recoverable);
}
+5
View File
@@ -1018,6 +1018,10 @@ impl BlocklistAIStatusBar {
.as_ref(app) .as_ref(app)
.provider_retry_status(conversation.id()) .provider_retry_status(conversation.id())
.map(|status| status.label()); .map(|status| status.label());
let model_progress_text = self
.controller
.as_ref(app)
.provider_tool_call_progress_label(conversation.id());
let secondary_element = if fallback_warping_text.is_some() { let secondary_element = if fallback_warping_text.is_some() {
Some(render_fallback_explanation(model.as_ref(), app)) Some(render_fallback_explanation(model.as_ref(), app))
} else { } else {
@@ -1077,6 +1081,7 @@ impl BlocklistAIStatusBar {
force_refresh_button, force_refresh_button,
default_warping_text, default_warping_text,
retry_status_text, retry_status_text,
model_progress_text,
secondary_element, secondary_element,
last_snapshot_at, last_snapshot_at,
warping_start_time: self.warping_start_time, warping_start_time: self.warping_start_time,
@@ -219,6 +219,7 @@ pub struct WarpingProps<'a, V> {
pub terminal_model: &'a TerminalModel, pub terminal_model: &'a TerminalModel,
pub default_warping_text: String, pub default_warping_text: String,
pub retry_status_text: Option<String>, pub retry_status_text: Option<String>,
pub model_progress_text: Option<String>,
pub secondary_element: Option<Box<dyn Element>>, pub secondary_element: Option<Box<dyn Element>>,
/// When an LRC subagent has sent at least one snapshot, the timestamp of the most recent snapshot. /// When an LRC subagent has sent at least one snapshot, the timestamp of the most recent snapshot.
pub last_snapshot_at: Option<instant::Instant>, pub last_snapshot_at: Option<instant::Instant>,
@@ -479,6 +480,13 @@ pub fn render_warping_indicator<V: View>(
} }
}; };
if let Some(model_progress_text) = props.model_progress_text.as_deref() {
non_shimmering_text = Some(match non_shimmering_text {
Some(text) if !text.is_empty() => format!("{text} · {model_progress_text}"),
Some(_) | None => format!(" · {model_progress_text}"),
});
}
if let Some(retry_status_text) = props.retry_status_text.as_deref() { if let Some(retry_status_text) = props.retry_status_text.as_deref() {
non_shimmering_text = Some(match non_shimmering_text { non_shimmering_text = Some(match non_shimmering_text {
Some(text) if !text.is_empty() => format!("{text} {retry_status_text}"), Some(text) if !text.is_empty() => format!("{text} {retry_status_text}"),
+106 -11
View File
@@ -266,6 +266,7 @@ async fn collect_progressive_summary(
} }
AgentEvent::TurnStarted { .. } AgentEvent::TurnStarted { .. }
| AgentEvent::KeepAlive | AgentEvent::KeepAlive
| AgentEvent::ToolCallProgress { .. }
| AgentEvent::ReasoningDelta { .. } | AgentEvent::ReasoningDelta { .. }
| AgentEvent::ReasoningCompleted { .. } | AgentEvent::ReasoningCompleted { .. }
| AgentEvent::RuntimeActivityUpdated { .. } | AgentEvent::RuntimeActivityUpdated { .. }
@@ -848,6 +849,60 @@ impl ProviderRetryStatus {
} }
} }
#[derive(Clone, Debug, PartialEq, Eq)]
struct ProviderToolCallProgressStatus {
call_id: String,
name: Option<String>,
arguments_bytes: u64,
}
impl ProviderToolCallProgressStatus {
fn label(&self) -> String {
let activity = match self.name.as_deref() {
Some("apply_file_diffs") => "Preparing file edit",
Some(name) if name.starts_with("mcp__") => "Preparing MCP tool call",
Some(_) | None => "Preparing tool call",
};
if self.arguments_bytes == 0 {
return format!("{activity}");
}
let kibibytes = self.arguments_bytes.saturating_add(1023) / 1024;
format!("{activity}… ({kibibytes} KB received)")
}
}
enum ProviderToolCallProgressUpdate {
Unchanged,
Set(ProviderToolCallProgressStatus),
Clear,
}
fn provider_tool_call_progress_update(
projection: &ProviderRunProjection,
) -> ProviderToolCallProgressUpdate {
match projection {
ProviderRunProjection::ModelEvent {
event:
AgentEvent::ToolCallProgress {
call_id,
name,
arguments_bytes,
},
..
} => ProviderToolCallProgressUpdate::Set(ProviderToolCallProgressStatus {
call_id: call_id.clone(),
name: name.clone(),
arguments_bytes: *arguments_bytes,
}),
ProviderRunProjection::ModelTurnRequested { .. }
| ProviderRunProjection::ModelTurnFinished { .. }
| ProviderRunProjection::ModelRetry { .. }
| ProviderRunProjection::ToolBatchReady { .. } => ProviderToolCallProgressUpdate::Clear,
ProviderRunProjection::ModelTurnStarted { .. }
| ProviderRunProjection::ModelEvent { .. } => ProviderToolCallProgressUpdate::Unchanged,
}
}
struct ActiveProviderRunSlot { struct ActiveProviderRunSlot {
stream_id: ResponseStreamId, stream_id: ResponseStreamId,
response_stream: ModelHandle<ResponseStream>, response_stream: ModelHandle<ResponseStream>,
@@ -867,6 +922,7 @@ struct ActiveProviderRunSlot {
pending_command_completion: Option<PendingProviderCommandCompletion>, pending_command_completion: Option<PendingProviderCommandCompletion>,
monitor_prose_continuations: usize, monitor_prose_continuations: usize,
retry_status: Option<ProviderRetryStatus>, retry_status: Option<ProviderRetryStatus>,
tool_call_progress: Option<ProviderToolCallProgressStatus>,
} }
struct QueuedProviderRun { struct QueuedProviderRun {
@@ -1906,6 +1962,7 @@ enum ProviderDriveMessage {
Projection { Projection {
lifecycle: Option<ProviderLlmLifecycle>, lifecycle: Option<ProviderLlmLifecycle>,
latest_usage: Option<Usage>, latest_usage: Option<Usage>,
tool_call_progress: ProviderToolCallProgressUpdate,
events: Vec<warp_multi_agent_api::ResponseEvent>, events: Vec<warp_multi_agent_api::ResponseEvent>,
acknowledgement: oneshot::Sender<Result<(), String>>, acknowledgement: oneshot::Sender<Result<(), String>>,
}, },
@@ -2098,6 +2155,16 @@ impl BlocklistAIController {
.and_then(|slot| slot.retry_status) .and_then(|slot| slot.retry_status)
} }
pub(crate) fn provider_tool_call_progress_label(
&self,
conversation_id: AIConversationId,
) -> Option<String> {
self.active_provider_runs
.get(&conversation_id)
.and_then(|slot| slot.tool_call_progress.as_ref())
.map(ProviderToolCallProgressStatus::label)
}
fn has_unresolved_ask_user_question( fn has_unresolved_ask_user_question(
&self, &self,
conversation_id: AIConversationId, conversation_id: AIConversationId,
@@ -5137,6 +5204,7 @@ impl BlocklistAIController {
pending_command_completion: None, pending_command_completion: None,
monitor_prose_continuations: 0, monitor_prose_continuations: 0,
retry_status: None, retry_status: None,
tool_call_progress: None,
}; };
match self.active_provider_runs.entry(conversation_data.id) { match self.active_provider_runs.entry(conversation_data.id) {
Entry::Occupied(_) => { Entry::Occupied(_) => {
@@ -5759,6 +5827,7 @@ impl BlocklistAIController {
pending_command_completion, pending_command_completion,
monitor_prose_continuations, monitor_prose_continuations,
retry_status: None, retry_status: None,
tool_call_progress: None,
}, },
); );
if let Err(error) = if let Err(error) =
@@ -5963,6 +6032,7 @@ impl BlocklistAIController {
pending_command_completion: None, pending_command_completion: None,
monitor_prose_continuations: 0, monitor_prose_continuations: 0,
retry_status: None, retry_status: None,
tool_call_progress: None,
}, },
base_provider_config, base_provider_config,
cli_provider_config, cli_provider_config,
@@ -6316,6 +6386,8 @@ impl BlocklistAIController {
turn_control, turn_control,
|projection| { |projection| {
let lifecycle = provider_llm_lifecycle(&projection); let lifecycle = provider_llm_lifecycle(&projection);
let tool_call_progress =
provider_tool_call_progress_update(&projection);
let latest_usage = match &projection { let latest_usage = match &projection {
ProviderRunProjection::ModelEvent { ProviderRunProjection::ModelEvent {
event: AgentEvent::UsageUpdated { usage }, event: AgentEvent::UsageUpdated { usage },
@@ -6337,6 +6409,7 @@ impl BlocklistAIController {
.send(ProviderDriveMessage::Projection { .send(ProviderDriveMessage::Projection {
lifecycle, lifecycle,
latest_usage, latest_usage,
tool_call_progress,
events, events,
acknowledgement, acknowledgement,
}) })
@@ -6396,6 +6469,7 @@ impl BlocklistAIController {
ProviderDriveMessage::Projection { ProviderDriveMessage::Projection {
lifecycle, lifecycle,
latest_usage, latest_usage,
tool_call_progress,
events, events,
acknowledgement, acknowledgement,
} => { } => {
@@ -6421,8 +6495,29 @@ impl BlocklistAIController {
break; break;
} }
} }
let progress_changed = {
let slot = self
.active_provider_runs
.get_mut(&conversation_id)
.expect("provider projection retained its active run slot");
match tool_call_progress {
ProviderToolCallProgressUpdate::Unchanged => false,
ProviderToolCallProgressUpdate::Set(progress) => {
let changed = slot
.tool_call_progress
.as_ref()
.is_none_or(|current| current.label() != progress.label());
slot.tool_call_progress = Some(progress);
changed
}
ProviderToolCallProgressUpdate::Clear => {
slot.tool_call_progress.take().is_some()
}
}
};
let mut should_refresh_status = progress_changed;
if let Some(lifecycle) = lifecycle.as_ref() { if let Some(lifecycle) = lifecycle.as_ref() {
let should_refresh_status = { should_refresh_status |= {
let slot = self let slot = self
.active_provider_runs .active_provider_runs
.get_mut(&conversation_id) .get_mut(&conversation_id)
@@ -6451,16 +6546,6 @@ impl BlocklistAIController {
} }
} }
}; };
if should_refresh_status {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.update_conversation_status(
self.terminal_surface_id,
conversation_id,
ConversationStatus::InProgress,
ctx,
);
});
}
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
remote_logging::log_model_event( remote_logging::log_model_event(
ctx, ctx,
@@ -6471,6 +6556,16 @@ impl BlocklistAIController {
), ),
); );
} }
if should_refresh_status {
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
history.update_conversation_status(
self.terminal_surface_id,
conversation_id,
ConversationStatus::InProgress,
ctx,
);
});
}
let _ = acknowledgement.send(result); let _ = acknowledgement.send(result);
} }
ProviderDriveMessage::Checkpoint { ProviderDriveMessage::Checkpoint {
+6
View File
@@ -468,6 +468,7 @@ fn queued_provider_follow_up_persists_while_active_slot_is_unprepared() {
pending_command_completion: None, pending_command_completion: None,
monitor_prose_continuations: 0, monitor_prose_continuations: 0,
retry_status: None, retry_status: None,
tool_call_progress: None,
}, },
); );
controller controller
@@ -494,6 +495,7 @@ fn queued_provider_follow_up_persists_while_active_slot_is_unprepared() {
pending_command_completion: None, pending_command_completion: None,
monitor_prose_continuations: 0, monitor_prose_continuations: 0,
retry_status: None, retry_status: None,
tool_call_progress: None,
}, },
base_provider_config: crate::ai::provider::ProviderConfig::None, base_provider_config: crate::ai::provider::ProviderConfig::None,
cli_provider_config: crate::ai::provider::ProviderConfig::None, cli_provider_config: crate::ai::provider::ProviderConfig::None,
@@ -1044,6 +1046,7 @@ fn cancelling_provider_startup_keeps_slot_and_durable_cancellation() {
pending_command_completion: None, pending_command_completion: None,
monitor_prose_continuations: 0, monitor_prose_continuations: 0,
retry_status: None, retry_status: None,
tool_call_progress: None,
}, },
); );
@@ -1143,6 +1146,7 @@ fn same_conversation_follow_up_waits_for_cancelled_provider_generation_cleanup()
pending_command_completion: None, pending_command_completion: None,
monitor_prose_continuations: 0, monitor_prose_continuations: 0,
retry_status: None, retry_status: None,
tool_call_progress: None,
}, },
); );
controller controller
@@ -1175,6 +1179,7 @@ fn same_conversation_follow_up_waits_for_cancelled_provider_generation_cleanup()
pending_command_completion: None, pending_command_completion: None,
monitor_prose_continuations: 0, monitor_prose_continuations: 0,
retry_status: None, retry_status: None,
tool_call_progress: None,
}, },
base_provider_config: crate::ai::provider::ProviderConfig::None, base_provider_config: crate::ai::provider::ProviderConfig::None,
cli_provider_config: crate::ai::provider::ProviderConfig::None, cli_provider_config: crate::ai::provider::ProviderConfig::None,
@@ -1285,6 +1290,7 @@ fn non_follow_up_provider_cancellation_does_not_admit_an_overlapping_generation(
pending_command_completion: None, pending_command_completion: None,
monitor_prose_continuations: 0, monitor_prose_continuations: 0,
retry_status: None, retry_status: None,
tool_call_progress: None,
}, },
); );
@@ -341,9 +341,9 @@ pub struct RequestedCommandView {
autoexecute_readonly_commands_speedbump_checkbox_handle: MouseStateHandle, autoexecute_readonly_commands_speedbump_checkbox_handle: MouseStateHandle,
manage_autonomy_settings_link_handle: MouseStateHandle, manage_autonomy_settings_link_handle: MouseStateHandle,
// Selection support for MCP tool call detail text // Selection support for scrollable command output and MCP tool-call detail text.
mcp_content_selection_handle: SelectionHandle, content_selection_handle: SelectionHandle,
mcp_content_selected_text: Arc<std::sync::RwLock<Option<String>>>, content_selected_text: Arc<std::sync::RwLock<Option<String>>>,
// Structured request data and per-tree expansion state for JSON tree rendering. // Structured request data and per-tree expansion state for JSON tree rendering.
// `mcp_request` is populated from the stream as soon as the tool name // `mcp_request` is populated from the stream as soon as the tool name
@@ -598,8 +598,8 @@ impl RequestedCommandView {
position_id_prefix, position_id_prefix,
terminal_model, terminal_model,
ai_block_view_id, ai_block_view_id,
mcp_content_selection_handle: SelectionHandle::default(), content_selection_handle: SelectionHandle::default(),
mcp_content_selected_text: Arc::new(std::sync::RwLock::new(None)), content_selected_text: Arc::new(std::sync::RwLock::new(None)),
mcp_request: None, mcp_request: None,
mcp_request_tree_state: Default::default(), mcp_request_tree_state: Default::default(),
mcp_response_tree_state: Default::default(), mcp_response_tree_state: Default::default(),
@@ -1092,10 +1092,10 @@ impl RequestedCommandView {
/// Returns the currently selected text. /// Returns the currently selected text.
pub fn selected_text(&self, ctx: &AppContext) -> Option<String> { pub fn selected_text(&self, ctx: &AppContext) -> Option<String> {
// Check MCP content selection first, then fall back to editor selection. // Check rendered pane content first, then fall back to editor selection.
if let Ok(mcp_selection) = self.mcp_content_selected_text.read() { if let Ok(content_selection) = self.content_selected_text.read() {
if mcp_selection.is_some() { if content_selection.is_some() {
return mcp_selection.clone(); return content_selection.clone();
} }
} }
self.editor self.editor
@@ -1104,11 +1104,11 @@ impl RequestedCommandView {
} }
pub fn clear_selection(&mut self, ctx: &mut ViewContext<Self>) { pub fn clear_selection(&mut self, ctx: &mut ViewContext<Self>) {
// Clear MCP content selection if it exists, else fall back to editor selection. self.content_selection_handle.clear();
self.mcp_content_selection_handle.clear(); if let Ok(mut content_selection) = self.content_selected_text.write() {
if let Ok(mut mcp_selection) = self.mcp_content_selected_text.write() { *content_selection = None;
*mcp_selection = None; }
} else if let Some(editor) = &self.editor { if let Some(editor) = &self.editor {
editor.update(ctx, |editor, ctx| { editor.update(ctx, |editor, ctx| {
editor.clear_selection(ctx); editor.clear_selection(ctx);
}); });
@@ -1630,12 +1630,12 @@ impl View for RequestedCommandView {
.with_selectable(true) .with_selectable(true)
.finish(); .finish();
let mcp_selected_text = self.mcp_content_selected_text.clone(); let content_selected_text = self.content_selected_text.clone();
let selectable_text = SelectableArea::new( let selectable_text = SelectableArea::new(
self.mcp_content_selection_handle.clone(), self.content_selection_handle.clone(),
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
move |selection_args, _, _| { move |selection_args, _, _| {
*mcp_selected_text.write().unwrap() = selection_args.selection; *content_selected_text.write().unwrap() = selection_args.selection;
}, },
text_element, text_element,
) )
@@ -1661,10 +1661,23 @@ impl View for RequestedCommandView {
app, app,
) )
.finish(); .finish();
let content_selected_text = self.content_selected_text.clone();
let selectable_output = SelectableArea::new(
self.content_selection_handle.clone(),
#[allow(clippy::unwrap_used)]
move |selection_args, _, _| {
*content_selected_text.write().unwrap() = selection_args.selection;
},
output_text,
)
.on_selection_updated(|ctx, _| {
ctx.dispatch_typed_action(RequestedCommandViewAction::SelectText);
})
.finish();
let scrollable = NewScrollable::vertical( let scrollable = NewScrollable::vertical(
SingleAxisConfig::Clipped { SingleAxisConfig::Clipped {
handle: self.output_scroll_state.clone(), handle: self.output_scroll_state.clone(),
child: output_text, child: selectable_output,
}, },
Fill::None, Fill::None,
Fill::None, Fill::None,
+2
View File
@@ -46,6 +46,8 @@ pub(crate) mod harness_display;
pub(crate) mod llms; pub(crate) mod llms;
pub(crate) mod local_harness_setup; pub(crate) mod local_harness_setup;
pub(crate) mod metadata_project_rules; pub(crate) mod metadata_project_rules;
#[cfg(not(target_family = "wasm"))]
pub(crate) mod model_output_logging;
pub mod onboarding; pub mod onboarding;
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
pub mod openai; pub mod openai;
+20
View File
@@ -0,0 +1,20 @@
//! Model response diagnostics for the opt-in full-session log.
//!
//! Model output can contain source code, file contents, reasoning, and tool arguments. Keep these
//! records behind the local filesystem logging setting rather than relying only on log levels,
//! which can also be changed through `RUST_LOG`.
const MODEL_OUTPUT_LOG_TARGET: &str = "galaxy::ai::model_output";
pub(crate) fn log(record: serde_json::Value) {
if !galaxy_logging::session_log_file_path().is_ok_and(|path| path.is_some()) {
return;
}
match serde_json::to_string(&record) {
Ok(record) => log::info!(target: MODEL_OUTPUT_LOG_TARGET, "{record}"),
Err(error) => {
log::warn!(target: MODEL_OUTPUT_LOG_TARGET, "Failed to serialize model output: {error}")
}
}
}
+1 -1
View File
@@ -163,7 +163,7 @@ impl RuntimeResponseTranslator {
let mut events = Vec::new(); let mut events = Vec::new();
match event { match event {
AgentEvent::TurnStarted { .. } => self.initialize(&mut events), AgentEvent::TurnStarted { .. } => self.initialize(&mut events),
AgentEvent::KeepAlive => {} AgentEvent::KeepAlive | AgentEvent::ToolCallProgress { .. } => {}
AgentEvent::TextDelta { text } => { AgentEvent::TextDelta { text } => {
self.initialize(&mut events); self.initialize(&mut events);
self.add_or_append_text(&text, &mut events); self.add_or_append_text(&text, &mut events);
@@ -18,6 +18,8 @@ use instant::Instant;
use warpui::r#async::Timer; use warpui::r#async::Timer;
use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent::conversation::AIConversationId;
#[cfg(not(target_family = "wasm"))]
use crate::ai::model_output_logging;
pub(crate) const BASE_PROVIDER_PROFILE: &str = "base"; pub(crate) const BASE_PROVIDER_PROFILE: &str = "base";
pub(crate) const CLI_MONITOR_PROVIDER_PROFILE: &str = "cli-monitor"; pub(crate) const CLI_MONITOR_PROVIDER_PROFILE: &str = "cli-monitor";
@@ -597,6 +599,18 @@ impl ProviderRunCoordinator {
return Ok(()); return Ok(());
} }
}; };
#[cfg(not(target_family = "wasm"))]
model_output_logging::log(serde_json::json!({
"event": "model_output",
"source": "direct_provider",
"provider_run_id": call.work_id.run_id.as_str(),
"provider_epoch": call.work_id.epoch.get(),
"profile": call.profile.as_str(),
"runtime_id": runtime_id,
"model_id": model_id,
"retry_attempt": call.retry_attempt,
"payload": &event,
}));
match event { match event {
AgentEvent::TurnStarted { runtime_request_id } => { AgentEvent::TurnStarted { runtime_request_id } => {
if buffer.started { if buffer.started {
@@ -641,6 +655,36 @@ impl ProviderRunCoordinator {
} }
} }
AgentEvent::KeepAlive => {} AgentEvent::KeepAlive => {}
AgentEvent::ToolCallProgress {
call_id,
name,
arguments_bytes,
} => {
if !self
.ensure_model_started_acknowledged(
&call, &profile, started_at, &buffer, project,
)
.await?
{
return Ok(());
}
if !self
.project_or_fail_acknowledged(
ProviderRunProjection::ModelEvent {
work_id: call.work_id.clone(),
event: AgentEvent::ToolCallProgress {
call_id,
name,
arguments_bytes,
},
},
project,
)
.await?
{
return Ok(());
}
}
AgentEvent::TextDelta { text } => { AgentEvent::TextDelta { text } => {
if !self if !self
.ensure_model_started_acknowledged( .ensure_model_started_acknowledged(
@@ -860,6 +904,18 @@ impl ProviderRunCoordinator {
where where
F: FnMut(ProviderRunProjection) -> BoxFuture<'static, Result<(), String>>, F: FnMut(ProviderRunProjection) -> BoxFuture<'static, Result<(), String>>,
{ {
#[cfg(not(target_family = "wasm"))]
model_output_logging::log(serde_json::json!({
"event": "model_error",
"source": "direct_provider",
"provider_run_id": call.work_id.run_id.as_str(),
"provider_epoch": call.work_id.epoch.get(),
"profile": call.profile.as_str(),
"runtime_id": profile.runtime.descriptor().id,
"model_id": profile.request.model.as_str(),
"retry_attempt": call.retry_attempt,
"payload": &error,
}));
let error_message = error.message.clone(); let error_message = error.message.clone();
let disposition = self let disposition = self
.run .run
+10 -5
View File
@@ -3,9 +3,10 @@ use std::sync::Arc;
use galaxy_agent_core::{AgentRuntime, ToolCall, TurnRequest}; use galaxy_agent_core::{AgentRuntime, ToolCall, TurnRequest};
use galaxy_agent_rig::{ use galaxy_agent_rig::{
AnthropicRuntime, AnthropicRuntimeConfig, ChatGPTSubscriptionRuntime, AnthropicRuntime, AnthropicRuntimeConfig, BedrockRigConfig, BedrockRuntime,
ChatGPTSubscriptionRuntimeConfig, GeminiRuntime, GeminiRuntimeConfig, OpenAICompatibleRuntime, ChatGPTSubscriptionRuntime, ChatGPTSubscriptionRuntimeConfig, GeminiRuntime,
OpenAICompatibleRuntimeConfig, VertexAiRuntime, VertexAiRuntimeConfig, GeminiRuntimeConfig, OpenAICompatibleRuntime, OpenAICompatibleRuntimeConfig, VertexAiRuntime,
VertexAiRuntimeConfig,
}; };
use uuid::Uuid; use uuid::Uuid;
use warp_multi_agent_api::ToolType; use warp_multi_agent_api::ToolType;
@@ -254,11 +255,15 @@ pub(crate) async fn provider_runtime_for_request(
let caching_config = let caching_config =
CachingConfig::from_external_config(&ExternalBedrockConfig::load()); CachingConfig::from_external_config(&ExternalBedrockConfig::load());
let client = BedrockClient::from_config(config).await?; let client = BedrockClient::from_config(config).await?;
Arc::new(client.agent_runtime( Arc::new(BedrockRuntime::from_aws_client(
client.runtime_client(),
BedrockRigConfig {
model, model,
region: client.region().to_string(),
cross_region_inference, cross_region_inference,
prompt_caching: caching_config.enabled,
max_output_tokens, max_output_tokens,
caching_config, },
)?) )?)
} }
crate::ai::provider::ProviderConfig::None => { crate::ai::provider::ProviderConfig::None => {
+3
View File
@@ -646,6 +646,9 @@ fn build_system_prompt(
let mut prompt = String::from( let mut prompt = String::from(
"You are Galaxy, a local-first software-engineering and terminal agent. Complete the user's task through inspection, implementation, and proportionate validation. Galaxy owns tool permissions and execution; use only the tools advertised in this request and treat every result as authoritative evidence.\n\n", "You are Galaxy, a local-first software-engineering and terminal agent. Complete the user's task through inspection, implementation, and proportionate validation. Galaxy owns tool permissions and execution; use only the tools advertised in this request and treat every result as authoritative evidence.\n\n",
); );
prompt.push_str(
"## Communication Style\nSpeak naturally, warmly, and directly, like a thoughtful collaborator working alongside the user. Default to short responses and expand only when complexity or the user's request warrants it. For nontrivial work, briefly tell the user what you are checking before the first tool call. Between dependent tool calls, add a concise update only when a result materially changes what you learned or what you will do next; ground it in specifics instead of generic activity narration. Do not narrate every routine read, repeat the plan, or end a turn with only a progress update when useful work can continue. In the final response, lead with the outcome and keep the handoff compact.\n\n",
);
prompt.push_str( prompt.push_str(
"## Execution Contract\nContinue until the user's requested outcome is complete and validated. Do not stop at an intermediate analysis, plan, status update, or promise of future work, and do not ask the user to say \"continue\". After each tool result, choose and perform the next necessary step. Stop only when the request is fulfilled or a concrete blocker requires user input; identify that blocker explicitly.\n\n", "## Execution Contract\nContinue until the user's requested outcome is complete and validated. Do not stop at an intermediate analysis, plan, status update, or promise of future work, and do not ask the user to say \"continue\". After each tool result, choose and perform the next necessary step. Stop only when the request is fulfilled or a concrete blocker requires user input; identify that blocker explicitly.\n\n",
); );
+24 -22
View File
@@ -915,6 +915,24 @@ fn run_internal(mut launch_mode: LaunchMode) -> Result<()> {
); );
let _enter = span.enter(); let _enter = span.enter();
// Load the TOML-backed preferences before logging so the opt-in session log can capture the
// complete application startup. The same backends are registered with AppContext below.
let private_preferences = settings::init_private_user_preferences();
let (public_preferences, startup_toml_parse_error) = settings::init_public_user_preferences();
let session_logs_enabled = FeatureFlag::SettingsFile.is_enabled()
&& crate::settings::SessionLogsEnabled::read_from_preferences(public_preferences.as_ref())
.unwrap_or_default();
// When the SettingsFile feature flag is enabled, public settings live in
// the TOML-backed store. When disabled, they live in the platform-native
// store (same backend as private). Use the correct one for pre-app reads.
let prefs_for_public_settings: &dyn galaxyui_extras::user_preferences::UserPreferences =
if FeatureFlag::SettingsFile.is_enabled() {
public_preferences.as_ref()
} else {
private_preferences.deref()
};
let log_destination = launch_mode.log_destination(); let log_destination = launch_mode.log_destination();
let is_cli = log_destination.is_some(); let is_cli = log_destination.is_some();
@@ -926,6 +944,7 @@ fn run_internal(mut launch_mode: LaunchMode) -> Result<()> {
galaxy_logging::init(galaxy_logging::LogConfig { galaxy_logging::init(galaxy_logging::LogConfig {
is_cli, is_cli,
log_destination, log_destination,
session_logs_enabled,
..Default::default() ..Default::default()
})?; })?;
} }
@@ -933,6 +952,7 @@ fn run_internal(mut launch_mode: LaunchMode) -> Result<()> {
galaxy_logging::init(galaxy_logging::LogConfig { galaxy_logging::init(galaxy_logging::LogConfig {
is_cli, is_cli,
log_destination, log_destination,
session_logs_enabled,
..Default::default() ..Default::default()
})?; })?;
} }
@@ -943,6 +963,10 @@ fn run_internal(mut launch_mode: LaunchMode) -> Result<()> {
} }
timer.mark_interval_end("LOG_FILE_SETUP_COMPLETE"); timer.mark_interval_end("LOG_FILE_SETUP_COMPLETE");
if let Some(error) = &startup_toml_parse_error {
log::warn!("Settings file has syntax errors and could not be parsed: {error}");
}
#[cfg(windows)] #[cfg(windows)]
platform::windows::check_redirection_guard(); platform::windows::check_redirection_guard();
@@ -1015,28 +1039,6 @@ fn run_internal(mut launch_mode: LaunchMode) -> Result<()> {
#[cfg(windows)] #[cfg(windows)]
command::windows::init(); command::windows::init();
let private_preferences = settings::init_private_user_preferences();
let (public_preferences, startup_toml_parse_error) = settings::init_public_user_preferences();
// When the SettingsFile feature flag is enabled, public settings live in
// the TOML-backed store. When disabled, they live in the platform-native
// store (same backend as private). Use the correct one for pre-app reads.
#[cfg_attr(
not(any(
enable_crash_recovery,
target_os = "linux",
target_os = "freebsd",
target_os = "macos"
)),
expect(unused)
)]
let prefs_for_public_settings: &dyn galaxyui_extras::user_preferences::UserPreferences =
if FeatureFlag::SettingsFile.is_enabled() {
public_preferences.as_ref()
} else {
private_preferences.deref()
};
#[cfg(enable_crash_recovery)] #[cfg(enable_crash_recovery)]
let crash_recovery = let crash_recovery =
crash_recovery::CrashRecovery::new(&launch_mode, prefs_for_public_settings); crash_recovery::CrashRecovery::new(&launch_mode, prefs_for_public_settings);
+9
View File
@@ -55,6 +55,15 @@ define_settings_group!(DebugSettings, settings: [
supported_platforms: SupportedPlatforms::ALL, supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Never, sync_to_cloud: SyncToCloud::Never,
private: true, private: true,
},
session_logs_enabled: SessionLogsEnabled {
type: bool,
default: false,
supported_platforms: SupportedPlatforms::ALL,
sync_to_cloud: SyncToCloud::Never,
private: false,
toml_path: "logging.session_logs_enabled",
description: "Writes full per-session logs, including model response text, reasoning, and tool-call JSON, to ~/.galaxy/session-logs after Galaxy restarts.",
} }
]); ]);
+8
View File
@@ -277,6 +277,14 @@ pub enum AgentEvent {
/// A transport heartbeat proving that the current model stream is still connected. /// A transport heartbeat proving that the current model stream is still connected.
/// Consumers should use this to refresh idle timeouts without rendering output. /// Consumers should use this to refresh idle timeouts without rendering output.
KeepAlive, KeepAlive,
/// Progress received while the provider is streaming a tool call's JSON arguments.
/// The arguments remain buffered by the provider runtime and are not executed until the
/// complete tool call has been parsed and emitted as [`ToolEvent::Proposed`].
ToolCallProgress {
call_id: String,
name: Option<String>,
arguments_bytes: u64,
},
TextDelta { TextDelta {
text: String, text: String,
}, },
+1
View File
@@ -117,6 +117,7 @@ impl ChatGPTSubscriptionRuntime {
| AgentEvent::ReasoningCompleted { .. } | AgentEvent::ReasoningCompleted { .. }
| AgentEvent::TurnStarted { .. } | AgentEvent::TurnStarted { .. }
| AgentEvent::KeepAlive | AgentEvent::KeepAlive
| AgentEvent::ToolCallProgress { .. }
| AgentEvent::UsageUpdated { .. } | AgentEvent::UsageUpdated { .. }
| AgentEvent::RuntimeActivityUpdated { .. } | AgentEvent::RuntimeActivityUpdated { .. }
| AgentEvent::ContextUsageUpdated { .. } | AgentEvent::ContextUsageUpdated { .. }
+44 -5
View File
@@ -1,10 +1,12 @@
use std::collections::HashMap;
use futures::{FutureExt, StreamExt}; use futures::{FutureExt, StreamExt};
use galaxy_agent_core::{ use galaxy_agent_core::{
AgentError, AgentErrorKind, AgentEvent, AgentEventStream, StopReason, ToolCall, TurnCommand, AgentError, AgentErrorKind, AgentEvent, AgentEventStream, StopReason, ToolCall, TurnCommand,
TurnControl, Usage, TurnControl, Usage,
}; };
use rig_core::completion::{CompletionError, CompletionModel, CompletionRequest}; use rig_core::completion::{CompletionError, CompletionModel, CompletionRequest};
use rig_core::streaming::StreamedAssistantContent; use rig_core::streaming::{StreamedAssistantContent, ToolCallDeltaContent};
use uuid::Uuid; use uuid::Uuid;
pub(crate) async fn start_model_turn<M>( pub(crate) async fn start_model_turn<M>(
@@ -48,6 +50,7 @@ where
let mut control_open = true; let mut control_open = true;
let mut last_output_tokens = 0; let mut last_output_tokens = 0;
let mut tool_call_progress = HashMap::<String, ToolCallProgressState>::new();
loop { loop {
let next_item = rig_stream.next().fuse(); let next_item = rig_stream.next().fuse();
let next_command = if control_open { let next_command = if control_open {
@@ -106,16 +109,45 @@ where
yield Ok(AgentEvent::ReasoningDelta { text: reasoning }); yield Ok(AgentEvent::ReasoningDelta { text: reasoning });
} }
} }
Ok(StreamedAssistantContent::ToolCall { tool_call, .. }) => { Ok(StreamedAssistantContent::ToolCall {
tool_call,
internal_call_id,
}) => {
tool_call_progress.remove(&internal_call_id);
yield Ok(AgentEvent::Tool { yield Ok(AgentEvent::Tool {
event: galaxy_agent_core::ToolEvent::Proposed { event: galaxy_agent_core::ToolEvent::Proposed {
call: domain_tool_call(tool_call), call: domain_tool_call(tool_call),
}, },
}); });
} }
Ok(StreamedAssistantContent::ToolCallDelta { .. }) => { Ok(StreamedAssistantContent::ToolCallDelta {
// Rig emits a complete ToolCall after its deltas, which id,
// is the canonical event Galaxy consumes. internal_call_id,
content,
}) => {
let progress = tool_call_progress
.entry(internal_call_id.clone())
.or_insert_with(|| ToolCallProgressState {
call_id: if id.is_empty() {
internal_call_id
} else {
id
},
..ToolCallProgressState::default()
});
match content {
ToolCallDeltaContent::Name(name) => progress.name = Some(name),
ToolCallDeltaContent::Delta(arguments) => {
progress.arguments_bytes = progress
.arguments_bytes
.saturating_add(arguments.len() as u64);
}
}
yield Ok(AgentEvent::ToolCallProgress {
call_id: progress.call_id.clone(),
name: progress.name.clone(),
arguments_bytes: progress.arguments_bytes,
});
} }
Ok(StreamedAssistantContent::Final(response)) => { Ok(StreamedAssistantContent::Final(response)) => {
let mapped_usage = map_usage(response.usage); let mapped_usage = map_usage(response.usage);
@@ -152,6 +184,13 @@ where
Ok(Box::pin(events)) Ok(Box::pin(events))
} }
#[derive(Default)]
struct ToolCallProgressState {
call_id: String,
name: Option<String>,
arguments_bytes: u64,
}
pub(crate) async fn start_model_completion<M>( pub(crate) async fn start_model_completion<M>(
model: M, model: M,
completion_request: CompletionRequest, completion_request: CompletionRequest,
+1
View File
@@ -12,6 +12,7 @@ cfg-if.workspace = true
env_logger.workspace = true env_logger.workspace = true
log.workspace = true log.workspace = true
galaxy_core.workspace = true galaxy_core.workspace = true
uuid.workspace = true
[target.'cfg(not(target_family = "wasm"))'.dependencies] [target.'cfg(not(target_family = "wasm"))'.dependencies]
dirs.workspace = true dirs.workspace = true
+6 -1
View File
@@ -22,6 +22,9 @@ pub struct LogConfig {
/// `rotate_log_files` behavior. `None` preserves the existing unbounded-within-session /// `rotate_log_files` behavior. `None` preserves the existing unbounded-within-session
/// growth (warpdotdev/warp#10879). /// growth (warpdotdev/warp#10879).
pub max_file_size_bytes: Option<u64>, pub max_file_size_bytes: Option<u64>,
/// Whether to retain an unrotated, full-detail copy of this process's logs in
/// `~/.galaxy/session-logs/<session-id>_<datetime>.log`.
pub session_logs_enabled: bool,
} }
#[cfg_attr(not(target_family = "wasm"), path = "native.rs")] #[cfg_attr(not(target_family = "wasm"), path = "native.rs")]
@@ -33,7 +36,9 @@ mod rotation;
pub use imp::init; pub use imp::init;
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
pub use imp::{create_log_bundle_zip, log_directory, log_file_path, rotate_log_files}; pub use imp::{
create_log_bundle_zip, log_directory, log_file_path, rotate_log_files, session_log_file_path,
};
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
pub use imp::{ pub use imp::{
init_for_crash_recovery_process, init_logging_for_unit_tests, on_crash_recovery_process_killed, init_for_crash_recovery_process, init_logging_for_unit_tests, on_crash_recovery_process_killed,
+114 -3
View File
@@ -1,6 +1,8 @@
use std::env; use std::env;
use std::fs::{self, File}; use std::fs::{self, File};
use std::io::{IsTerminal, Write, copy}; use std::io::{IsTerminal, Write, copy};
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::OnceLock; use std::sync::OnceLock;
@@ -17,7 +19,11 @@ use crate::{LogConfig, LogDestination};
const MAX_FILES_IN_GUI_ROTATION: usize = 5; const MAX_FILES_IN_GUI_ROTATION: usize = 5;
const MAX_FILES_IN_CLI_ROTATION: usize = 10; const MAX_FILES_IN_CLI_ROTATION: usize = 10;
const CLI_LOG_SUBDIRECTORY: &str = "oz"; const CLI_LOG_SUBDIRECTORY: &str = "oz";
const SESSION_LOG_SUBDIRECTORY: &str = "session-logs";
const TEMP_LOG_FILE_SUFFIX: &str = "old.temp"; const TEMP_LOG_FILE_SUFFIX: &str = "old.temp";
const INPUT_CLASSIFIER_LOG_TARGET: &str = "input_classifier";
const TERMINAL_ANSI_HANDLER_LOG_TARGET: &str =
"galaxy::terminal::model::grid::grid_handler::ansi_handler";
/// Runtime logging state, computed from `LogConfig` during initialization. /// Runtime logging state, computed from `LogConfig` during initialization.
#[derive(Debug)] #[derive(Debug)]
@@ -31,10 +37,31 @@ struct LogState {
/// The maximum number of backup log files to keep during rotation. /// The maximum number of backup log files to keep during rotation.
max_rotation: usize, max_rotation: usize,
/// The opt-in full-session log for this process, if enabled successfully.
session_log_path: Option<PathBuf>,
} }
static LOG_STATE: OnceLock<LogState> = OnceLock::new(); static LOG_STATE: OnceLock<LogState> = OnceLock::new();
struct TeeWriter<Primary, Session> {
primary: Primary,
session: Session,
}
impl<Primary: Write, Session: Write> Write for TeeWriter<Primary, Session> {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.primary.write_all(buf)?;
self.session.write_all(buf)?;
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
self.primary.flush()?;
self.session.flush()
}
}
/// Formats a log record to be output to the terminal. /// Formats a log record to be output to the terminal.
fn format_for_terminal_output( fn format_for_terminal_output(
buf: &mut env_logger::fmt::Formatter, buf: &mut env_logger::fmt::Formatter,
@@ -283,6 +310,7 @@ pub fn init_for_crash_recovery_process() -> Result<()> {
false, /* is_cli */ false, /* is_cli */
None, /* log_destination */ None, /* log_destination */
None, /* max_file_size_bytes — crash recovery uses its own short-lived log */ None, /* max_file_size_bytes — crash recovery uses its own short-lived log */
false, /* session_logs_enabled */
) )
} }
@@ -296,6 +324,7 @@ pub fn init(config: LogConfig) -> Result<()> {
config.is_cli, config.is_cli,
config.log_destination, config.log_destination,
config.max_file_size_bytes, config.max_file_size_bytes,
config.session_logs_enabled,
) )
} }
@@ -475,11 +504,42 @@ fn temp_log_file_path(log_directory: impl AsRef<Path>) -> PathBuf {
.join(format!("{channel_logfile_name}.{TEMP_LOG_FILE_SUFFIX}")) .join(format!("{channel_logfile_name}.{TEMP_LOG_FILE_SUFFIX}"))
} }
fn session_log_path(home_directory: &Path, session_id: &str, datetime: &str) -> PathBuf {
home_directory
.join(galaxy_core::paths::WARP_CONFIG_DIR)
.join(SESSION_LOG_SUBDIRECTORY)
.join(format!("{session_id}_{datetime}.log"))
}
fn create_session_log_file() -> Result<(File, PathBuf)> {
let home_directory = dirs::home_dir().ok_or_else(|| {
anyhow::anyhow!("could not locate home directory in order to create a session log")
})?;
let session_id = uuid::Uuid::new_v4().simple().to_string();
let datetime = Local::now().format("%Y%m%d_%H%M%S").to_string();
let path = session_log_path(&home_directory, &session_id, &datetime);
let directory = path
.parent()
.ok_or_else(|| anyhow::anyhow!("session log path did not have a parent directory"))?;
fs::create_dir_all(directory)?;
#[cfg(unix)]
fs::set_permissions(directory, fs::Permissions::from_mode(0o700))?;
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(0o600);
let file = options.open(&path)?;
Ok((file, path))
}
fn init_internal( fn init_internal(
is_from_crash_recovery_process: bool, is_from_crash_recovery_process: bool,
is_cli: bool, is_cli: bool,
log_destination: Option<LogDestination>, log_destination: Option<LogDestination>,
max_file_size_bytes: Option<u64>, max_file_size_bytes: Option<u64>,
session_logs_enabled: bool,
) -> Result<()> { ) -> Result<()> {
/// Returns an empty file named `warp.log` to log the current execution, and /// Returns an empty file named `warp.log` to log the current execution, and
/// renames the previous execution's log to a temporary name. /// renames the previous execution's log to a temporary name.
@@ -513,7 +573,11 @@ fn init_internal(
let mut base_logger = env_logger::builder(); let mut base_logger = env_logger::builder();
base_logger.filter_level(LevelFilter::Warn); base_logger.filter_level(if session_logs_enabled {
LevelFilter::Trace
} else {
LevelFilter::Warn
});
// Only include `WARN` or higher logs for wgpu. By default, wgpu outputs logs at the `INFO` // Only include `WARN` or higher logs for wgpu. By default, wgpu outputs logs at the `INFO`
// level multiple times _per_ frame. See https://github.com/gfx-rs/wgpu/issues/3206. // level multiple times _per_ frame. See https://github.com/gfx-rs/wgpu/issues/3206.
@@ -521,6 +585,14 @@ fn init_internal(
base_logger base_logger
.filter(Some("naga"), LevelFilter::Warn) .filter(Some("naga"), LevelFilter::Warn)
.filter(Some("wgpu_core"), LevelFilter::Warn) .filter(Some("wgpu_core"), LevelFilter::Warn)
// ANSI rendering emits trace records for every character and terminal control sequence.
// Preserve meaningful info/warnings/errors without filling full-session logs with typing,
// carriage-return, linefeed, cursor-motion, and scrolling noise.
.filter(Some(TERMINAL_ANSI_HANDLER_LOG_TARGET), LevelFilter::Info)
// The input classifier runs as the buffer changes and includes partial user input in its
// info/debug records. Keep initialization failures and classification errors, but omit the
// noisy pre-submission decision trail from full-session logs.
.filter(Some(INPUT_CLASSIFIER_LOG_TARGET), LevelFilter::Warn)
// Since we always pair an insertion with a deletion to avoid duplicate, // Since we always pair an insertion with a deletion to avoid duplicate,
// tantivy will log a lot of warnings for deleting a non-existing doc. // tantivy will log a lot of warnings for deleting a non-existing doc.
.filter(Some("tantivy"), LevelFilter::Error) .filter(Some("tantivy"), LevelFilter::Error)
@@ -554,6 +626,15 @@ fn init_internal(
if is_cli { if is_cli {
log_directory = log_directory.join(CLI_LOG_SUBDIRECTORY); log_directory = log_directory.join(CLI_LOG_SUBDIRECTORY);
} }
let (session_log_file, session_log_path, session_log_error) = if session_logs_enabled {
match create_session_log_file() {
Ok((file, path)) => (Some(file), Some(path), None),
Err(error) => (None, None, Some(error)),
}
} else {
(None, None, None)
};
if use_logfile { if use_logfile {
let file = let file =
setup_log_files_for_current_execution(&log_directory, is_from_crash_recovery_process)?; setup_log_files_for_current_execution(&log_directory, is_from_crash_recovery_process)?;
@@ -561,7 +642,7 @@ fn init_internal(
// by the parent on crash, and otherwise deleted on clean exit), so // by the parent on crash, and otherwise deleted on clean exit), so
// skip in-session rotation for them — `max_file_size_bytes` only // skip in-session rotation for them — `max_file_size_bytes` only
// applies to the main process's `warp.log`. // applies to the main process's `warp.log`.
let target: Box<dyn std::io::Write + Send + 'static> = if is_from_crash_recovery_process { let primary: Box<dyn std::io::Write + Send + 'static> = if is_from_crash_recovery_process {
Box::new(file) Box::new(file)
} else { } else {
crate::rotation::wrap_for_rotation( crate::rotation::wrap_for_rotation(
@@ -572,8 +653,20 @@ fn init_internal(
max_rotation, max_rotation,
)? )?
}; };
let target: Box<dyn std::io::Write + Send + 'static> = match session_log_file {
Some(session) => Box::new(TeeWriter { primary, session }),
None => primary,
};
base_logger.target(env_logger::Target::Pipe(target)); base_logger.target(env_logger::Target::Pipe(target));
base_logger.format(format_for_file_output); base_logger.format(format_for_file_output);
} else if let Some(session) = session_log_file {
let target = TeeWriter {
primary: std::io::stderr(),
session,
};
base_logger.target(env_logger::Target::Pipe(Box::new(target)));
base_logger.write_style(env_logger::WriteStyle::Never);
base_logger.format(format_for_file_output);
} else { } else {
// Agent mode eval outputs are written to stdout but redirected to a file, so we don't want terminal styling. // Agent mode eval outputs are written to stdout but redirected to a file, so we don't want terminal styling.
if cfg!(feature = "agent_mode_evals") { if cfg!(feature = "agent_mode_evals") {
@@ -588,7 +681,7 @@ fn init_internal(
// If we're logging to a file, initialize the `log_panics` crate, which // If we're logging to a file, initialize the `log_panics` crate, which
// will install a panic hook that writes out panics using `log::error`. // will install a panic hook that writes out panics using `log::error`.
if use_logfile { if use_logfile || session_log_path.is_some() {
log_panics::init(); log_panics::init();
} }
@@ -597,10 +690,20 @@ fn init_internal(
use_logfile, use_logfile,
log_directory, log_directory,
max_rotation, max_rotation,
session_log_path: session_log_path.clone(),
}) })
.expect("Logging already initialized"); .expect("Logging already initialized");
// We can .expect here because .init would have already panicked if we initialized logging twice. // We can .expect here because .init would have already panicked if we initialized logging twice.
if let Some(path) = session_log_path {
log::info!("Full session logging enabled at {}", path.display());
}
if let Some(error) = session_log_error {
log::error!(
"Full session logging was enabled but the log file could not be created: {error:#}"
);
}
Ok(()) Ok(())
} }
@@ -611,6 +714,14 @@ pub fn log_directory() -> Result<std::path::PathBuf> {
.ok_or_else(|| anyhow::anyhow!("Logging not initialized")) .ok_or_else(|| anyhow::anyhow!("Logging not initialized"))
} }
/// Returns the opt-in full-session log path for this process, when enabled and created.
pub fn session_log_file_path() -> Result<Option<PathBuf>> {
LOG_STATE
.get()
.map(|config| config.session_log_path.clone())
.ok_or_else(|| anyhow::anyhow!("Logging not initialized"))
}
fn init_log_directory() -> Result<std::path::PathBuf> { fn init_log_directory() -> Result<std::path::PathBuf> {
cfg_if::cfg_if! { cfg_if::cfg_if! {
if #[cfg(target_os = "macos")] { if #[cfg(target_os = "macos")] {
+27
View File
@@ -1,5 +1,32 @@
use std::io::Write as _;
use super::*; use super::*;
#[test]
fn session_log_path_uses_the_galaxy_session_logs_directory() {
let path = session_log_path(Path::new("/Users/tester"), "session123", "20260822_091530");
assert_eq!(
path,
PathBuf::from("/Users/tester/.galaxy/session-logs/session123_20260822_091530.log")
);
}
#[test]
fn tee_writer_copies_each_record_to_both_destinations() {
let mut writer = TeeWriter {
primary: Vec::new(),
session: Vec::new(),
};
writer.write_all(b"first\n").unwrap();
writer.write_all(b"second\n").unwrap();
writer.flush().unwrap();
assert_eq!(writer.primary, b"first\nsecond\n");
assert_eq!(writer.session, b"first\nsecond\n");
}
fn touch(dir: &Path, name: &str) -> PathBuf { fn touch(dir: &Path, name: &str) -> PathBuf {
let path = dir.join(name); let path = dir.join(name);
File::create(&path).unwrap(); File::create(&path).unwrap();