Compare commits

..
4 Commits
Author SHA1 Message Date
Ryan Ward 3fa7f2f00c Prevent idle event waits from stalling user follow-ups 2026-11-06 21:25:17 +00:00
Ryan Ward 38f1dcdef6 Harden provider diagnostics and tool failure handling 2026-11-06 19:49:39 +00:00
Ryan Ward 4b968f34f8 Add app-wide AI activity inspector 2026-11-04 22:18:31 +00:00
Ryan Ward a5eda544c4 Keep zsh profile functions in background jobs
Load zshrc and evaluate the submitted command in the same noninteractive worker so functions, aliases and unexported shell state survive. Preserve private overrides, working directory, stdin restrictions and command exit status.
2026-11-03 18:34:26 +00:00
51 changed files with 3876 additions and 131 deletions
+13
View File
@@ -50,6 +50,13 @@ Environment variables:
- Per-event Bedrock diagnostic logs written to `bedrock-diagnostics.log` in the active Warp log directory - Per-event Bedrock diagnostic logs written to `bedrock-diagnostics.log` in the active Warp log directory
- Set `GALAXY_TOOL_DIAGNOSTICS=1` to enable verbose local tool queue/execution debug logs and cancellation backtraces. These diagnostics are disabled during routine operation. - Set `GALAXY_TOOL_DIAGNOSTICS=1` to enable verbose local tool queue/execution debug logs and cancellation backtraces. These diagnostics are disabled during routine operation.
### Local AI Activity Inspector
- Open `/ai-activity` or **Show AI Activity** in the Command Palette to start an app-wide, in-memory recording of model requests/results, compaction, tools, permission assessments, crosscheck reviews, and background-job lifecycle events.
- Each event carries its owning conversation/session where available. Filter by activity or click a session label; click the activity to inspect wrapped, selectable details. Recording continues after closing the inspector until paused, and Clear removes retained events without affecting running work.
- Capture is off until first opened, independent of remote-logging settings, and bounded to 2,000 events / 16 MiB of retained entries and 262,144 characters per capture. Truncation and eviction are explicit. Transport headers/provider credentials are excluded; user-supplied model/tool content may appear in the local capture.
- Requests are Galaxy's provider-neutral snapshots, not exact upstream HTTP payloads. ACP exposes only the traffic Galaxy exchanges with the external agent; the external agent's internal model requests and compaction are not visible. Provider token usage is marked as reported only when supplied by the runtime.
### Remote Logging: Pulling Galaxy Logs ### Remote Logging: Pulling Galaxy Logs
When investigating Galaxy behavior, retrieve recent remote logs from When investigating Galaxy behavior, retrieve recent remote logs from
@@ -87,6 +94,12 @@ zsh -lc '
conclusions about retries or repeated discovery. conclusions about retries or repeated discovery.
- Inspect `context.app.source_commit` and `source_modified` to identify the running - Inspect `context.app.source_commit` and `source_modified` to identify the running
build. Remote events may come from another machine or an older build. build. Remote events may come from another machine or an older build.
- Terminal `provider_run_finished` events include `model_failure_diagnostics`:
the last eight sanitized model failures observed in the current process, with
an observed count and explicit truncation. A recovered run can complete with
retries; inspect this evidence even if individual retry logs were lost during
a connection interruption. This diagnostic history is never model context or
a persisted checkpoint and does not cover failures before a process restart.
- `raw_model_request` events can include prompts, history, and tool data in - `raw_model_request` events can include prompts, history, and tool data in
`context.raw_payload.payload`. Check `truncated` and the accompanying size fields `context.raw_payload.payload`. Check `truncated` and the accompanying size fields
before parsing or treating missing content as evidence. These are Galaxy request before parsing or treating missing content as evidence. These are Galaxy request
+395
View File
@@ -0,0 +1,395 @@
//! Local, opt-in inspection of Galaxy's AI traffic and execution lifecycle.
//!
//! Payloads describe Galaxy's provider-neutral requests/results, not HTTP wire
//! traffic. No transport headers or provider credentials are captured here.
#[cfg(not(target_family = "wasm"))]
pub(crate) mod view;
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, Mutex};
use chrono::{DateTime, Utc};
use serde::Serialize;
use serde_json::{json, Value};
/// Limits bound both retention and the work spent serializing a single event.
pub(crate) const MAX_PAYLOAD_CHARS: usize = 256 * 1024;
const MAX_ITEMS: usize = 2_000;
const MAX_RETAINED_BYTES: usize = 16 * 1024 * 1024;
const MAX_LABELS: usize = 4_096;
static RECORDING: AtomicBool = AtomicBool::new(false);
static STORE: LazyLock<Mutex<ActivityStore>> = LazyLock::new(Mutex::default);
#[derive(Clone)]
pub(crate) struct ActivityEntry {
pub id: u64,
pub timestamp: DateTime<Utc>,
pub session_id: Option<String>,
pub session_label: String,
pub category: String,
pub event: String,
pub summary: String,
pub model: Option<String>,
pub duration_ms: Option<u64>,
pub detail: String,
}
pub(crate) struct ActivitySnapshot {
pub recording: bool,
pub revision: u64,
pub dropped: usize,
pub entries: Vec<Arc<ActivityEntry>>,
}
#[derive(Default)]
struct ActivityStore {
entries: VecDeque<Arc<ActivityEntry>>,
labels: HashMap<String, String>,
label_order: VecDeque<String>,
sessions: HashMap<String, String>,
correlation_order: VecDeque<String>,
retained_bytes: usize,
next_id: u64,
revision: u64,
dropped: usize,
recording: bool,
generation: u64,
}
pub(crate) fn is_recording() -> bool {
RECORDING.load(Ordering::Relaxed)
}
pub(crate) fn set_recording(recording: bool) {
if let Ok(mut store) = STORE.lock() {
store.set_recording(recording);
RECORDING.store(recording, Ordering::Relaxed);
}
}
/// Stops neither running work nor recording; it only clears retained diagnostics.
pub(crate) fn clear() {
if let Ok(mut store) = STORE.lock() {
store.clear();
}
}
pub(crate) fn snapshot() -> ActivitySnapshot {
let store = STORE.lock().unwrap_or_else(|error| error.into_inner());
ActivitySnapshot {
recording: store.recording,
revision: store.revision,
dropped: store.dropped,
entries: store.entries.iter().cloned().collect(),
}
}
/// Titles are bounded separately from payloads, and retained across Clear so
/// subsequent tool/background events keep their owning session's name.
pub(crate) fn register_session(session_id: impl Into<String>, label: impl Into<String>) {
if !is_recording() {
return;
}
let session_id = session_id.into();
if session_id.is_empty() || session_id.len() > 256 {
return;
}
let label = bounded_text(&label.into(), 200);
if label.trim().is_empty() {
return;
}
if let Ok(mut store) = STORE.lock() {
if store.recording {
store.register_session(session_id, label);
}
}
}
pub(crate) fn record(event: &str, summary: impl Into<String>, context: Value) {
if !is_recording() {
return;
}
record_serializable(event, summary, context, &());
}
/// Serialize by reference with early termination, without first cloning a
/// conversation or materializing an unbounded serde_json::Value.
pub(crate) fn record_serializable(
event: &str,
summary: impl Into<String>,
context: Value,
payload: &(impl Serialize + ?Sized),
) {
if !is_recording() {
return;
}
let Some(generation) = STORE
.lock()
.ok()
.and_then(|store| store.capture_generation())
else {
return;
};
let timestamp = Utc::now();
#[derive(Serialize)]
struct Capture<'a, T: Serialize + ?Sized> {
context: &'a Value,
payload: &'a T,
}
let captured =
super::remote_logging::prefix_bounded_payload_context(MAX_PAYLOAD_CHARS, |writer| {
serde_json::to_writer_pretty(
writer,
&Capture {
context: &context,
payload,
},
)
});
let mut detail = captured["payload"].as_str().unwrap_or_default().to_owned();
if captured["truncated"].as_bool() == Some(true) {
detail.push_str(
"\n\n[Capture truncated at 262,144 characters; remaining payload was not serialized.]",
);
}
if let Some(error) = captured.get("serialization_error") {
detail = format!("Capture could not be serialized: {error}");
}
let Ok(mut store) = STORE.lock() else {
return;
};
store.push_captured(
generation,
event,
summary.into(),
&context,
detail,
timestamp,
);
}
impl ActivityStore {
fn set_recording(&mut self, recording: bool) {
if self.recording != recording {
self.generation = self.generation.wrapping_add(1);
self.recording = recording;
self.revision = self.revision.wrapping_add(1);
}
}
fn clear(&mut self) {
self.entries.clear();
self.retained_bytes = 0;
self.dropped = 0;
self.generation = self.generation.wrapping_add(1);
self.revision = self.revision.wrapping_add(1);
}
fn capture_generation(&self) -> Option<u64> {
self.recording.then_some(self.generation)
}
fn register_session(&mut self, session_id: String, label: String) {
let label = format!(
"{} · {}",
bounded_text(&label, 185),
session_id.chars().take(8).collect::<String>()
);
if self.labels.get(&session_id) == Some(&label) {
return;
}
if !self.labels.contains_key(&session_id) {
if self.labels.len() >= MAX_LABELS {
if let Some(oldest) = self.label_order.pop_front() {
self.labels.remove(&oldest);
}
}
self.label_order.push_back(session_id.clone());
}
self.labels.insert(session_id.clone(), label.clone());
for entry in &mut self.entries {
if entry.session_id.as_deref() == Some(&session_id) {
self.retained_bytes -= entry_size(entry);
Arc::make_mut(entry).session_label.clone_from(&label);
self.retained_bytes += entry_size(entry);
}
}
self.evict();
self.revision = self.revision.wrapping_add(1);
}
fn push_captured(
&mut self,
generation: u64,
event: &str,
summary: String,
context: &Value,
detail: String,
timestamp: DateTime<Utc>,
) {
// Clear and pause/resume invalidate captures that started before the
// transition, even when serialization finishes after recording resumes.
if self.capture_generation() != Some(generation) {
return;
}
let mut session_id = session_id(context);
let correlation = correlation_keys(context);
if session_id.is_none() {
session_id = correlation
.iter()
.find_map(|key| self.sessions.get(key).cloned());
}
if let Some(id) = &session_id {
for key in correlation {
if !self.sessions.contains_key(&key) {
if self.sessions.len() >= MAX_LABELS {
if let Some(oldest) = self.correlation_order.pop_front() {
self.sessions.remove(&oldest);
}
}
self.correlation_order.push_back(key.clone());
}
self.sessions.insert(key, id.clone());
}
}
let session_label = session_id.as_ref().map_or_else(
|| "App background activity".to_owned(),
|id| {
self.labels.get(id).cloned().unwrap_or_else(|| {
format!("Session {}", id.chars().take(8).collect::<String>())
})
},
);
let entry = Arc::new(ActivityEntry {
id: self.next_id,
timestamp,
session_id,
session_label,
category: category(event).to_owned(),
event: bounded_text(event, 160),
summary: bounded_text(&summary, 400),
model: context
.get("model_id")
.or_else(|| context.get("model"))
.and_then(Value::as_str)
.map(|model| bounded_text(model, 200)),
duration_ms: context
.get("elapsed_ms")
.or_else(|| context.get("duration_ms"))
.and_then(Value::as_u64),
detail,
});
self.next_id = self.next_id.wrapping_add(1);
self.retained_bytes += entry_size(&entry);
self.entries.push_back(entry);
self.evict();
self.revision = self.revision.wrapping_add(1);
}
fn evict(&mut self) {
while self.entries.len() > MAX_ITEMS || self.retained_bytes > MAX_RETAINED_BYTES {
if let Some(evicted) = self.entries.pop_front() {
self.retained_bytes -= entry_size(&evicted);
self.dropped += 1;
}
}
}
}
/// Entry retention counts metadata as well as captured content. The two
/// identity registries are separately bounded by count and string length.
fn entry_size(entry: &ActivityEntry) -> usize {
std::mem::size_of::<ActivityEntry>()
+ entry.session_id.as_ref().map_or(0, String::capacity)
+ entry.session_label.capacity()
+ entry.category.capacity()
+ entry.event.capacity()
+ entry.summary.capacity()
+ entry.model.as_ref().map_or(0, String::capacity)
+ entry.detail.capacity()
}
fn correlation_keys(context: &Value) -> Vec<String> {
["stream_id", "request_id", "provider_run_id", "run_id"]
.into_iter()
.filter_map(|key| {
let value = context.get(key)?.as_str()?;
// Overlong identifiers cannot be allowed to grow the registry or
// be truncated into another session's correlation key.
(!value.is_empty() && value.len() <= 256).then(|| format!("{key}:{value}"))
})
.collect()
}
fn session_id(context: &Value) -> Option<String> {
[
context.get("conversation_id"),
context.pointer("/tool/conversation_id"),
context.pointer("/identifiers/client_conversation_id"),
context.get("parent_conversation_id"),
]
.into_iter()
.flatten()
.filter_map(Value::as_str)
.find(|id| !id.is_empty() && id.len() <= 256)
.map(str::to_owned)
}
fn category(event: &str) -> &'static str {
if event.contains("compact") || event.contains("summar") {
"Compaction"
} else if event.contains("risk_review")
|| event.contains("risk_assessment")
|| event.contains("permission")
{
"Permissions"
} else if event.contains("tool") || event.contains("action") {
"Tools"
} else if event.contains("command") || event.contains("job") {
"Background jobs"
} else if event.contains("model") || event.contains("llm") {
"Completions"
} else {
"Lifecycle"
}
}
fn bounded_text(text: &str, limit: usize) -> String {
text.chars().take(limit).collect()
}
/// Reuses existing operational metadata while keeping remote logging consent
/// and credentials independent from this local inspector.
pub(crate) fn record_log(record: &super::remote_logging::RemoteLogRecord) {
if !is_recording() {
return;
}
let event = record
.context
.get("event")
.and_then(Value::as_str)
.unwrap_or("activity");
record_serializable(
event,
&record.message,
json!({
"conversation_id": session_id(&record.context),
"stream_id": record.context.get("stream_id"),
"request_id": record.context.get("request_id"),
"provider_run_id": record.context.get("provider_run_id"),
"run_id": record.context.get("run_id"),
"duration_ms": record.context.get("duration_ms"),
"model_id": record.context.get("model_id"),
"elapsed_ms": record.context.get("elapsed_ms"),
"level": record.level.as_str(),
}),
&record.context,
);
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
+190
View File
@@ -0,0 +1,190 @@
use super::*;
fn recording_store() -> ActivityStore {
let mut store = ActivityStore::default();
store.set_recording(true);
store
}
fn capture(store: &mut ActivityStore, context: Value, detail: String) {
store.push_captured(
store.capture_generation().unwrap(),
"model_response",
"Model completed".to_owned(),
&context,
detail,
Utc::now(),
);
}
#[test]
fn clear_and_pause_resume_invalidate_in_flight_captures() {
let mut store = recording_store();
let first_generation = store.capture_generation().unwrap();
store.clear();
store.push_captured(
first_generation,
"tool_result",
"Late".to_owned(),
&json!({}),
"old".to_owned(),
Utc::now(),
);
assert!(store.entries.is_empty());
let second_generation = store.capture_generation().unwrap();
store.set_recording(false);
assert!(store.capture_generation().is_none());
store.set_recording(true);
store.push_captured(
second_generation,
"tool_result",
"Late".to_owned(),
&json!({}),
"old".to_owned(),
Utc::now(),
);
assert!(store.entries.is_empty());
capture(&mut store, json!({}), "new".to_owned());
assert_eq!(store.entries.len(), 1);
}
#[test]
fn retention_evicts_oldest_entries_by_count_and_allocated_size() {
let mut store = recording_store();
for _ in 0..MAX_ITEMS + 3 {
capture(&mut store, json!({}), String::new());
}
assert_eq!(store.entries.len(), MAX_ITEMS);
assert_eq!(store.dropped, 3);
assert_eq!(store.entries.front().unwrap().id, 3);
store.clear();
let detail = "x".repeat(1024 * 1024);
for _ in 0..20 {
capture(&mut store, json!({}), detail.clone());
}
assert!(store.retained_bytes <= MAX_RETAINED_BYTES);
assert_eq!(
store.retained_bytes,
store
.entries
.iter()
.map(|entry| entry_size(entry))
.sum::<usize>()
);
assert!(store.dropped > 0);
// Metadata also consumes the budget, so sixteen 1 MiB payloads do not fit.
assert!(store.entries.len() < 16);
}
#[test]
fn session_names_propagate_to_prior_entries_and_correlated_responses() {
let mut store = recording_store();
capture(
&mut store,
json!({"conversation_id": "session-12345", "stream_id": "stream-1"}),
String::new(),
);
store.register_session("session-12345".to_owned(), "Inspect build".to_owned());
let previous_snapshot = store.entries.front().unwrap().clone();
capture(&mut store, json!({"stream_id": "stream-1"}), String::new());
let response = store.entries.back().unwrap();
assert_eq!(response.session_id.as_deref(), Some("session-12345"));
assert_eq!(response.session_label, "Inspect build · session-");
assert_eq!(previous_snapshot.session_label, response.session_label);
let bytes_before_rename = store.retained_bytes;
store.register_session(
"session-12345".to_owned(),
"Longer build inspection title".to_owned(),
);
assert!(store.retained_bytes > bytes_before_rename);
assert_eq!(
store.retained_bytes,
store
.entries
.iter()
.map(|entry| entry_size(entry))
.sum::<usize>()
);
}
#[test]
fn correlation_rejects_oversized_ids_and_preserves_explicit_ownership() {
let mut store = recording_store();
let overlong = "s".repeat(100_000);
capture(
&mut store,
json!({"conversation_id": "session-a", "stream_id": overlong}),
String::new(),
);
assert!(store.sessions.is_empty());
capture(&mut store, json!({"stream_id": overlong}), String::new());
assert!(store.entries.back().unwrap().session_id.is_none());
capture(
&mut store,
json!({"conversation_id": "session-a", "request_id": "request-1"}),
String::new(),
);
capture(
&mut store,
json!({"conversation_id": "session-b", "request_id": "request-1"}),
String::new(),
);
assert_eq!(
store.entries.back().unwrap().session_id.as_deref(),
Some("session-b")
);
assert_eq!(
session_id(&json!({"conversation_id": "", "tool": {"conversation_id": "owner"}})),
Some("owner".to_owned())
);
}
#[test]
fn risk_assessments_share_the_permissions_filter() {
assert_eq!(category("tool_risk_assessment_finished"), "Permissions");
assert_eq!(category("risk_review_request"), "Permissions");
assert_eq!(category("provider_compaction_requested"), "Compaction");
assert_eq!(
category("shell_command_terminal_completed"),
"Background jobs"
);
}
#[test]
fn long_recordings_keep_accepting_new_session_and_request_identities() {
let mut store = recording_store();
for index in 0..MAX_LABELS + 2 {
let owner = format!("session-{index}");
store.register_session(owner.clone(), format!("Task {index}"));
capture(
&mut store,
json!({"conversation_id": owner, "request_id": format!("request-{index}")}),
String::new(),
);
}
assert_eq!(store.labels.len(), MAX_LABELS);
assert_eq!(store.label_order.len(), MAX_LABELS);
assert_eq!(store.sessions.len(), MAX_LABELS);
assert_eq!(store.correlation_order.len(), MAX_LABELS);
assert!(!store.labels.contains_key("session-0"));
assert!(!store.sessions.contains_key("request_id:request-0"));
let latest = MAX_LABELS + 1;
capture(
&mut store,
json!({"request_id": format!("request-{latest}")}),
String::new(),
);
let response = store.entries.back().unwrap();
assert_eq!(
response.session_id.as_deref(),
Some(format!("session-{latest}").as_str())
);
assert!(response
.session_label
.starts_with(&format!("Task {latest}")));
}
+692
View File
@@ -0,0 +1,692 @@
//! App-wide live browser for local AI diagnostics.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use galaxyui::elements::{
Border, ChildView, Clipped, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox,
Container, CrossAxisAlignment, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize,
MouseStateHandle, Padding, ParentElement, RowBackground, ScrollbarWidth, Table,
TableColumnWidth, TableConfig, TableHeader, TableStateHandle, TableVerticalSizing, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::platform::Cursor;
use galaxyui::r#async::Timer;
use galaxyui::text_layout::ClipConfig;
use galaxyui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use super::{ActivityEntry, ActivitySnapshot};
use crate::appearance::Appearance;
use crate::editor::{
EditorOptions, EditorView, Event as EditorEvent, InteractionState, PlainTextEditorViewAction,
SingleLineEditorOptions, TextOptions,
};
use crate::modal::ModalAction;
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme, SecondaryTheme};
const PAGE_SIZE: usize = 200;
#[derive(Clone, Debug)]
pub enum AIActivityAction {
ToggleRecording,
Clear,
Select(u64),
FilterSession(Option<String>),
FilterCategory(Option<String>),
ShowMore,
}
pub struct AIActivityView {
snapshot: ActivitySnapshot,
search: ViewHandle<EditorView>,
detail: ViewHandle<EditorView>,
recording_button: ViewHandle<ActionButton>,
clear_button: ViewHandle<ActionButton>,
all_sessions_button: ViewHandle<ActionButton>,
more_button: ViewHandle<ActionButton>,
category_buttons: Vec<(Option<String>, ViewHandle<ActionButton>)>,
selected: Option<Arc<ActivityEntry>>,
session_filter: Option<String>,
category_filter: Option<String>,
rows: HashMap<u64, (MouseStateHandle, MouseStateHandle)>,
display_limit: usize,
visible: bool,
refresh_scheduled: bool,
list_scroll: ClippedScrollStateHandle,
detail_scroll: ClippedScrollStateHandle,
filters_scroll: ClippedScrollStateHandle,
table_state: TableStateHandle,
}
impl AIActivityView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let search = ctx.add_typed_action_view(|ctx| {
let mut editor = EditorView::single_line(SingleLineEditorOptions::default(), ctx);
editor.set_placeholder_text("Search activity, model, or session", ctx);
editor
});
ctx.subscribe_to_view(&search, |me, _, event, ctx| match event {
EditorEvent::Edited(_) => {
me.display_limit = PAGE_SIZE;
me.synchronize_rows(ctx);
}
EditorEvent::Escape => ctx.dispatch_typed_action(&ModalAction::Close),
_ => {}
});
let appearance = Appearance::as_ref(ctx);
let font = appearance.monospace_font_family();
let size = appearance.monospace_font_size();
let detail = ctx.add_typed_action_view(|ctx| {
let mut editor = EditorView::new(
EditorOptions {
text: TextOptions {
font_family_override: Some(font),
font_size_override: Some(size),
..Default::default()
},
autogrow: true,
soft_wrap: true,
supports_vim_mode: false,
use_settings_line_height_ratio: true,
..Default::default()
},
ctx,
);
editor.set_interaction_state(InteractionState::Selectable, ctx);
editor
});
ctx.subscribe_to_view(&detail, |_, _, event, ctx| {
if matches!(event, EditorEvent::Escape) {
ctx.dispatch_typed_action(&ModalAction::Close);
}
});
let recording_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Pause recording", SecondaryTheme)
.with_size(ButtonSize::Small)
.on_click(|ctx| ctx.dispatch_typed_action(AIActivityAction::ToggleRecording))
});
let clear_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Clear", SecondaryTheme)
.with_size(ButtonSize::Small)
.on_click(|ctx| ctx.dispatch_typed_action(AIActivityAction::Clear))
});
let all_sessions_button = ctx.add_typed_action_view(|_| {
ActionButton::new("All sessions", NakedTheme)
.with_size(ButtonSize::Small)
.on_click(|ctx| ctx.dispatch_typed_action(AIActivityAction::FilterSession(None)))
});
let more_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Show more", SecondaryTheme)
.with_size(ButtonSize::Small)
.on_click(|ctx| ctx.dispatch_typed_action(AIActivityAction::ShowMore))
});
let category_buttons = [
(None, "All activity"),
(Some("Completions"), "Models"),
(Some("Compaction"), "Compaction"),
(Some("Tools"), "Tools"),
(Some("Permissions"), "Permissions"),
(Some("Background jobs"), "Jobs"),
(Some("Lifecycle"), "Lifecycle"),
]
.into_iter()
.map(|(category, label)| {
let category = category.map(str::to_owned);
let value = category.clone();
let button = ctx.add_typed_action_view(|_| {
ActionButton::new(label, NakedTheme)
.with_size(ButtonSize::Small)
.on_click(move |ctx| {
ctx.dispatch_typed_action(AIActivityAction::FilterCategory(value.clone()))
})
});
(category, button)
})
.collect();
Self {
// Workspace modals are constructed while hidden. Do not retain global
// payloads until this inspector is actually opened.
snapshot: ActivitySnapshot {
recording: super::is_recording(),
revision: 0,
dropped: 0,
entries: Vec::new(),
},
search,
detail,
recording_button,
clear_button,
all_sessions_button,
more_button,
category_buttons,
selected: None,
session_filter: None,
category_filter: None,
rows: HashMap::new(),
display_limit: PAGE_SIZE,
visible: false,
refresh_scheduled: false,
list_scroll: Default::default(),
detail_scroll: Default::default(),
filters_scroll: Default::default(),
table_state: Default::default(),
}
}
pub fn on_open(&mut self, ctx: &mut ViewContext<Self>) {
// Opening the inspector is the explicit opt-in to in-memory payload capture.
if super::snapshot().revision == 0 {
super::set_recording(true);
}
self.visible = true;
self.refresh(ctx);
self.update_recording_button(ctx);
self.synchronize_rows(ctx);
self.schedule_refresh(ctx);
ctx.focus(&self.search);
}
pub fn on_close(&mut self, ctx: &mut ViewContext<Self>) {
self.visible = false;
self.snapshot.entries.clear();
self.rows.clear();
self.clear_detail(ctx);
self.list_scroll = Default::default();
self.table_state = Default::default();
// Leave any pending refresh scheduled: it will observe `visible`, or
// resume on a rapid reopen, without creating a second polling chain.
ctx.notify();
}
fn schedule_refresh(&mut self, ctx: &mut ViewContext<Self>) {
if self.refresh_scheduled || !self.visible {
return;
}
self.refresh_scheduled = true;
ctx.spawn(
async { Timer::after(Duration::from_millis(500)).await },
|me, _, ctx| {
me.refresh_scheduled = false;
if me.visible {
me.refresh(ctx);
me.schedule_refresh(ctx);
}
},
);
}
fn refresh(&mut self, ctx: &mut ViewContext<Self>) {
let snapshot = super::snapshot();
let changed = snapshot.revision != self.snapshot.revision
|| snapshot.recording != self.snapshot.recording;
self.snapshot = snapshot;
if changed {
if self.selected.as_ref().is_some_and(|selected| {
!self
.snapshot
.entries
.iter()
.any(|entry| entry.id == selected.id)
}) {
self.clear_detail(ctx);
}
self.update_recording_button(ctx);
self.synchronize_rows(ctx);
}
}
fn clear_detail(&mut self, ctx: &mut ViewContext<Self>) {
self.selected = None;
self.detail.update(ctx, |editor, ctx| {
editor.set_interaction_state(InteractionState::Editable, ctx);
editor.clear_buffer_and_reset_undo_stack(ctx);
editor.set_interaction_state(InteractionState::Selectable, ctx);
});
self.detail_scroll = Default::default();
}
fn update_recording_button(&self, ctx: &mut ViewContext<Self>) {
self.recording_button.update(ctx, |button, ctx| {
button.set_label(
if self.snapshot.recording {
"Pause recording"
} else {
"Record"
},
ctx,
);
});
}
fn filtered_entries(&self, app: &AppContext) -> Vec<Arc<ActivityEntry>> {
let query = self
.search
.as_ref(app)
.buffer_text(app)
.trim()
.to_lowercase();
self.snapshot
.entries
.iter()
.rev()
.filter(|entry| {
self.session_filter
.as_ref()
.is_none_or(|session| entry.session_id.as_ref() == Some(session))
&& self
.category_filter
.as_ref()
.is_none_or(|category| entry.category == *category)
&& (query.is_empty()
|| [
&entry.session_label,
&entry.summary,
&entry.event,
&entry.category,
]
.into_iter()
.any(|field| field.to_lowercase().contains(&query))
|| entry
.session_id
.as_ref()
.is_some_and(|id| id.to_lowercase().contains(&query))
|| entry
.model
.as_ref()
.is_some_and(|model| model.to_lowercase().contains(&query)))
})
.cloned()
.collect()
}
fn synchronize_rows(&mut self, ctx: &mut ViewContext<Self>) {
let ids = self
.filtered_entries(ctx)
.into_iter()
.take(self.display_limit)
.map(|entry| entry.id)
.collect::<HashSet<_>>();
self.rows.retain(|id, _| ids.contains(id));
for id in ids {
self.rows.entry(id).or_default();
}
for (category, button) in &self.category_buttons {
button.update(ctx, |button, ctx| {
button.set_active(*category == self.category_filter, ctx)
});
}
self.all_sessions_button.update(ctx, |button, ctx| {
button.set_active(self.session_filter.is_none(), ctx);
});
ctx.notify();
}
fn select_entry(&mut self, id: u64, ctx: &mut ViewContext<Self>) {
let Some(entry) = self
.snapshot
.entries
.iter()
.find(|entry| entry.id == id)
.cloned()
else {
return;
};
let session_id = entry.session_id.as_deref().unwrap_or("Unscoped");
let text = format!(
"{}\nSession: {}\nSession ID: {}\nTime: {}\nEvent: {}\n\n{}",
entry.summary,
entry.session_label,
session_id,
entry
.timestamp
.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S%.3f %Z"),
entry.event,
readable_detail(&entry),
);
self.detail.update(ctx, |editor, ctx| {
editor.set_interaction_state(InteractionState::Editable, ctx);
editor.clear_buffer_and_reset_undo_stack(ctx);
editor.insert_with_styles(&text, &[], PlainTextEditorViewAction::ReplaceBuffer, ctx);
editor.set_interaction_state(InteractionState::Selectable, ctx);
});
self.selected = Some(entry);
self.detail_scroll = Default::default();
ctx.notify();
}
fn render_table(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let entries = self
.filtered_entries(app)
.into_iter()
.take(self.display_limit)
.collect::<Vec<_>>();
if entries.is_empty() {
let message = if self.snapshot.entries.is_empty() {
"Waiting for AI activity. Continue a conversation in any session to capture requests, responses, tools, and compaction."
} else {
"No activity matches these filters."
};
return Container::new(
Text::new(
message,
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.soft_wrap(true)
.with_color(theme.sub_text_color(theme.background()).into())
.finish(),
)
.with_padding(Padding::uniform(20.))
.finish();
}
let headers = [
("Time", TableColumnWidth::Fixed(102.)),
("Session", TableColumnWidth::Flex(1.)),
("Type", TableColumnWidth::Fixed(100.)),
("Activity", TableColumnWidth::Flex(1.8)),
("Model", TableColumnWidth::Flex(1.)),
("Duration", TableColumnWidth::Fixed(88.)),
]
.into_iter()
.map(|(label, width)| {
TableHeader::new(
Text::new(
label,
appearance.ui_font_family(),
appearance.ui_font_size() - 1.,
)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(theme.nonactive_ui_text_color().into())
.finish(),
)
.with_width(width)
})
.collect();
let rows = self.rows.clone();
let selected = self.selected.as_ref().map(|entry| entry.id);
let table = Table::new(self.table_state.clone(), 0., 0.)
.with_headers(headers)
.with_row_count(entries.len())
.with_row_render_fn(move |index, app| {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let entry = &entries[index];
let id = entry.id;
let Some((row_mouse, session_mouse)) = rows.get(&id) else {
return Vec::new();
};
let text = |value: String| {
Text::new_inline(
value,
appearance.ui_font_family(),
appearance.ui_font_size() - 1.,
)
.with_clip(ClipConfig::ellipsis())
.with_color(theme.active_ui_text_color().into())
.finish()
};
let session_id = entry.session_id.clone();
let session = text(entry.session_label.clone());
let session = if session_id.is_some() {
Hoverable::new(session_mouse.clone(), move |_| session)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AIActivityAction::FilterSession(
session_id.clone(),
))
})
.with_cursor(Cursor::PointingHand)
.finish()
} else {
session
};
let summary = entry.summary.clone();
let activity = Hoverable::new(row_mouse.clone(), move |_| {
Text::new_inline(
summary,
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_clip(ClipConfig::ellipsis())
.with_style(Properties::default().weight(if selected == Some(id) {
Weight::Semibold
} else {
Weight::Normal
}))
.with_color(theme.active_ui_text_color().into())
.finish()
})
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(AIActivityAction::Select(id)))
.with_cursor(Cursor::PointingHand)
.finish();
vec![
text(
entry
.timestamp
.with_timezone(&chrono::Local)
.format("%H:%M:%S%.3f")
.to_string(),
),
session,
text(entry.category.clone()),
activity,
text(entry.model.clone().unwrap_or_else(|| "—".to_owned())),
text(
entry
.duration_ms
.map(|ms| format!("{:.2} s", ms as f64 / 1000.))
.unwrap_or_else(|| "—".to_owned()),
),
]
})
.with_config(TableConfig {
border_width: 1.,
border_color: theme.outline().into(),
outer_border: true,
column_dividers: false,
row_dividers: true,
cell_padding: 10.,
header_background: theme.surface_2().into(),
row_background: RowBackground::uniform(theme.surface_1().into()),
fixed_header: false,
vertical_sizing: TableVerticalSizing::ExpandToContent,
measure_body_cells_for_intrinsic_widths: false,
});
ClippedScrollable::vertical(
self.list_scroll.clone(),
table.finish(),
ScrollbarWidth::Auto,
theme.nonactive_ui_detail().into(),
theme.active_ui_detail().into(),
galaxyui::elements::Fill::None,
)
.finish()
}
}
/// Legacy/ACP and projected tool arguments arrive as bounded Debug strings.
/// Unwrap those strings for reading while retaining their operational metadata.
/// A truncated capture is deliberately left as text rather than parsed as JSON.
fn readable_detail(entry: &ActivityEntry) -> String {
let Ok(capture) = serde_json::from_str::<serde_json::Value>(&entry.detail) else {
return entry.detail.clone();
};
let payload = &capture["payload"];
let raw = if entry.event == "tool_call" {
payload.as_str()
} else if matches!(
entry.event.as_str(),
"raw_model_request" | "raw_model_response"
) {
payload
.pointer("/raw_payload/payload")
.and_then(serde_json::Value::as_str)
} else {
None
};
let Some(raw) = raw else {
return entry.detail.clone();
};
let decoded = serde_json::from_str::<String>(raw).unwrap_or_else(|_| raw.to_owned());
let mut metadata = capture;
if entry.event == "tool_call" {
metadata["payload"] = serde_json::json!("See captured content below");
} else if let Some(raw_payload) = metadata
.pointer_mut("/payload/raw_payload")
.and_then(serde_json::Value::as_object_mut)
{
raw_payload.remove("payload");
}
format!(
"{}\n\nCaptured content:\n{decoded}",
serde_json::to_string_pretty(&metadata).unwrap_or_default()
)
}
impl Entity for AIActivityView {
type Event = ();
}
impl View for AIActivityView {
fn ui_name() -> &'static str {
"AIActivityView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let small_text = |text: String| {
Text::new(
text,
appearance.ui_font_family(),
appearance.ui_font_size() - 1.,
)
.soft_wrap(true)
.with_color(theme.sub_text_color(theme.background()).into())
.finish()
};
let search = Container::new(Clipped::new(ChildView::new(&self.search).finish()).finish())
.with_horizontal_padding(10.)
.with_vertical_padding(8.)
.with_border(Border::all(1.).with_border_color(theme.outline().into()))
.finish();
let mut filters = Flex::row()
.with_spacing(4.)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
for (_, button) in &self.category_buttons {
filters.add_child(ChildView::new(button).finish());
}
filters.add_child(ChildView::new(&self.all_sessions_button).finish());
let filters = ConstrainedBox::new(
ClippedScrollable::horizontal(
self.filters_scroll.clone(),
filters.finish(),
ScrollbarWidth::Auto,
theme.nonactive_ui_detail().into(),
theme.active_ui_detail().into(),
galaxyui::elements::Fill::None,
)
.finish(),
)
.with_height(36.)
.finish();
let session_label = self.session_filter.as_ref().map(|id| {
self.snapshot
.entries
.iter()
.find(|entry| entry.session_id.as_ref() == Some(id))
.map(|entry| entry.session_label.clone())
.unwrap_or_else(|| id.clone())
});
let count = self.filtered_entries(app).len();
let mut footer = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(small_text(format!(
"{} of {count} events · {}{}",
self.display_limit.min(count),
session_label
.map(|label| format!("Session: {label}"))
.unwrap_or_else(|| "All sessions · click a session to filter".to_owned()),
if self.snapshot.dropped > 0 {
format!(" · {} older events discarded", self.snapshot.dropped)
} else {
String::new()
}
)));
if count > self.display_limit {
footer.add_child(ChildView::new(&self.more_button).finish());
}
let detail = if self.selected.is_some() {
ClippedScrollable::vertical(
self.detail_scroll.clone(),
Container::new(ChildView::new(&self.detail).finish())
.with_padding(Padding::uniform(12.))
.finish(),
ScrollbarWidth::Auto,
theme.nonactive_ui_detail().into(),
theme.active_ui_detail().into(),
galaxyui::elements::Fill::None,
)
.finish()
} else {
Container::new(small_text("Click an activity to inspect its request, response, or lifecycle details. Text is selectable and wrapped.".to_owned()))
.with_padding(Padding::uniform(16.)).finish()
};
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_main_axis_size(MainAxisSize::Max).with_spacing(10.)
.with_child(Flex::row().with_spacing(8.).with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Expanded::new(1., search).finish())
.with_child(ChildView::new(&self.recording_button).finish())
.with_child(ChildView::new(&self.clear_button).finish()).finish())
.with_child(filters)
.with_child(small_text(format!("{} · Local Galaxy request/result snapshots, up to 2,000 events / 16 MiB and 256 Ki characters per event. Recording continues when this window closes.",
if self.snapshot.recording { "Recording all sessions" } else { "Recording paused" })))
.with_child(Expanded::new(1., self.render_table(app)).finish())
.with_child(footer.finish())
.with_child(Expanded::new(1., Container::new(detail)
.with_background(theme.background())
.with_border(Border::all(1.).with_border_color(theme.outline().into())).finish()).finish())
.finish()
}
}
impl TypedActionView for AIActivityView {
type Action = AIActivityAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
AIActivityAction::ToggleRecording => {
super::set_recording(!super::snapshot().recording);
self.refresh(ctx);
}
AIActivityAction::Clear => {
super::clear();
self.clear_detail(ctx);
self.refresh(ctx);
}
AIActivityAction::Select(id) => self.select_entry(*id, ctx),
AIActivityAction::FilterSession(session) => {
self.session_filter = session.clone();
self.display_limit = PAGE_SIZE;
self.list_scroll = Default::default();
self.synchronize_rows(ctx);
}
AIActivityAction::FilterCategory(category) => {
self.category_filter = category.clone();
self.display_limit = PAGE_SIZE;
self.list_scroll = Default::default();
self.synchronize_rows(ctx);
}
AIActivityAction::ShowMore => {
self.display_limit += PAGE_SIZE;
self.synchronize_rows(ctx);
}
}
}
}
+7
View File
@@ -469,6 +469,13 @@ impl BackgroundJobsModel {
id: BackgroundJobId, id: BackgroundJobId,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) -> Result<(), String> { ) -> Result<(), String> {
let job = self.jobs.get(&id).ok_or("Background job not found")?;
// Completion can win the race with an already-authorized cancellation.
// Its recorded outcome is final; acknowledge the request without
// signalling a process or publishing another completion.
if job.status.is_terminal() {
return Ok(());
}
if self.unreconciled_remote_jobs.contains(&id) if self.unreconciled_remote_jobs.contains(&id)
&& !self.remote_cancellations.contains_key(&id) && !self.remote_cancellations.contains_key(&id)
{ {
+36 -13
View File
@@ -322,9 +322,9 @@ const NONINTERACTIVE_ENVIRONMENT: &[(&str, &str)] = &[
("SSH_ASKPASS_REQUIRE", "never"), ("SSH_ASKPASS_REQUIRE", "never"),
]; ];
// Only profile initialization is interactive. The approved command is passed as // Commands are passed as untouched data to constant setup scripts and execute
// one untouched argv item to a fresh noninteractive shell, never interpolated // noninteractively in the owned process tree. Bash/fish use a profile bootstrap;
// into these setup scripts. exec keeps the existing owned process-group leader. // zsh keeps rc-defined shell state in its noninteractive command worker.
// Private environment shuttles preserve explicit overrides through rc files // Private environment shuttles preserve explicit overrides through rc files
// without exposing their values in argv, files, or captured environment output. // without exposing their values in argv, files, or captured environment output.
const BASH_PROFILE_SETUP: &str = r#" const BASH_PROFILE_SETUP: &str = r#"
@@ -355,18 +355,31 @@ __galaxy_job_cwd=$1
__galaxy_job_prefix=$2 __galaxy_job_prefix=$2
__galaxy_job_count=$3 __galaxy_job_count=$3
builtin shift 3 builtin shift 3
__galaxy_job_keys=()
while [ "$__galaxy_job_count" -gt 0 ]; do while [ "$__galaxy_job_count" -gt 0 ]; do
__galaxy_job_key=$1 __galaxy_job_keys+=("$1")
builtin shift
__galaxy_job_count=$((__galaxy_job_count - 1))
done
__galaxy_job_command=$1
builtin set --
# Keep rc-defined functions, aliases and unexported state in the command worker.
# Login files are loaded by zsh; noninteractive shells need an explicit zshrc.
__galaxy_job_rc="${ZDOTDIR:-$HOME}/.zshrc"
if [[ -r "$__galaxy_job_rc" ]]; then
builtin source "$__galaxy_job_rc"
fi
builtin unsetopt XTRACE VERBOSE MONITOR
builtin set --
for __galaxy_job_key in "${__galaxy_job_keys[@]}"; do
__galaxy_job_slot="$__galaxy_job_prefix$__galaxy_job_key" __galaxy_job_slot="$__galaxy_job_prefix$__galaxy_job_key"
builtin typeset -p "$__galaxy_job_slot" >/dev/null 2>&1 || exit 126 builtin typeset -p "$__galaxy_job_slot" >/dev/null 2>&1 || exit 126
builtin export "$__galaxy_job_key=${(P)__galaxy_job_slot}" || exit 126 builtin export "$__galaxy_job_key=${(P)__galaxy_job_slot}" || exit 126
builtin unset "$__galaxy_job_slot" builtin unset "$__galaxy_job_slot"
builtin shift
__galaxy_job_count=$((__galaxy_job_count - 1))
done done
builtin unset SSH_ASKPASS SUDO_ASKPASS BASH_ENV ENV builtin unset SSH_ASKPASS SUDO_ASKPASS BASH_ENV ENV
builtin cd -- "$__galaxy_job_cwd" || exit 126 builtin cd -- "$__galaxy_job_cwd" || exit 126
builtin exec -- "$@" builtin eval -- "$__galaxy_job_command"
"#; "#;
const FISH_PROFILE_SETUP: &str = r#" const FISH_PROFILE_SETUP: &str = r#"
@@ -551,35 +564,45 @@ fn configure_profile_bootstrap(
shell_type: ShellType, shell_type: ShellType,
environment: &HashMap<String, String>, environment: &HashMap<String, String>,
) -> Result<(), String> { ) -> Result<(), String> {
let msys = matches!(&launch.shell, Some(ShellLaunchData::MSYS2 { .. }));
let prefix = format!("{BACKGROUND_ENV_PREFIX}{}_", uuid::Uuid::new_v4().simple()); let prefix = format!("{BACKGROUND_ENV_PREFIX}{}_", uuid::Uuid::new_v4().simple());
let mut keys = environment.keys().collect::<Vec<_>>(); let mut keys = environment.keys().collect::<Vec<_>>();
keys.sort(); keys.sort();
let (setup, inner_flags): (&str, &[&str]) = match shell_type { let (setup, inner_flags): (&str, &[&str]) = match shell_type {
ShellType::Bash => (BASH_PROFILE_SETUP, &["--noprofile", "--norc", "-c"]), ShellType::Bash => (BASH_PROFILE_SETUP, &["--noprofile", "--norc", "-c"]),
ShellType::Zsh => (ZSH_PROFILE_SETUP, &["-f", "-c"]), ShellType::Zsh => (ZSH_PROFILE_SETUP, &[]),
ShellType::Fish => (FISH_PROFILE_SETUP, &["--no-config", "-c"]), ShellType::Fish => (FISH_PROFILE_SETUP, &["--no-config", "-c"]),
ShellType::PowerShell => return Err("Invalid shell bootstrap selection".to_owned()), ShellType::PowerShell => return Err("Invalid shell bootstrap selection".to_owned()),
}; };
if shell_type == ShellType::Zsh {
// Source zshrc and execute in one noninteractive process. A second zsh
// would discard rc-defined functions such as user command wrappers.
command.args(["-l", "-c", setup]);
command.arg(shell_path_argument(program, msys)?);
} else {
command.args(["-i", "-l"]); command.args(["-i", "-l"]);
if shell_type != ShellType::Fish { if shell_type == ShellType::Bash {
// No terminal is attached: profile initialization must not create // No terminal is attached: profile initialization must not create
// separate interactive job-control groups before the owned exec. // separate interactive job-control groups before the owned exec.
command.arg("+m"); command.arg("+m");
} }
command.args(["-c", setup]); command.args(["-c", setup]);
// Bash/zsh reserve this argument as $0; fish puts every following argument in $argv. // Bash reserves this as $0; fish uses every following argument as $argv.
if shell_type != ShellType::Fish { if shell_type == ShellType::Bash {
command.arg("galaxy-background-job"); command.arg("galaxy-background-job");
} }
let msys = matches!(&launch.shell, Some(ShellLaunchData::MSYS2 { .. })); }
command.arg(shell_path_argument(approved_directory, msys)?); command.arg(shell_path_argument(approved_directory, msys)?);
command.arg(&prefix).arg(keys.len().to_string()); command.arg(&prefix).arg(keys.len().to_string());
for key in keys { for key in keys {
command.arg(key); command.arg(key);
command.env(format!("{prefix}{key}"), &environment[key]); command.env(format!("{prefix}{key}"), &environment[key]);
} }
if shell_type != ShellType::Zsh {
command.arg(shell_path_argument(program, msys)?); command.arg(shell_path_argument(program, msys)?);
command.args(inner_flags).arg(&launch.command); command.args(inner_flags);
}
command.arg(&launch.command);
Ok(()) Ok(())
} }
+139
View File
@@ -183,6 +183,145 @@ line2"
assert!(!std::fs::read_to_string(argv_log).unwrap().contains(secret)); assert!(!std::fs::read_to_string(argv_log).unwrap().contains(secret));
} }
#[cfg(unix)]
#[tokio::test]
async fn zsh_worker_retains_profile_functions_aliases_and_state_without_interactivity() {
let Some(zsh) = std::env::var_os("PATH").and_then(|paths| {
std::env::split_paths(&paths)
.map(|path| path.join("zsh"))
.find(|path| path.is_file())
}) else {
// Optional host tool: fixture coverage never sources real user profiles.
return;
};
let root = tempfile::tempdir().unwrap();
let cwd = std::fs::canonicalize(root.path()).unwrap();
let shell = cwd.join("fixture-zsh");
let home = cwd.join("home");
let rc_directory = cwd.join("rc");
let bin = home.join("bin");
std::fs::create_dir_all(&bin).unwrap();
std::fs::create_dir(&rc_directory).unwrap();
// Exercise the selected zsh worker and constant setup script while
// disabling automatic user/login profiles in this isolated fixture.
write_executable(
&shell,
r#"#!/bin/sh
printf '%s\n' "$@" >> "$GALAXY_TEST_ARGV"
printf '%s' "$$" > "$GALAXY_TEST_PID"
if [ "$1" != '-l' ] || [ "$2" != '-c' ]; then exit 95; fi
shift
exec "$GALAXY_TEST_ZSH" -f "$@"
"#,
);
write_executable(
&bin.join("galaxy-fixture-tool"),
r#"#!/bin/sh
if [ "${GALAXY_TEST_UNEXPORTED+x}" = x ]; then exit 94; fi
printf 'tool:%s:%s\n' "$1" "$2"
"#,
);
std::fs::write(
bin.join("worktree.zsh"),
r#"wt() {
"$HOME/bin/galaxy-fixture-tool" "$GALAXY_TEST_UNEXPORTED" "$@"
}
alias galaxy-fixture-alias='wt alias'
"#,
)
.unwrap();
std::fs::write(
rc_directory.join(".zshrc"),
r#"if [[ -o interactive || $# -ne 0 ]]; then return 93; fi
printf '%s\n' loaded >> "$GALAXY_TEST_PROFILE_COUNT"
source "$HOME/bin/worktree.zsh"
GALAXY_TEST_UNEXPORTED='worker-state'
GALAXY_TEST_PROFILE_PID=$$
export PATH="$HOME/bin:$PATH"
export GALAXY_TEST_OVERRIDE=profile
export CI=profile
export TERM=profile
export SSH_ASKPASS=profile
export SUDO_ASKPASS=profile
export BASH_ENV=profile
export ENV=profile
builtin set -- 'rc changed argv' 'discard these arguments'
builtin cd /
"#,
)
.unwrap();
let script = r#"printf '%s\n' "$PWD" "$0" "$GALAXY_TEST_PROFILE_PID" "$GALAXY_TEST_OVERRIDE" "$CI" "$TERM" "${SSH_ASKPASS-unset}" "${SUDO_ASKPASS-unset}" "${BASH_ENV-unset}" "${ENV-unset}" "$#"
if [[ -o interactive || -t 0 || -t 1 || -t 2 ]]; then exit 99; fi
if IFS= read -r ignored; then exit 98; fi
wt direct
galaxy-fixture-alias
galaxy-fixture-tool lookup via-path
printf '%s\n' "a'b \"quoted\"" "line1
line2"
# trailing comment
"#;
let secret = "synthetic-zsh-session-value-not-for-argv";
let argv_log = cwd.join("argv");
let pid_file = cwd.join("pid");
let profile_count = cwd.join("profile-count");
for (index, (text, expected_code, expected_tail)) in [
(script, 0, None),
(
"wt exit-check\nexit 17\n# trailing comment",
17,
Some("tool:worker-state:exit-check\n"),
),
(
"wt failure-check\nfalse\n# trailing comment",
1,
Some("tool:worker-state:failure-check\n"),
),
]
.into_iter()
.enumerate()
{
let mut launch = fixture_launch(shell.clone(), ShellType::Zsh, text, &cwd);
launch.environment = Some(HashMap::from([(
"GALAXY_TEST_OVERRIDE".into(),
secret.into(),
)]));
let mut command = command_for_launch(&launch, &cwd).unwrap();
command
.current_dir(&cwd)
.env("HOME", &home)
.env("ZDOTDIR", &rc_directory)
.env("GALAXY_TEST_ZSH", &zsh)
.env("GALAXY_TEST_ARGV", &argv_log)
.env("GALAXY_TEST_PID", &pid_file)
.env("GALAXY_TEST_PROFILE_COUNT", &profile_count)
.env_remove("GALAXY_TEST_UNEXPORTED")
.env_remove("GALAXY_TEST_PROFILE_PID");
let output = command.output().await.unwrap();
assert_eq!(
output.status.code(),
Some(expected_code),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let expected = if let Some(tail) = expected_tail {
tail.to_owned()
} else {
let original_pid = std::fs::read_to_string(&pid_file).unwrap();
format!(
"{}\n{}\n{original_pid}\n{secret}\n1\ndumb\nunset\nunset\nunset\nunset\n0\ntool:worker-state:direct\ntool:worker-state:alias\ntool:lookup:via-path\na'b \"quoted\"\nline1\nline2\n",
cwd.display(),
shell.display()
)
};
assert_eq!(String::from_utf8(output.stdout).unwrap(), expected);
assert_eq!(
std::fs::read_to_string(&profile_count).unwrap(),
"loaded\n".repeat(index + 1)
);
}
assert!(!std::fs::read_to_string(argv_log).unwrap().contains(secret));
}
#[cfg(unix)] #[cfg(unix)]
#[tokio::test] #[tokio::test]
async fn powershell_setup_preserves_explicit_exit_native_failure_and_script_text() { async fn powershell_setup_preserves_explicit_exit_native_failure_and_script_text() {
+85
View File
@@ -144,3 +144,88 @@ fn corrupt_history_is_reported_instead_of_replaced() {
b"broken" b"broken"
); );
} }
fn cancellation_fixture(status: BackgroundJobStatus) -> BackgroundJob {
BackgroundJob {
id: uuid::Uuid::new_v4(),
conversation_id: crate::ai::agent::conversation::AIConversationId::new(),
action_id: "cancellation-test-action".to_owned().into(),
command: "already completed command".to_owned(),
working_directory: std::path::PathBuf::new(),
project_root: None,
host_id: None,
started_at: Utc::now(),
finished_at: Some(Utc::now()),
status,
exit_code: Some(17),
error: Some("original outcome".to_owned()),
completion_delivered: true,
log_path: std::path::PathBuf::new(),
output: "original output".to_owned(),
output_truncated: false,
log_bytes: 15,
}
}
#[test]
fn cancelling_a_completed_job_preserves_its_outcome_without_signalling_or_redelivery() {
galaxyui::App::test((), |mut app| async move {
let jobs = app.add_model(super::BackgroundJobsModel::new);
for status in [
BackgroundJobStatus::Succeeded,
BackgroundJobStatus::Failed,
BackgroundJobStatus::Cancelled,
] {
jobs.update(&mut app, |jobs, ctx| {
let job = cancellation_fixture(status);
let id = job.id;
let owner = job.conversation_id;
let finished_at = job.finished_at;
let (cancel, cancellations) = async_channel::bounded(1);
jobs.jobs.insert(id, job);
// Even a stale remote cancellation handle must not be used.
jobs.remote_cancellations.insert(id, cancel);
assert_eq!(jobs.cancel(id, ctx), Ok(()));
assert_eq!(jobs.cancel(id, ctx), Ok(()));
assert_eq!(
cancellations.try_recv(),
Err(async_channel::TryRecvError::Empty)
);
let job = jobs.job(id).unwrap();
assert_eq!(job.status, status);
assert_eq!(job.exit_code, Some(17));
assert_eq!(job.finished_at, finished_at);
assert_eq!(job.error.as_deref(), Some("original outcome"));
assert_eq!(job.output, "original output");
assert_eq!(job.completion_delivered, true);
assert_eq!(jobs.pending_completions(owner).len(), 0);
assert_eq!(jobs.unpublished_completions.contains(&id), false);
});
}
});
}
#[test]
fn cancellation_still_rejects_unknown_and_unowned_running_processes() {
galaxyui::App::test((), |mut app| async move {
let jobs = app.add_model(super::BackgroundJobsModel::new);
jobs.update(&mut app, |jobs, ctx| {
let unknown = uuid::Uuid::new_v4();
let (cancel, cancellations) = async_channel::bounded(1);
jobs.remote_cancellations.insert(unknown, cancel);
assert_eq!(jobs.cancel(unknown, ctx), Err("Background job not found".to_owned()));
assert_eq!(cancellations.try_recv(), Err(async_channel::TryRecvError::Empty));
let mut job = cancellation_fixture(BackgroundJobStatus::Running);
job.finished_at = None;
let id = job.id;
jobs.jobs.insert(id, job);
// A known Running record alone never authorizes signalling a PID.
assert_eq!(jobs.cancel(id, ctx), Err("This background job is no longer running".to_owned()));
jobs.unreconciled_remote_jobs.insert(id);
assert_eq!(jobs.cancel(id, ctx), Err("Waiting to reconnect and confirm this job's state on its host before cancellation is available".to_owned()));
assert_eq!(jobs.job(id).unwrap().status, BackgroundJobStatus::Running);
});
});
}
+63
View File
@@ -821,6 +821,18 @@ fn log_tool_event(
); );
} }
/// Stream the app's projected tool arguments into the inspector's bounded
/// writer instead of allocating an unbounded Debug string first.
#[cfg(not(target_family = "wasm"))]
struct ActivityToolArguments<'a>(&'a AIAgentActionType);
#[cfg(not(target_family = "wasm"))]
impl serde::Serialize for ActivityToolArguments<'_> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(&format_args!("{:#?}", self.0))
}
}
pub struct BlocklistAIActionModel { pub struct BlocklistAIActionModel {
executor: ModelHandle<BlocklistAIActionExecutor>, executor: ModelHandle<BlocklistAIActionExecutor>,
active_session: ModelHandle<ActiveSession>, active_session: ModelHandle<ActiveSession>,
@@ -2029,6 +2041,22 @@ impl BlocklistAIActionModel {
action.id, action.id,
std::mem::discriminant(&action.action) std::mem::discriminant(&action.action)
); );
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
crate::ai::activity::record_serializable(
"tool_call",
format!("Tool requested: {}", action_tool_name(action)),
serde_json::json!({
"conversation_id": conversation_id.to_string(),
"action_id": action.id.to_string(),
"task_id": action.task_id.to_string(),
"tool_name": action_tool_name(action),
"payload_kind": "projected_tool_arguments",
"requires_result": action.requires_result,
}),
&ActivityToolArguments(&action.action),
);
}
} }
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
log_tool_event( log_tool_event(
@@ -2616,6 +2644,41 @@ impl BlocklistAIActionModel {
{ {
tool_result.content = reason; tool_result.content = reason;
} }
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
#[derive(serde::Serialize)]
struct ResultPayload<'a> {
content: &'a str,
status: &'a ToolResultStatus,
image_count: usize,
image_bytes_omitted: usize,
}
crate::ai::activity::record_serializable(
"tool_result",
format!(
"Tool result: {}",
action_result_type_name(&action_result.result)
),
serde_json::json!({
"conversation_id": conversation_id.to_string(),
"action_id": action_result.id.to_string(),
"task_id": action_result.task_id.to_string(),
"result_type": action_result_type_name(&action_result.result),
"permission_denied": permission_denied,
"payload_kind": "provider_neutral_tool_result",
}),
&ResultPayload {
content: &tool_result.content,
status: &tool_result.status,
image_count: tool_result.images.len(),
image_bytes_omitted: tool_result
.images
.iter()
.map(|image| image.data.len())
.sum(),
},
);
}
if execution_ref.is_none() { if execution_ref.is_none() {
self.finished_tool_results self.finished_tool_results
.entry(conversation_id) .entry(conversation_id)
@@ -93,7 +93,7 @@ impl GrepError {
error: GrepErrorType::NonZeroExitCode, error: GrepErrorType::NonZeroExitCode,
output: Some(output), output: Some(output),
.. ..
} => format!("{NON_ZERO_EXIT_CODE_ERROR}, output:\n{output}"), } => actionable_grep_error(format!("{NON_ZERO_EXIT_CODE_ERROR}, output:\n{output}")),
GrepError { GrepError {
error: GrepErrorType::NonZeroExitCode, error: GrepErrorType::NonZeroExitCode,
output: None, output: None,
@@ -102,11 +102,43 @@ impl GrepError {
GrepError { GrepError {
error: GrepErrorType::Other(error), error: GrepErrorType::Other(error),
.. ..
} => error.clone(), } => actionable_grep_error(error.clone()),
} }
} }
} }
/// Preserve backend regex semantics and report recovery guidance only after
/// that backend has diagnosed invalid syntax. Never retry with different
/// semantics or reject valid backend-specific expressions in a local parser.
fn actionable_grep_error(message: String) -> String {
let lower = message.to_ascii_lowercase();
let invalid_regex = [
"invalid regular expression",
"invalid pattern",
"regex parse error",
"unmatched [",
"unmatched (",
"unmatched )",
"unbalanced parenth",
"parentheses not balanced",
"brackets ([ ]) not balanced",
"unclosed group",
"unclosed character class",
"unterminated character class",
"unterminated [",
"missing closing parenthesis",
"not enough )",
"not enough ]",
]
.iter()
.any(|marker| lower.contains(marker));
if invalid_regex {
format!("{message}\nGrep uses regex by default. For literal source text, retry the same queries with fixed_strings: true. For an intended regex, correct or escape the invalid syntax; do not repeat the same malformed pattern.")
} else {
message
}
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
fn create_redacted_grep_error_event( fn create_redacted_grep_error_event(
should_collect_ugc: bool, should_collect_ugc: bool,
@@ -125,3 +125,45 @@ fn parse_grep_output_reports_when_results_reach_the_per_file_limit() {
); );
assert!(truncated); assert!(truncated);
} }
#[test]
fn backend_regex_syntax_errors_explain_literal_and_regex_recovery() {
for output in [
"fatal: -e option, 'variantResolutions: []': brackets ([ ]) not balanced",
"fatal: -e option, 'indexAsset(context': parentheses not balanced",
"fatal: command line, 'variantResolutions: []': Unmatched [, [^, [:, [., or [=",
r"fatal: command line, 'indexAsset(context': Unmatched ( or \(",
"regex parse error: unclosed group",
"Select-String: Invalid pattern 'indexAsset(context': Not enough )'s.",
] {
let error = GrepError::new_for_non_zero_exit_code().with_output(output.to_owned());
let message = error.error_for_conversation();
assert!(message.contains(output));
assert!(message.contains("fixed_strings: true"));
assert!(message.contains("correct or escape"));
assert_eq!(error.error_message(), NON_ZERO_EXIT_CODE_ERROR);
}
}
#[test]
fn ripgrep_regex_errors_also_include_literal_mode_guidance() {
let error =
GrepError::new("Ripgrep search failed: regex parse error: unclosed group".to_owned());
assert!(error
.error_for_conversation()
.contains("fixed_strings: true"));
}
#[test]
fn unrelated_grep_failures_keep_the_original_diagnostic() {
let output = "fatal: cannot access '/project': Permission denied";
let error = GrepError::new_for_non_zero_exit_code().with_output(output.to_owned());
assert_eq!(
error.error_for_conversation(),
format!("{NON_ZERO_EXIT_CODE_ERROR}, output:\n{output}")
);
assert_eq!(
GrepError::new("Grep operation timed out".to_owned()).error_for_conversation(),
"Grep operation timed out"
);
}
@@ -173,57 +173,7 @@ impl ReadFilesExecutor {
.await .await
.map_err(|e| anyhow::anyhow!("Remote read failed: {e}"))?; .map_err(|e| anyhow::anyhow!("Remote read failed: {e}"))?;
if !response.failed_files.is_empty() && response.file_contexts.is_empty() { Ok(remote_read_files_result(response))
let failed = response
.failed_files
.iter()
.map(|f| {
let reason = f
.error
.as_ref()
.map(|e| e.message.as_str())
.unwrap_or("unknown error");
format!("{}: {reason}", f.path)
})
.collect::<Vec<_>>()
.join(", ");
return Ok(ReadFilesResult::Error(format!(
"Failed to read files: {failed}"
)));
}
let file_contexts = response
.file_contexts
.into_iter()
.filter_map(|fc| {
let content = match fc.content? {
remote_server::proto::file_context_proto::Content::TextContent(
text,
) => crate::ai::agent::AnyFileContent::StringContent(text),
remote_server::proto::file_context_proto::Content::BinaryContent(
bytes,
) => crate::ai::agent::AnyFileContent::BinaryContent(bytes),
};
let line_range = match (fc.line_range_start, fc.line_range_end) {
(Some(start), Some(end)) => Some(start as usize..end as usize),
_ => None,
};
let last_modified = fc.last_modified_epoch_millis.map(|ms| {
std::time::UNIX_EPOCH + std::time::Duration::from_millis(ms)
});
Some(crate::ai::agent::FileContext {
file_name: fc.file_name,
content,
line_range,
last_modified,
line_count: fc.line_count as usize,
})
})
.collect();
Ok(ReadFilesResult::Success {
files: file_contexts,
})
}), }),
on_complete: Box::new(|res: Result<ReadFilesResult, anyhow::Error>, _ctx| { on_complete: Box::new(|res: Result<ReadFilesResult, anyhow::Error>, _ctx| {
let action_result = let action_result =
@@ -254,16 +204,7 @@ impl ReadFilesExecutor {
result.file_contexts.extend(file_result.file_contexts); result.file_contexts.extend(file_result.file_contexts);
result.missing_files.extend(file_result.missing_files); result.missing_files.extend(file_result.missing_files);
} }
if result.missing_files.is_empty() { Ok(local_read_files_result(result))
Ok(ReadFilesResult::Success {
files: result.file_contexts,
})
} else {
let missing_files = result.missing_files.join(", ");
Ok(ReadFilesResult::Error(format!(
"These files do not exist: {missing_files}"
)))
}
}), }),
on_complete: Box::new(|res: Result<ReadFilesResult, anyhow::Error>, _ctx| { on_complete: Box::new(|res: Result<ReadFilesResult, anyhow::Error>, _ctx| {
let action_result = res.unwrap_or_else(|e| ReadFilesResult::Error(e.to_string())); let action_result = res.unwrap_or_else(|e| ReadFilesResult::Error(e.to_string()));
@@ -284,3 +225,125 @@ impl ReadFilesExecutor {
impl Entity for ReadFilesExecutor { impl Entity for ReadFilesExecutor {
type Event = (); type Event = ();
} }
/// The shared action/protobuf contract has no partial-success representation.
/// Do not claim a remote batch succeeded while silently losing failed paths.
fn remote_read_files_result(
response: remote_server::proto::ReadFileContextResponse,
) -> ReadFilesResult {
if !response.failed_files.is_empty()
|| response
.file_contexts
.iter()
.any(|file| file.content.is_none())
{
let failed = response.failed_files.iter().map(|failure| {
(
failure.path.as_str(),
failure.error.as_ref().map(|error| error.message.as_str()),
)
});
let missing_content = response
.file_contexts
.iter()
.filter(|file| file.content.is_none())
.map(|file| {
let name = if file.file_name.is_empty() {
"<unnamed remote file>"
} else {
file.file_name.as_str()
};
(name, Some("remote host returned no file content"))
});
return read_files_failure(failed.chain(missing_content));
}
let file_contexts = response
.file_contexts
.into_iter()
.filter_map(|fc| {
let content = match fc.content? {
remote_server::proto::file_context_proto::Content::TextContent(text) => {
crate::ai::agent::AnyFileContent::StringContent(text)
}
remote_server::proto::file_context_proto::Content::BinaryContent(bytes) => {
crate::ai::agent::AnyFileContent::BinaryContent(bytes)
}
};
let line_range = match (fc.line_range_start, fc.line_range_end) {
(Some(start), Some(end)) => Some(start as usize..end as usize),
_ => None,
};
let last_modified = fc
.last_modified_epoch_millis
.map(|ms| std::time::UNIX_EPOCH + std::time::Duration::from_millis(ms));
Some(crate::ai::agent::FileContext {
file_name: fc.file_name,
content,
line_range,
last_modified,
line_count: fc.line_count as usize,
})
})
.collect();
ReadFilesResult::Success {
files: file_contexts,
}
}
fn local_read_files_result(result: super::ReadFileContextResult) -> ReadFilesResult {
if result.missing_files.is_empty() {
ReadFilesResult::Success {
files: result.file_contexts,
}
} else {
read_files_failure(
result
.missing_files
.iter()
.map(|path| (path.as_str(), None)),
)
}
}
const MAX_REPORTED_READ_FAILURES: usize = 8;
const MAX_READ_FAILURE_FIELD_CHARS: usize = 256;
fn read_files_failure<'a>(
failures: impl IntoIterator<Item = (&'a str, Option<&'a str>)>,
) -> ReadFilesResult {
let mut message = "Could not read all requested files. No file contents were returned for this batch.\nFailed paths:".to_owned();
let mut total = 0;
for (path, reason) in failures {
total += 1;
if total > MAX_REPORTED_READ_FAILURES {
continue;
}
message.push_str("\n- ");
append_failure_field(&mut message, path);
message.push_str(": ");
append_failure_field(
&mut message,
reason.unwrap_or("missing, unreadable, or exceeds the read limit"),
);
}
if total > MAX_REPORTED_READ_FAILURES {
message.push_str(&format!(
"\n{} additional failed paths omitted.",
total - MAX_REPORTED_READ_FAILURES
));
}
message.push_str("\nUse file discovery to confirm exact paths before retrying. For large files, request a smaller line range or fewer files. A file may exist but be unreadable or exceed the read limit.");
ReadFilesResult::Error(message)
}
fn append_failure_field(message: &mut String, field: &str) {
let mut characters = field.chars();
message.extend(characters.by_ref().take(MAX_READ_FAILURE_FIELD_CHARS));
if characters.next().is_some() {
message.push('…');
}
}
#[cfg(test)]
#[path = "read_files_tests.rs"]
mod tests;
@@ -0,0 +1,143 @@
use super::*;
use crate::ai::agent::{AnyFileContent, FileContext};
fn remote_file() -> remote_server::proto::FileContextProto {
remote_server::proto::FileContextProto {
file_name: "/project/present.rs".to_owned(),
content: Some(
remote_server::proto::file_context_proto::Content::TextContent(
"returned file content".to_owned(),
),
),
line_range_start: Some(3),
line_range_end: Some(5),
last_modified_epoch_millis: Some(42),
line_count: 20,
}
}
#[test]
fn partial_remote_reads_report_failed_paths_instead_of_claiming_success() {
let result = remote_read_files_result(remote_server::proto::ReadFileContextResponse {
file_contexts: vec![remote_file()],
failed_files: vec![remote_server::proto::FailedFileRead {
path: "/project/restricted.rs".to_owned(),
error: Some(remote_server::proto::FileOperationError {
message: "Permission denied".to_owned(),
}),
}],
});
let ReadFilesResult::Error(message) = result else {
panic!("A partial remote read must not claim full success");
};
assert!(message.contains("/project/restricted.rs: Permission denied"));
assert!(message.contains("No file contents were returned for this batch"));
assert!(!message.contains("returned file content"));
assert!(message.contains("confirm exact paths"));
}
#[test]
fn remote_file_context_without_content_is_an_explicit_failure() {
let mut missing = remote_file();
missing.file_name = "/project/no-content.rs".to_owned();
missing.content = None;
let result = remote_read_files_result(remote_server::proto::ReadFileContextResponse {
file_contexts: vec![remote_file(), missing.clone()],
failed_files: vec![],
});
let ReadFilesResult::Error(message) = result else {
panic!("A remote context without content must not disappear into Success");
};
assert!(message.contains("/project/no-content.rs: remote host returned no file content"));
assert!(!message.contains("returned file content"));
let result = remote_read_files_result(remote_server::proto::ReadFileContextResponse {
file_contexts: vec![missing],
failed_files: vec![remote_server::proto::FailedFileRead {
path: "/project/restricted.rs".to_owned(),
error: Some(remote_server::proto::FileOperationError {
message: "Permission denied".to_owned(),
}),
}],
});
let ReadFilesResult::Error(message) = result else {
panic!("Both remote failure categories must remain visible");
};
assert!(message.contains("/project/no-content.rs: remote host returned no file content"));
assert!(message.contains("/project/restricted.rs: Permission denied"));
}
#[test]
fn successful_remote_reads_preserve_content_ranges_and_file_metadata() {
let result = remote_read_files_result(remote_server::proto::ReadFileContextResponse {
file_contexts: vec![remote_file()],
failed_files: vec![],
});
let ReadFilesResult::Success { files } = result else {
panic!("A complete remote read must succeed");
};
assert_eq!(files.len(), 1);
assert_eq!(files[0].file_name, "/project/present.rs");
assert_eq!(
files[0].content,
AnyFileContent::StringContent("returned file content".to_owned())
);
assert_eq!(files[0].line_range, Some(3..5));
assert_eq!(files[0].line_count, 20);
assert_eq!(
files[0].last_modified,
Some(std::time::UNIX_EPOCH + std::time::Duration::from_millis(42))
);
}
#[test]
fn local_failures_do_not_assert_that_oversized_or_unreadable_files_are_missing() {
let result = local_read_files_result(super::super::ReadFileContextResult {
file_contexts: vec![],
missing_files: vec!["/project/large.bin".to_owned()],
});
let ReadFilesResult::Error(message) = result else {
panic!("An unread file must remain an error");
};
assert!(message.contains("/project/large.bin"));
assert!(message.contains("exceeds the read limit"));
assert!(message.contains("smaller line range or fewer files"));
assert!(!message.contains("do not exist"));
}
#[test]
fn successful_local_reads_preserve_their_original_content() {
let file = FileContext {
file_name: "/project/present.rs".to_owned(),
content: AnyFileContent::StringContent("unchanged".to_owned()),
line_range: None,
last_modified: None,
line_count: 1,
};
assert_eq!(
local_read_files_result(super::super::ReadFileContextResult {
file_contexts: vec![file.clone()],
missing_files: vec![],
}),
ReadFilesResult::Success { files: vec![file] },
);
}
#[test]
fn failure_details_bound_unicode_paths_reasons_and_the_number_of_reported_files() {
let long_field = "界".repeat(10_000);
let result = read_files_failure(
(0..MAX_REPORTED_READ_FAILURES + 3)
.map(|_| (long_field.as_str(), Some(long_field.as_str()))),
);
let ReadFilesResult::Error(message) = result else {
panic!("Failed paths must produce an error");
};
assert_eq!(message.matches("\n- ").count(), MAX_REPORTED_READ_FAILURES);
assert!(message.contains("3 additional failed paths omitted"));
assert!(message.contains('…'));
assert!(
message.chars().count()
< MAX_REPORTED_READ_FAILURES * MAX_READ_FAILURE_FIELD_CHARS * 2 + 1_000
);
}
@@ -692,7 +692,18 @@ impl BlocklistAIActionModel {
request.max_output_tokens = Some(u64::from(REVIEW_MAX_OUTPUT_TOKENS)); request.max_output_tokens = Some(u64::from(REVIEW_MAX_OUTPUT_TOKENS));
let started = Instant::now(); let started = Instant::now();
ctx.spawn( ctx.spawn(
assess(provider_config, request, cancel, control), assess(
provider_config,
request,
cancel,
control,
#[cfg(not(target_family = "wasm"))]
serde_json::json!({
"conversation_id": conversation_id.to_string(),
"action_id": action.id.to_string(),
"request_id": token.to_string(),
}),
),
move |me, result, ctx| me.finish_action_review(key, token, started, result, ctx), move |me, result, ctx| me.finish_action_review(key, token, started, result, ctx),
); );
None None
@@ -1010,18 +1021,41 @@ async fn assess(
request: TurnRequest, request: TurnRequest,
sender: TurnCommandSender, sender: TurnCommandSender,
control: TurnControl, control: TurnControl,
#[cfg(not(target_family = "wasm"))] activity_identity: serde_json::Value,
) -> anyhow::Result<Assessment> { ) -> anyhow::Result<Assessment> {
anyhow::ensure!( anyhow::ensure!(
serde_json::to_vec(&request)?.len() <= MAX_REVIEW_INPUT_BYTES, serde_json::to_vec(&request)?.len() <= MAX_REVIEW_INPUT_BYTES,
"action is too large for a reliable bounded assessment" "action is too large for a reliable bounded assessment"
); );
#[cfg(not(target_family = "wasm"))]
let started = Instant::now();
#[cfg(not(target_family = "wasm"))]
let activity_model_id = request.model.as_str().to_string();
#[cfg(not(target_family = "wasm"))]
let activity_context = || {
let mut context = activity_identity.clone();
context["model_id"] = serde_json::json!(activity_model_id);
context["elapsed_ms"] = serde_json::json!(started.elapsed().as_millis());
context
};
let review = async { let review = async {
let model_id = request.model.as_str().to_string(); let model_id = request.model.as_str().to_string();
let runtime = provider_runtime_for_request(provider_config, &request).await?; let runtime = provider_runtime_for_request(provider_config, &request).await?;
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
crate::ai::activity::record_serializable(
"risk_review_request",
"Permission assessment request",
activity_context(),
&request,
);
}
let mut stream = runtime.start_turn(request, control).await?; let mut stream = runtime.start_turn(request, control).await?;
let mut text = String::new(); let mut text = String::new();
let mut reasoning_bytes = 0usize; let mut reasoning_bytes = 0usize;
let mut output_tokens = None; let mut output_tokens = None;
#[cfg(not(target_family = "wasm"))]
let mut response_usage = None;
while let Some(event) = stream.next().await { while let Some(event) = stream.next().await {
match event? { match event? {
AgentEvent::TextDelta { text: delta } => { AgentEvent::TextDelta { text: delta } => {
@@ -1031,12 +1065,25 @@ async fn assess(
); );
text.push_str(&delta); text.push_str(&delta);
} }
AgentEvent::TurnStopped {
reason: StopReason::Completed,
} => return Assessment::parse(&text).map_err(|error| anyhow::anyhow!(
"{error} (model={model_id}, decision_bytes={}, reasoning_bytes={reasoning_bytes}, output_tokens={output_tokens:?})", text.len()
)),
AgentEvent::TurnStopped { reason } => { AgentEvent::TurnStopped { reason } => {
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
crate::ai::activity::record_serializable(
"risk_review_response",
"Permission assessment response",
activity_context(),
&serde_json::json!({
"text": text,
"usage": response_usage,
"stop_reason": reason,
}),
);
}
if reason == StopReason::Completed {
return Assessment::parse(&text).map_err(|error| anyhow::anyhow!(
"{error} (model={model_id}, decision_bytes={}, reasoning_bytes={reasoning_bytes}, output_tokens={output_tokens:?})", text.len()
));
}
anyhow::bail!("permission reviewer stopped with {reason:?} (model={model_id}, decision_bytes={}, reasoning_bytes={reasoning_bytes}, output_tokens={output_tokens:?})", text.len()) anyhow::bail!("permission reviewer stopped with {reason:?} (model={model_id}, decision_bytes={}, reasoning_bytes={reasoning_bytes}, output_tokens={output_tokens:?})", text.len())
} }
AgentEvent::Tool { .. } => { AgentEvent::Tool { .. } => {
@@ -1050,6 +1097,10 @@ async fn assess(
} }
AgentEvent::UsageUpdated { usage } => { AgentEvent::UsageUpdated { usage } => {
output_tokens = Some(usage.output_tokens); output_tokens = Some(usage.output_tokens);
#[cfg(not(target_family = "wasm"))]
{
response_usage = Some(usage);
}
} }
AgentEvent::TurnStarted { .. } AgentEvent::TurnStarted { .. }
| AgentEvent::KeepAlive | AgentEvent::KeepAlive
@@ -1066,6 +1117,18 @@ async fn assess(
let timeout = FutureExt::fuse(Timer::after(REVIEW_TIMEOUT)); let timeout = FutureExt::fuse(Timer::after(REVIEW_TIMEOUT));
futures::pin_mut!(review, timeout); futures::pin_mut!(review, timeout);
let result = futures::select_biased! { result = review => result, _ = timeout => Err(anyhow::anyhow!("assessment timed out")) }; let result = futures::select_biased! { result = review => result, _ = timeout => Err(anyhow::anyhow!("assessment timed out")) };
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
if let Err(error) = &result {
let mut context = activity_context();
context["error"] = serde_json::json!(crate::ai::remote_logging::sanitize_error(error));
crate::ai::activity::record(
"risk_review_error",
"Permission assessment failed",
context,
);
}
}
let _ = sender.try_send(TurnCommand::Cancel); let _ = sender.try_send(TurnCommand::Cancel);
result result
} }
+233 -5
View File
@@ -37,6 +37,7 @@ use persistence::model::ProviderConversationContext;
use session_sharing_protocol::common::ParticipantId; use session_sharing_protocol::common::ParticipantId;
use settings::Setting; use settings::Setting;
pub use slash_command::*; pub use slash_command::*;
use uuid::Uuid;
use warp_multi_agent_api::{message, Task, ToolType}; use warp_multi_agent_api::{message, Task, ToolType};
use warpui::r#async::{SpawnedFutureHandle, Timer}; use warpui::r#async::{SpawnedFutureHandle, Timer};
use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
@@ -53,6 +54,8 @@ use super::orchestration_events::{OrchestrationEventService, OrchestrationEventS
use super::orchestration_topology::descendant_conversation_ids_in_spawn_order; use super::orchestration_topology::descendant_conversation_ids_in_spawn_order;
use super::queued_query::{QueuedQueryId, QueuedQueryModel}; use super::queued_query::{QueuedQueryId, QueuedQueryModel};
use super::{BlocklistAIInputModel, ResponseStreamId}; use super::{BlocklistAIInputModel, ResponseStreamId};
#[cfg(not(target_family = "wasm"))]
use crate::ai::activity;
use crate::ai::agent::api::{self, ServerConversationToken}; use crate::ai::agent::api::{self, ServerConversationToken};
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus}; use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
use crate::ai::agent::task::TaskId; use crate::ai::agent::task::TaskId;
@@ -416,6 +419,49 @@ fn progressive_summary_messages(summary: String) -> Vec<ConversationMessage> {
async fn collect_progressive_summary( async fn collect_progressive_summary(
provider_config: ProviderConfig, provider_config: ProviderConfig,
request: TurnRequest, request: TurnRequest,
mut activity_context: serde_json::Value,
) -> anyhow::Result<(String, Usage)> {
let started_at = Instant::now();
activity_context["model_id"] = serde_json::json!(request.model.as_str());
activity_context["request_id"] = serde_json::json!(Uuid::new_v4().to_string());
activity_context["snapshot_kind"] = serde_json::json!("provider_neutral");
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record_serializable(
"compaction_request",
"Compaction model request",
activity_context.clone(),
&request,
);
}
let result = collect_progressive_summary_inner(provider_config, request).await;
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity_context["elapsed_ms"] = serde_json::json!(started_at.elapsed().as_millis() as u64);
match &result {
Ok((summary, usage)) => activity::record_serializable(
"compaction_response",
"Compaction model response",
activity_context,
&(summary, usage),
),
Err(error) => {
activity_context["error"] =
serde_json::json!(remote_logging::sanitize_error(error));
activity::record(
"compaction_error",
"Compaction request failed",
activity_context,
);
}
}
}
result
}
async fn collect_progressive_summary_inner(
provider_config: ProviderConfig,
request: TurnRequest,
) -> anyhow::Result<(String, Usage)> { ) -> anyhow::Result<(String, Usage)> {
let (command_sender, control) = turn_control(); let (command_sender, control) = turn_control();
let startup = async move { let startup = async move {
@@ -1046,6 +1092,7 @@ enum ProviderCommandResult {
fn convert_provider_tool_batch( fn convert_provider_tool_batch(
action_context: &ProviderActionContext, action_context: &ProviderActionContext,
batch: &PendingToolBatch, batch: &PendingToolBatch,
has_expected_orchestration_event: bool,
) -> (Vec<(AIAgentAction, bool)>, Vec<ToolResult>) { ) -> (Vec<(AIAgentAction, bool)>, Vec<ToolResult>) {
let mut actions = Vec::new(); let mut actions = Vec::new();
let mut invalid_results = Vec::new(); let mut invalid_results = Vec::new();
@@ -1054,7 +1101,18 @@ fn convert_provider_tool_batch(
.iter() .iter()
.filter(|pending| pending.state.result().is_none()) .filter(|pending| pending.state.result().is_none())
{ {
match action_context.action_from_tool_call(&pending.call) { let action = action_context.action_from_tool_call(&pending.call).and_then(|action| {
if matches!(action.action, AIAgentActionType::WaitForEvents { .. }) {
if batch.calls.len() != 1 {
return Err("wait_for_events must be the only tool in its batch. Finish the other tool results first. Background commands deliver completion automatically; do not wait for them with this tool.".to_string());
}
if !has_expected_orchestration_event {
return Err("No child-agent or orchestration notification source is pending. Handle the latest user request now: inspect the requested logs or answer from available evidence. Background commands deliver completion automatically. This tool cannot schedule a follow-up, watch a log file, or wait for a future incident.".to_string());
}
}
Ok(action)
});
match action {
Ok(action) => actions.push(( Ok(action) => actions.push((
action, action,
matches!(pending.state, PendingToolCallState::RecoveryPending), matches!(pending.state, PendingToolCallState::RecoveryPending),
@@ -2212,7 +2270,12 @@ fn provider_run_terminal_remote_log_record(
stream_id: &ResponseStreamId, stream_id: &ResponseStreamId,
run: &ProviderRun, run: &ProviderRun,
outcome: &ProviderRunOutcome, outcome: &ProviderRunOutcome,
model_failure_diagnostics: serde_json::Value,
) -> RemoteLogRecord { ) -> RemoteLogRecord {
let source_error = match outcome {
ProviderRunOutcome::Failed(failure) => failure.source.as_ref(),
ProviderRunOutcome::Completed(_) | ProviderRunOutcome::Cancelled { .. } => None,
};
let (level, outcome_name, llm_finished, stop_reason, failure_kind, error) = match outcome { let (level, outcome_name, llm_finished, stop_reason, failure_kind, error) = match outcome {
ProviderRunOutcome::Completed(completion) => ( ProviderRunOutcome::Completed(completion) => (
RemoteLogLevel::Info, RemoteLogLevel::Info,
@@ -2251,10 +2314,13 @@ fn provider_run_terminal_remote_log_record(
"profile": run.profile().as_str(), "profile": run.profile().as_str(),
"model_turn_count": run.model_turns(), "model_turn_count": run.model_turns(),
"model_retry_count": run.model_retries(), "model_retry_count": run.model_retries(),
"model_failure_diagnostics": model_failure_diagnostics,
"outcome": outcome_name, "outcome": outcome_name,
"stop_reason": stop_reason, "stop_reason": stop_reason,
"failure_kind": failure_kind, "failure_kind": failure_kind,
"error": error, "error": error,
"error_kind": source_error.map(|error| format!("{:?}", error.kind)),
"error_recoverable": source_error.map(|error| error.recoverable),
"llm_finished": llm_finished, "llm_finished": llm_finished,
"provider_run_finished": true, "provider_run_finished": true,
"response_stream_terminal": true, "response_stream_terminal": true,
@@ -5482,6 +5548,17 @@ impl BlocklistAIController {
)); ));
}; };
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
let title = conversation
.title()
.unwrap_or_else(|| "Untitled session".to_string());
let label = conversation
.agent_name()
.map_or_else(|| title.clone(), |name| format!("{name}: {title}"));
activity::register_session(conversation.id().to_string(), label);
}
let active_tasks = conversation.compute_active_tasks(); let active_tasks = conversation.compute_active_tasks();
( (
@@ -7483,6 +7560,7 @@ impl BlocklistAIController {
}), }),
}, },
); );
let summary_activity_identity = identity.clone();
ctx.spawn( ctx.spawn(
async move { async move {
let mut errors = Vec::new(); let mut errors = Vec::new();
@@ -7493,7 +7571,17 @@ impl BlocklistAIController {
); );
request.system_prompt = Some(PROGRESSIVE_SUMMARY_PROMPT.to_string()); request.system_prompt = Some(PROGRESSIVE_SUMMARY_PROMPT.to_string());
request.max_output_tokens = Some(u64::from(PROGRESSIVE_SUMMARY_OUTPUT_TOKENS)); request.max_output_tokens = Some(u64::from(PROGRESSIVE_SUMMARY_OUTPUT_TOKENS));
match collect_progressive_summary(candidate.provider_config, request).await { match collect_progressive_summary(
candidate.provider_config,
request,
serde_json::json!({
"conversation_id": conversation_id,
"provider_run_id": summary_activity_identity.run_id.as_str(),
"stream_id": summary_activity_identity.stream_id.as_str(),
}),
)
.await
{
Ok((summary, usage)) => { Ok((summary, usage)) => {
return (plan, Ok((summary, usage, candidate.request_model_id))); return (plan, Ok((summary, usage, candidate.request_model_id)));
} }
@@ -7764,6 +7852,31 @@ impl BlocklistAIController {
if let Some((usage, summarizer_model_id)) = recorded_usage { if let Some((usage, summarizer_model_id)) = recorded_usage {
self.record_progressive_summary_usage(conversation_id, usage, summarizer_model_id, ctx); self.record_progressive_summary_usage(conversation_id, usage, summarizer_model_id, ctx);
} }
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record(
if summary_applied {
"compaction_applied"
} else {
"compaction_discarded"
},
if summary_applied {
"Compaction applied at model boundary"
} else {
"Compaction was not applied"
},
serde_json::json!({
"conversation_id": conversation_id,
"applied": summary_applied,
"error": failure.as_deref().map(remote_logging::sanitize_error),
"summarized_message_count": plan.split_point,
"trigger_context_tokens": plan.trigger_context_tokens,
"context_tokens_after": failure_context_tokens,
"context_usage_before": plan.context_usage,
"waiting_for_context": waiting_for_context,
}),
);
}
if waiting_for_context && !summary_applied { if waiting_for_context && !summary_applied {
self.fail_active_provider_run( self.fail_active_provider_run(
conversation_id, conversation_id,
@@ -7931,6 +8044,20 @@ impl BlocklistAIController {
conversation_id: AIConversationId, conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
if let Some(conversation) =
BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id)
{
let title = conversation
.title()
.unwrap_or_else(|| "Untitled session".to_string());
let label = conversation
.agent_name()
.map_or_else(|| title.clone(), |name| format!("{name}: {title}"));
activity::register_session(conversation_id.to_string(), label);
}
}
// A follow-up owns a new generation. Its history must never be blocked // A follow-up owns a new generation. Its history must never be blocked
// by, or replaced with, a detached summary from the previous generation. // by, or replaced with, a detached summary from the previous generation.
// Cancellation also discards summaries before they can fail recovery. // Cancellation also discards summaries before they can fail recovery.
@@ -7952,6 +8079,18 @@ impl BlocklistAIController {
.remove(&conversation_id) .remove(&conversation_id)
.is_some() .is_some()
{ {
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record(
"compaction_discarded",
"Compaction generation ended",
serde_json::json!({
"conversation_id": conversation_id,
"stale_generation": stale_summary,
"cancelled": cancelling,
}),
);
}
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| {
if let Some(conversation) = history.conversation_mut(&conversation_id) { if let Some(conversation) = history.conversation_mut(&conversation_id) {
conversation.set_has_pending_progressive_summary(false); conversation.set_has_pending_progressive_summary(false);
@@ -7987,6 +8126,8 @@ impl BlocklistAIController {
let Some(mut run) = slot.run.take() else { let Some(mut run) = slot.run.take() else {
return; return;
}; };
#[cfg(not(target_family = "wasm"))]
run.coordinator.set_activity_session_id(conversation_id);
let checkpoint_template = match ActiveProviderRunCheckpoint::from_active_run(&run) { let checkpoint_template = match ActiveProviderRunCheckpoint::from_active_run(&run) {
Ok(checkpoint) => checkpoint, Ok(checkpoint) => checkpoint,
Err(error) => { Err(error) => {
@@ -8619,6 +8760,25 @@ impl BlocklistAIController {
batch: PendingToolBatch, batch: PendingToolBatch,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
// A generic wait has no way to observe files, incidents, or command output.
// Require a real orchestration source before parking a durable generation.
// Hosted tasks retain owner-side registration for out-of-band children/messages.
let has_expected_orchestration_event = batch.calls.len() == 1
&& batch.calls[0].call.name == "wait_for_events"
&& {
let history = BlocklistAIHistoryModel::as_ref(ctx);
let events = OrchestrationEventService::as_ref(ctx);
#[cfg(not(target_family = "wasm"))]
let has_pending_events = events.has_pending_events(conversation_id);
#[cfg(target_family = "wasm")]
let has_pending_events = false;
history
.conversation(&conversation_id)
.is_some_and(|conversation| conversation.task_id().is_some())
|| !active_descendant_conversation_ids(history, conversation_id).is_empty()
|| events.has_waiting_local_groups(conversation_id, ctx)
|| has_pending_events
};
let Some(run) = self let Some(run) = self
.active_provider_runs .active_provider_runs
.get_mut(&conversation_id) .get_mut(&conversation_id)
@@ -8636,8 +8796,11 @@ impl BlocklistAIController {
.map(|title| (pending.call.id.clone(), title)) .map(|title| (pending.call.id.clone(), title))
}) })
.collect::<HashMap<_, _>>(); .collect::<HashMap<_, _>>();
let (mut converted_actions, invalid_results) = let (mut converted_actions, invalid_results) = convert_provider_tool_batch(
convert_provider_tool_batch(&run.action_context, &batch); &run.action_context,
&batch,
has_expected_orchestration_event,
);
// Seed each worker from the current durable transcript, including discoveries // Seed each worker from the current durable transcript, including discoveries
// made in this run that have not reached the persisted conversation yet. // made in this run that have not reached the persisted conversation yet.
let history = BlocklistAIHistoryModel::as_ref(ctx); let history = BlocklistAIHistoryModel::as_ref(ctx);
@@ -9398,6 +9561,7 @@ impl BlocklistAIController {
&stream_id, &stream_id,
run.coordinator.run(), run.coordinator.run(),
&outcome, &outcome,
run.coordinator.model_failure_diagnostics(),
), ),
); );
let completed_cleanly = matches!( let completed_cleanly = matches!(
@@ -9529,6 +9693,17 @@ impl BlocklistAIController {
if BlocklistAIHistoryModel::as_ref(ctx).conversation_status(&conversation_id) if BlocklistAIHistoryModel::as_ref(ctx).conversation_status(&conversation_id)
!= Some(&ConversationStatus::Success) != Some(&ConversationStatus::Success)
{ {
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record(
"compaction_discarded",
"Compaction discarded for unfinished session",
serde_json::json!({
"conversation_id": conversation_id,
"reason": "conversation did not complete successfully",
}),
);
}
return; return;
} }
@@ -9554,6 +9729,13 @@ impl BlocklistAIController {
"[progressive-summary] Discarding stale idle summary for conversation {:?}; the snapshotted prefix no longer matches", "[progressive-summary] Discarding stale idle summary for conversation {:?}; the snapshotted prefix no longer matches",
conversation_id, conversation_id,
); );
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record("compaction_discarded", "Compaction prefix no longer matches", serde_json::json!({
"conversation_id": conversation_id,
"reason": "snapshotted prefix no longer matches conversation history",
}));
}
return; return;
} }
if !summary_reduces_context { if !summary_reduces_context {
@@ -9561,6 +9743,13 @@ impl BlocklistAIController {
"[progressive-summary] Idle summary did not reduce context for conversation {:?}", "[progressive-summary] Idle summary did not reduce context for conversation {:?}",
conversation_id, conversation_id,
); );
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record("compaction_discarded", "Compaction did not reduce context", serde_json::json!({
"conversation_id": conversation_id,
"reason": "summary did not reduce context",
}));
}
return; return;
} }
@@ -9587,6 +9776,16 @@ impl BlocklistAIController {
conversation_id, conversation_id,
}); });
conversation.write_updated_conversation_state(ctx); conversation.write_updated_conversation_state(ctx);
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record("compaction_applied", "Compaction applied after session completed", serde_json::json!({
"conversation_id": conversation_id,
"context_tokens_after": new_context_tokens,
"context_limit": plan.active_context_limit,
"retained_message_count": retained_count,
"summarized_message_count": plan.split_point,
}));
}
log::info!( log::info!(
"[progressive-summary] Applied background summary after active run completion {:?}: ~{} tokens ({:.1}% of {} context), {} tail messages preserved", "[progressive-summary] Applied background summary after active run completion {:?}: ~{} tokens ({:.1}% of {} context), {} tail messages preserved",
conversation_id, conversation_id,
@@ -11361,6 +11560,21 @@ impl BlocklistAIController {
candidate_names, candidate_names,
); );
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record(
"compaction_started",
"Background compaction started",
serde_json::json!({
"conversation_id": conversation_id,
"summarized_message_count": messages_count,
"retained_message_count": retained_messages_count,
"context_limit": active_context_limit,
"persistent_context_tokens": persistent_context_tokens,
"trigger_usage": PROGRESSIVE_SUMMARY_TRIGGER_USAGE,
}),
);
}
let summarize_messages = vec![ConversationMessage { let summarize_messages = vec![ConversationMessage {
role: MessageRole::User, role: MessageRole::User,
content: MessageContent::Text(summarize_content), content: MessageContent::Text(summarize_content),
@@ -11376,7 +11590,11 @@ impl BlocklistAIController {
); );
request.system_prompt = Some(summarize_prompt.to_string()); request.system_prompt = Some(summarize_prompt.to_string());
request.max_output_tokens = Some(u64::from(PROGRESSIVE_SUMMARY_OUTPUT_TOKENS)); request.max_output_tokens = Some(u64::from(PROGRESSIVE_SUMMARY_OUTPUT_TOKENS));
match collect_progressive_summary(candidate.provider_config, request).await { match collect_progressive_summary(
candidate.provider_config,
request,
serde_json::json!({ "conversation_id": conversation_id }),
).await {
Ok((summary, usage)) => { Ok((summary, usage)) => {
return Ok((summary, usage, candidate.request_model_id)); return Ok((summary, usage, candidate.request_model_id));
} }
@@ -11449,6 +11667,16 @@ impl BlocklistAIController {
conversation.set_context_window_usage(new_usage.clamp(0.0, 1.0)); conversation.set_context_window_usage(new_usage.clamp(0.0, 1.0));
conversation.set_current_context_tokens(new_context_tokens); conversation.set_current_context_tokens(new_context_tokens);
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record("compaction_applied", "Compaction applied to session history", serde_json::json!({
"conversation_id": conversation_id,
"context_tokens_after": new_context_tokens,
"context_limit": active_context_limit,
"summarized_message_count": drain_count,
"retained_message_count": conversation.bedrock_message_history().len(),
}));
}
log::info!( log::info!(
"[progressive-summary] Post-summary: ~{} tokens ({:.1}% of {} context), {} messages retained", "[progressive-summary] Post-summary: ~{} tokens ({:.1}% of {} context), {} messages retained",
new_context_tokens, new_context_tokens,
@@ -430,11 +430,19 @@ impl ResponseStream {
&self, &self,
request_id: Uuid, request_id: Uuid,
payload_kind: &str, payload_kind: &str,
raw_payload: impl AsRef<str>, raw_payload: &impl std::fmt::Debug,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
let Some(raw_payload) = remote_logging::raw_model_payload_context(ctx, |max_chars| { let Some(raw_payload) = remote_logging::raw_model_payload_context(ctx, |max_chars| {
remote_logging::tail_limited_payload_context(raw_payload.as_ref(), max_chars) struct DebugPayload<'a, T>(&'a T);
impl<T: std::fmt::Debug> serde::Serialize for DebugPayload<'_, T> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(&format_args!("{:#?}", self.0))
}
}
remote_logging::prefix_bounded_payload_context(max_chars, |writer| {
serde_json::to_writer_pretty(writer, &DebugPayload(raw_payload))
})
}) else { }) else {
return; return;
}; };
@@ -956,7 +964,7 @@ impl ResponseStream {
self.log_raw_model_response( self.log_raw_model_response(
request_id, request_id,
event_type_name, event_type_name,
format!("{response_event:#?}"), response_event,
ctx, ctx,
); );
if let Some(event_type) = &response_event.r#type { if let Some(event_type) = &response_event.r#type {
+187 -3
View File
@@ -156,6 +156,7 @@ fn provider_terminal_logs_distinguish_clean_completion_from_failure() {
&ProviderRunOutcome::Completed(galaxy_agent_core::ProviderRunCompletion { &ProviderRunOutcome::Completed(galaxy_agent_core::ProviderRunCompletion {
stop_reason: StopReason::Completed, stop_reason: StopReason::Completed,
}), }),
serde_json::json!({"observed_count": 0, "recent": []}),
); );
let failed = super::provider_run_terminal_remote_log_record( let failed = super::provider_run_terminal_remote_log_record(
conversation_id, conversation_id,
@@ -164,8 +165,14 @@ fn provider_terminal_logs_distinguish_clean_completion_from_failure() {
&ProviderRunOutcome::Failed(ProviderRunFailure { &ProviderRunOutcome::Failed(ProviderRunFailure {
kind: ProviderRunFailureKind::RetryLimitExceeded, kind: ProviderRunFailureKind::RetryLimitExceeded,
message: "sk-secret timeout".to_owned(), message: "sk-secret timeout".to_owned(),
source: None, source: Some(galaxy_agent_core::AgentError {
kind: galaxy_agent_core::AgentErrorKind::Transport,
message: "connection failed".to_owned(),
user_message: None,
recoverable: true,
}), }),
}),
serde_json::json!({"observed_count": 1, "recent": [{"error": "connection failed"}]}),
); );
assert_eq!(completed.context["llm_finished"], true); assert_eq!(completed.context["llm_finished"], true);
@@ -176,6 +183,9 @@ fn provider_terminal_logs_distinguish_clean_completion_from_failure() {
assert_eq!(failed.context["llm_finished"], false); assert_eq!(failed.context["llm_finished"], false);
assert_eq!(failed.context["outcome"], "failed"); assert_eq!(failed.context["outcome"], "failed");
assert_eq!(failed.context["failure_kind"], "RetryLimitExceeded"); assert_eq!(failed.context["failure_kind"], "RetryLimitExceeded");
assert_eq!(failed.context["error_kind"], "Transport");
assert_eq!(failed.context["error_recoverable"], true);
assert_eq!(failed.context["model_failure_diagnostics"]["observed_count"], 1);
assert_eq!(failed.context["error"], "[redacted] timeout"); assert_eq!(failed.context["error"], "[redacted] timeout");
} }
@@ -1445,7 +1455,8 @@ fn malformed_provider_tool_inputs_become_correlated_errors_without_dropping_vali
panic!("expected provider tool batch"); panic!("expected provider tool batch");
}; };
let (actions, errors) = super::convert_provider_tool_batch(&snapshot.action_context, &batch); let (actions, errors) =
super::convert_provider_tool_batch(&snapshot.action_context, &batch, false);
assert_eq!(actions.len(), 1); assert_eq!(actions.len(), 1);
assert_eq!(actions[0].0.id.to_string(), "good-grep"); assert_eq!(actions[0].0.id.to_string(), "good-grep");
@@ -1482,7 +1493,8 @@ fn malformed_provider_tool_error_can_be_committed_and_run_continues() {
let Some(ProviderRunStep::DispatchTools(batch)) = snapshot.run.next_step().unwrap() else { let Some(ProviderRunStep::DispatchTools(batch)) = snapshot.run.next_step().unwrap() else {
panic!("expected provider tool batch"); panic!("expected provider tool batch");
}; };
let (actions, errors) = super::convert_provider_tool_batch(&snapshot.action_context, &batch); let (actions, errors) =
super::convert_provider_tool_batch(&snapshot.action_context, &batch, false);
assert!(actions.is_empty()); assert!(actions.is_empty());
snapshot snapshot
@@ -4585,3 +4597,175 @@ fn restored_provider_checkpoint_waits_for_explicit_resume() {
}); });
}); });
} }
fn dispatch_follow_up_tool_batch(
snapshot: &mut super::ActiveProviderRunSnapshot,
calls: Vec<ToolCall>,
) -> galaxy_agent_core::PendingToolBatch {
let Some(ProviderRunStep::CallModel(call)) = snapshot.run.next_step().unwrap() else {
panic!("expected model call");
};
let advertised_tools = calls.iter().map(|call| call.name.clone()).collect();
snapshot
.run
.accept_model_turn(
&call.work_id,
CompletedModelTurn {
assistant_content: Vec::new(),
tool_calls: calls,
usage: Usage::default(),
stop_reason: StopReason::Completed,
advertised_tools,
},
)
.unwrap();
let Some(ProviderRunStep::DispatchTools(batch)) = snapshot.run.next_step().unwrap() else {
panic!("expected tool batch");
};
batch
}
#[test]
fn wait_without_an_event_source_returns_a_correlated_error_and_allows_another_model_turn() {
let mut snapshot = provider_snapshot(AIConversationId::new());
let batch = dispatch_follow_up_tool_batch(
&mut snapshot,
vec![ToolCall {
id: "unsupported-wait".to_owned(),
name: "wait_for_events".to_owned(),
arguments: serde_json::json!({}),
}],
);
let (actions, errors) =
super::convert_provider_tool_batch(&snapshot.action_context, &batch, false);
assert_eq!(actions.len(), 0);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].call_id, "unsupported-wait");
assert_eq!(errors[0].status, galaxy_agent_core::ToolResultStatus::Error);
assert!(errors[0]
.content
.contains("No child-agent or orchestration notification source"));
assert!(errors[0].content.contains("cannot schedule a follow-up"));
snapshot
.run
.complete_tool(&batch.work_id, errors[0].clone())
.unwrap();
snapshot.run.commit_tool_batch(&batch.work_id).unwrap();
let MessageContent::MultiPart(parts) = &snapshot.run.transcript().last().unwrap().content
else {
panic!("expected committed tool result");
};
assert!(matches!(&parts[0], ContentPart::ToolResult {
tool_use_id, is_error: true, ..
} if tool_use_id == "unsupported-wait"));
assert!(matches!(
snapshot.run.next_step().unwrap(),
Some(ProviderRunStep::CallModel(_))
));
}
#[test]
fn sole_wait_with_an_expected_orchestration_notification_remains_executable() {
let mut snapshot = provider_snapshot(AIConversationId::new());
let batch = dispatch_follow_up_tool_batch(
&mut snapshot,
vec![ToolCall {
id: "child-wait".to_owned(),
name: "wait_for_events".to_owned(),
arguments: serde_json::json!({"idle_timeout_seconds": 45}),
}],
);
let (actions, errors) =
super::convert_provider_tool_batch(&snapshot.action_context, &batch, true);
assert_eq!(errors.len(), 0);
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].0.id.to_string(), "child-wait");
assert!(
matches!(&actions[0].0.action, AIAgentActionType::WaitForEvents {
tool_call_id, idle_timeout_seconds: 45,
} if tool_call_id == "child-wait")
);
assert!(
matches!(snapshot.run.state(), ProviderRunState::AwaitingTools { batch } if !batch.is_complete())
);
}
#[test]
fn mixed_shell_launch_and_wait_preserves_the_launch_and_yields_after_every_result_commits() {
let mut snapshot = provider_snapshot(AIConversationId::new());
let batch = dispatch_follow_up_tool_batch(
&mut snapshot,
vec![
ToolCall {
id: "job-launch".to_owned(),
name: "run_shell_command".to_owned(),
arguments: serde_json::json!({"command": "echo ready"}),
},
ToolCall {
id: "mixed-wait".to_owned(),
name: "wait_for_events".to_owned(),
arguments: serde_json::json!({}),
},
],
);
let (actions, errors) =
super::convert_provider_tool_batch(&snapshot.action_context, &batch, true);
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].0.id.to_string(), "job-launch");
assert!(
matches!(&actions[0].0.action, AIAgentActionType::RequestCommandOutput { command, .. } if command == "echo ready")
);
assert_eq!(errors.len(), 1);
assert_eq!(errors[0].call_id, "mixed-wait");
assert_eq!(errors[0].status, galaxy_agent_core::ToolResultStatus::Error);
assert!(errors[0].content.contains("only tool in its batch"));
snapshot
.run
.complete_tool(&batch.work_id, errors[0].clone())
.unwrap();
let error = snapshot.run.commit_tool_batch(&batch.work_id).unwrap_err();
assert!(error.to_string().contains("job-launch"));
assert!(matches!(
snapshot.run.state(),
ProviderRunState::AwaitingTools { .. }
));
snapshot
.run
.start_tool(&batch.work_id, "job-launch")
.unwrap();
snapshot
.run
.complete_tool(
&batch.work_id,
galaxy_agent_core::ToolResult {
call_id: "job-launch".to_owned(),
content: serde_json::json!({
"background_job_started": {"job_id": "job-1", "command": "echo ready"}
})
.to_string(),
status: galaxy_agent_core::ToolResultStatus::Success,
images: Vec::new(),
},
)
.unwrap();
snapshot.run.commit_tool_batch(&batch.work_id).unwrap();
let MessageContent::MultiPart(parts) = &snapshot.run.transcript().last().unwrap().content
else {
panic!("expected both committed tool results");
};
assert_eq!(parts.len(), 2);
assert!(matches!(&parts[0], ContentPart::ToolResult {
tool_use_id, is_error: false, ..
} if tool_use_id == "job-launch"));
assert!(matches!(&parts[1], ContentPart::ToolResult {
tool_use_id, is_error: true, ..
} if tool_use_id == "mixed-wait"));
assert!(
matches!(snapshot.run.next_step().unwrap(), Some(ProviderRunStep::Done(
ProviderRunOutcome::Completed(completion)
)) if completion.stop_reason == StopReason::Completed)
);
}
+77 -2
View File
@@ -118,7 +118,17 @@ impl CrosscheckReviewer {
// Spawn the reviewer request asynchronously // Spawn the reviewer request asynchronously
let conversation_id_for_task = conversation_id; let conversation_id_for_task = conversation_id;
ctx.spawn( ctx.spawn(
Self::invoke_reviewer(agent_output, model_id, provider_config), Self::invoke_reviewer(
agent_output,
model_id,
provider_config,
#[cfg(not(target_family = "wasm"))]
serde_json::json!({
"conversation_id": conversation_id,
"iteration": current_iteration,
"max_iterations": max,
}),
),
move |me, reviewer_response, ctx| { move |me, reviewer_response, ctx| {
me.handle_reviewer_response( me.handle_reviewer_response(
conversation_id_for_task, conversation_id_for_task,
@@ -206,6 +216,70 @@ impl CrosscheckReviewer {
agent_output: String, agent_output: String,
model_id: String, model_id: String,
provider_config: ProviderConfig, provider_config: ProviderConfig,
#[cfg(not(target_family = "wasm"))] mut activity_context: serde_json::Value,
) -> Result<String, String> {
#[cfg(not(target_family = "wasm"))]
let started_at = instant::Instant::now();
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
let effective_model = match &provider_config {
ProviderConfig::OpenAI(config) => config.model.as_deref().unwrap_or(&model_id),
ProviderConfig::Bedrock(_) | ProviderConfig::None => &model_id,
};
activity_context["model_id"] = serde_json::json!(effective_model);
activity_context["request_id"] = serde_json::json!(uuid::Uuid::new_v4().to_string());
activity_context["snapshot_kind"] = serde_json::json!("reviewer_input");
#[derive(serde::Serialize)]
struct ReviewerInput<'a> {
system_prompt: &'a str,
user_prompt_prefix: &'a str,
agent_output: &'a str,
max_output_tokens: u32,
}
crate::ai::activity::record_serializable(
"crosscheck_model_request",
"Crosscheck reviewer request",
activity_context.clone(),
&ReviewerInput {
system_prompt: prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT,
user_prompt_prefix: "Please review the following agent output:\n\n---\n\n",
agent_output: &agent_output,
max_output_tokens: REVIEWER_MAX_OUTPUT_TOKENS,
},
);
}
let response = Self::invoke_reviewer_inner(agent_output, model_id, provider_config).await;
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
activity_context["elapsed_ms"] =
serde_json::json!(started_at.elapsed().as_millis() as u64);
// These compatibility helpers return text only, with no usage report.
activity_context["usage_reported"] = serde_json::json!(false);
match &response {
Ok(text) => crate::ai::activity::record_serializable(
"crosscheck_model_response",
"Crosscheck reviewer response",
activity_context,
text,
),
Err(error) => {
activity_context["error"] =
serde_json::json!(crate::ai::remote_logging::sanitize_error(error));
crate::ai::activity::record(
"crosscheck_model_error",
"Crosscheck reviewer failed",
activity_context,
);
}
}
}
response
}
async fn invoke_reviewer_inner(
agent_output: String,
model_id: String,
provider_config: ProviderConfig,
) -> Result<String, String> { ) -> Result<String, String> {
match provider_config { match provider_config {
ProviderConfig::OpenAI(config) ProviderConfig::OpenAI(config)
@@ -217,7 +291,8 @@ impl CrosscheckReviewer {
reasoning_effort: config.reasoning_effort, reasoning_effort: config.reasoning_effort,
max_output_tokens: Some(u64::from(REVIEWER_MAX_OUTPUT_TOKENS)), max_output_tokens: Some(u64::from(REVIEWER_MAX_OUTPUT_TOKENS)),
}, },
).with_credential_resolver(crate::ai::chatgpt_auth::credential_resolver()); )
.with_credential_resolver(crate::ai::chatgpt_auth::credential_resolver());
runtime runtime
.complete_text( .complete_text(
prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT.to_string(), prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT.to_string(),
+42
View File
@@ -0,0 +1,42 @@
//! Match-mode preparation shared by native and compatibility grep tool calls.
/// Keep regex mode unchanged; literal mode escapes only metacharacters shared
/// by git/grep extended regex, ripgrep, and PowerShell Select-String. This keeps
/// the persisted action and backend execution paths identical in both modes.
pub(crate) fn prepare_grep_queries(queries: Vec<String>, fixed_strings: bool) -> Vec<String> {
if !fixed_strings {
return queries;
}
queries
.into_iter()
.map(|query| {
let mut escaped = String::with_capacity(query.len());
for character in query.chars() {
if matches!(
character,
'\\' | '.'
| '^'
| '$'
| '|'
| '?'
| '*'
| '+'
| '('
| ')'
| '['
| ']'
| '{'
| '}'
) {
escaped.push('\\');
}
escaped.push(character);
}
escaped
})
.collect()
}
#[cfg(test)]
#[path = "grep_query_tests.rs"]
mod tests;
+39
View File
@@ -0,0 +1,39 @@
use super::prepare_grep_queries;
#[test]
fn literal_queries_match_source_punctuation_backslashes_and_unicode_exactly() {
let cases = [
("variantResolutions: []", r"variantResolutions: \[\]"),
("indexAsset(context", r"indexAsset\(context"),
(
r#"describe("duplicate"|managed output|duplicateContentAsset"#,
r#"describe\("duplicate"\|managed output\|duplicateContentAsset"#,
),
(r"C:\repo\file.rs", r"C:\\repo\\file\.rs"),
("🚀 café [数据] (名)", r"🚀 café \[数据\] \(名\)"),
(
r#"owner's $HOME `id` #&-~ name=foo: bar"#,
r#"owner's \$HOME `id` #&-~ name=foo: bar"#,
),
(
"^foo.*(bar|baz)?[0-9]+{2}$",
r"\^foo\.\*\(bar\|baz\)\?\[0-9\]\+\{2\}\$",
),
];
for (literal, escaped) in cases {
let queries = prepare_grep_queries(vec![literal.to_string()], true);
assert_eq!(queries, vec![escaped], "literal: {literal}");
let regex = regex::Regex::new(&queries[0]).unwrap();
assert_eq!(regex.find(literal).unwrap().as_str(), literal);
}
}
#[test]
fn regex_queries_stay_byte_for_byte_unchanged_including_backend_specific_syntax() {
let queries = vec![
r"^(foo|bar)\s+[0-9]+$".to_string(),
r"(?<=prefix)lookbehind".to_string(),
"[invalid".to_string(),
];
assert_eq!(prepare_grep_queries(queries.clone(), false), queries);
}
+3
View File
@@ -4,6 +4,8 @@
//! The side panel Warp AI implementation lives in `super::ai_assistant`. //! The side panel Warp AI implementation lives in `super::ai_assistant`.
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
pub(crate) mod acp; pub(crate) mod acp;
#[cfg(not(target_family = "wasm"))]
pub(crate) mod activity;
pub(crate) mod active_agent_views_model; pub(crate) mod active_agent_views_model;
pub(crate) mod agent; pub(crate) mod agent;
pub(crate) mod agent_conversations_model; pub(crate) mod agent_conversations_model;
@@ -79,6 +81,7 @@ pub mod connected_self_hosted_workers;
pub mod execution_profiles; pub mod execution_profiles;
pub mod facts; pub mod facts;
pub(crate) mod generate_block_title; pub(crate) mod generate_block_title;
pub(crate) mod grep_query;
pub(crate) mod generate_code_review_content; pub(crate) mod generate_code_review_content;
pub(crate) mod loading; pub(crate) mod loading;
pub mod mcp; pub mod mcp;
+3 -2
View File
@@ -136,11 +136,12 @@ fn apply_file_diffs() -> ToolDefinition {
fn grep() -> ToolDefinition { fn grep() -> ToolDefinition {
ToolDefinition { ToolDefinition {
name: "grep".to_string(), name: "grep".to_string(),
description: "Search for up to 3 focused regex patterns per call. Use for exact symbols, strings, imports, and call sites in source code or other text files. Scope to known files or directories when possible; use search_codebase for semantic discovery. Uses git grep in git repos (respects .gitignore) or ripgrep otherwise. Returns up to 1,000 matching line locations and reports when additional matches were omitted. Use read_files afterward to see context around matches.".to_string(), description: "Search for up to 3 focused patterns per call. Regex is the default; set fixed_strings=true for literal source text containing brackets, parentheses, or other regex metacharacters. Use for exact symbols, strings, imports, and call sites in source code or other text files. Scope to known files or directories when possible; use search_codebase for semantic discovery. Uses git grep in git repos (respects .gitignore) or ripgrep otherwise. Returns up to 1,000 matching line locations and reports when additional matches were omitted. Use read_files afterward to see context around matches.".to_string(),
input_schema: serde_json::json!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
"queries": { "type": "array", "maxItems": 3, "items": { "type": "string" }, "description": "Regex patterns to search for (maximum 3 per call)" }, "queries": { "type": "array", "maxItems": 3, "items": { "type": "string" }, "description": "Patterns (literal when fixed_strings=true) to search for (maximum 3 per call)" },
"fixed_strings": { "type": "boolean", "default": false, "description": "Treat every query as literal text instead of regex" },
"path": { "type": "string", "description": "Directory to scope the search to" } "path": { "type": "string", "description": "Directory to scope the search to" }
}, },
"required": ["queries"] "required": ["queries"]
+6 -5
View File
@@ -1718,11 +1718,11 @@ pub(crate) fn tool_name_is_supported(name: &str, supported: &HashSet<api::ToolTy
/// Shared by direct-provider prompts and compatibility/background request prompts. /// Shared by direct-provider prompts and compatibility/background request prompts.
pub(crate) const CODING_DELEGATION_GUIDANCE: &str = "## Coding Delegation\nFor coding tasks, actively look for bounded implementation or review work to offload to available subagents early. Prefer delegation for substantial independent changes or a focused review of completed work; give the child the known files, just enough context, an explicit implementation or research role, the expected deliverable, and a validation check. The parent owns integration and reviews the result, returning corrections to the same child when needed. Do not duplicate delegated implementation while it runs. Keep trivial edits and tightly dependent steps local when delegation would add delay. Research and plan-mode assignments remain read-only. Delegate only when delegation tools are advertised; child agents are leaf workers and must not spawn other agents.\n\n## Dueling Models Review\nAfter a substantial coherent set of authorized code changes, the parent must request an independent read-only subagent review before reporting completion. Skip this workflow for trivial edits. Always select an available reviewer model; provider or model diversity is a preference, never a reason to skip review. Prefer a capable reviewer with a different model and a different provider from the implementer, using only the advertised model catalog. If no suitable different-provider option exists, select the next best capable different model; if only one model is available, use a separate reviewer conversation and disclose that limitation. Do not invent model IDs or provider diversity. Give the reviewer the requirements, changed files and diff scope, implementation model ID, completed checks, and known uncertainties. Ask for concrete correctness, regression, security, or missing-validation findings with file references and evidence; distinguish blocking findings from optional suggestions. The reviewer must inspect the current code and return findings or explicit LGTM, never rubber-stamp a summary. The parent evaluates findings, sends actionable corrections to the same implementer, and sends the revised diff and validation evidence back to the same reviewer. Continue repair and re-review until the reviewer returns LGTM for the current changes. Changes after approval need review again. Do not force stylistic churn, hide disagreements, or claim LGTM without an actual reviewer response. If verification is unavailable or a disagreement requires user input, report the concrete blocker and remaining findings honestly. The parent coordinates this loop; leaf implementers report readiness for parent review and never spawn reviewers themselves.\n\n"; pub(crate) const CODING_DELEGATION_GUIDANCE: &str = "## Coding Delegation\nFor coding tasks, actively look for bounded implementation or review work to offload to available subagents early. Prefer delegation for substantial independent changes or a focused review of completed work; give the child the known files, just enough context, an explicit implementation or research role, the expected deliverable, and a validation check. The parent owns integration and reviews the result, returning corrections to the same child when needed. Do not duplicate delegated implementation while it runs. Keep trivial edits and tightly dependent steps local when delegation would add delay. Research and plan-mode assignments remain read-only. Delegate only when delegation tools are advertised; child agents are leaf workers and must not spawn other agents.\n\n## Dueling Models Review\nAfter a substantial coherent set of authorized code changes, the parent must request an independent read-only subagent review before reporting completion. Skip this workflow for trivial edits. Always select an available reviewer model; provider or model diversity is a preference, never a reason to skip review. Prefer a capable reviewer with a different model and a different provider from the implementer, using only the advertised model catalog. If no suitable different-provider option exists, select the next best capable different model; if only one model is available, use a separate reviewer conversation and disclose that limitation. Do not invent model IDs or provider diversity. Give the reviewer the requirements, changed files and diff scope, implementation model ID, completed checks, and known uncertainties. Ask for concrete correctness, regression, security, or missing-validation findings with file references and evidence; distinguish blocking findings from optional suggestions. The reviewer must inspect the current code and return findings or explicit LGTM, never rubber-stamp a summary. The parent evaluates findings, sends actionable corrections to the same implementer, and sends the revised diff and validation evidence back to the same reviewer. Continue repair and re-review until the reviewer returns LGTM for the current changes. Changes after approval need review again. Do not force stylistic churn, hide disagreements, or claim LGTM without an actual reviewer response. If verification is unavailable or a disagreement requires user input, report the concrete blocker and remaining findings honestly. The parent coordinates this loop; leaf implementers report readiness for parent review and never spawn reviewers themselves.\n\n";
pub(crate) const PROGRESS_COMMUNICATION_GUIDANCE: &str = "## Progress Updates\nFor an active implementation task, work quietly: the normal response to a tool result is the next necessary tool call, with no assistant text. Tool panes show activity. Do not greet, acknowledge instructions, announce intentions, restate the request or plan, recap each completed step, or reassure the user that work continues. This applies equally to the parent and subagents, follow-up instructions, tool results, command polls, and restored or compacted context. These all continue the same task; they do not start a new introduction.\nDuring work, send prose only to ask a necessary user decision, report a concrete blocker that requires user action, or flag a newly discovered urgent consequence the user must act on now. Otherwise save findings for the final result. Before speaking, compare the intended factual content with prior assistant messages and the conversation summary. If it adds no new actionable information, omit it entirely; paraphrasing a previous update still counts as repetition. Track what has already been communicated using that history, without extra tracking tools or files.\nExamples: after a read/search result, call the next tool without saying 'Got it' or 'I will inspect the files'; after a successful edit, run the relevant check without saying 'The change is in place'; while waiting for a command or subagent, use its wait tool without saying 'Still working'; after approval or a follow-up correction, perform the authorized work without another acknowledgement. Do not stop with a promise, an update, or a repeated question when work can continue. Answer explicit user questions, including status requests, briefly and directly, then continue. When finished, report the final outcome once.\n\n"; pub(crate) const PROGRESS_COMMUNICATION_GUIDANCE: &str = "## Progress Updates\nFor an active implementation task, work quietly: the normal response to a tool result is the next necessary tool call, with no assistant text. Tool panes show activity. Do not greet, acknowledge instructions, announce intentions, restate the request or plan, recap each completed step, or reassure the user that work continues. This applies equally to the parent and subagents, follow-up instructions, tool results, command polls, and restored or compacted context. These all continue the same task; they do not start a new introduction.\nDuring work, send prose only to ask a necessary user decision, report a concrete blocker that requires user action, or flag a newly discovered urgent consequence the user must act on now. Otherwise save findings for the final result. Before speaking, compare the intended factual content with prior assistant messages and the conversation summary. If it adds no new actionable information, omit it entirely; paraphrasing a previous update still counts as repetition. Track what has already been communicated using that history, without extra tracking tools or files.\nExamples: after a read/search result, call the next tool without saying 'Got it' or 'I will inspect the files'; after a successful edit, run the relevant check without saying 'The change is in place'; while waiting for an expected child-agent update, use wait_for_events without saying 'Still working'; background-command completion is delivered automatically; after approval or a follow-up correction, perform the authorized work without another acknowledgement. Do not stop with a promise, an update, or a repeated question when work can continue. Answer explicit user questions, including status requests, briefly and directly, then continue. When finished, report the final outcome once.\n\n";
pub(crate) const IMPLEMENTATION_PROGRESS_GUIDANCE: &str = "## Incremental Implementation\nFor an authorized implementation task, reuse the parent handoff's paths, findings, constraints, and acceptance criteria. Search until you have just enough context to make the requested change. Read the immediate implementation and a relevant example or test as needed, then make the smallest coherent change. The parent can review and request follow-up changes; do not try to anticipate every possible review concern before editing, and still validate your own work. Validate that slice before expanding the investigation. Do not inventory every related subsystem before editing. Further discovery must resolve a concrete uncertainty that affects the next change. When a local code change is reversible and a focused build or test can settle an uncertainty, make a small, evidence-based attempt and use the result to refine it instead of searching for complete certainty. Do not guess APIs or edit files you have not inspected. Read known files directly and batch independent focused reads; avoid repeating discovery already supplied by the parent. Prefer the nearest relevant check before a full build. Investigate first when a mistake could destroy data, affect production, expose credentials, or create irreversible external effects. For review-only work, inspect the changed code and its immediate dependencies, then report actionable findings; expand the search only to test a specific suspected defect. For delegated implementation work, include known paths and symbols, established findings, changes to preserve, a concrete first change, and its acceptance check; distinguish known facts from unanswered questions. Do not invent missing discoveries. Research-only, review, and planning requests remain read-only unless the user authorizes changes.\n\n"; pub(crate) const IMPLEMENTATION_PROGRESS_GUIDANCE: &str = "## Incremental Implementation\nFor an authorized implementation task, reuse the parent handoff's paths, findings, constraints, and acceptance criteria. Search until you have just enough context to make the requested change. Read the immediate implementation and a relevant example or test as needed, then make the smallest coherent change. The parent can review and request follow-up changes; do not try to anticipate every possible review concern before editing, and still validate your own work. Validate that slice before expanding the investigation. Do not inventory every related subsystem before editing. Further discovery must resolve a concrete uncertainty that affects the next change. When a local code change is reversible and a focused build or test can settle an uncertainty, make a small, evidence-based attempt and use the result to refine it instead of searching for complete certainty. Do not guess APIs or edit files you have not inspected. Read known files directly and batch independent focused reads; avoid repeating discovery already supplied by the parent. Prefer the nearest relevant check before a full build. Investigate first when a mistake could destroy data, affect production, expose credentials, or create irreversible external effects. For review-only work, inspect the changed code and its immediate dependencies, then report actionable findings; expand the search only to test a specific suspected defect. For delegated implementation work, include known paths and symbols, established findings, changes to preserve, a concrete first change, and its acceptance check; distinguish known facts from unanswered questions. Do not invent missing discoveries. Research-only, review, and planning requests remain read-only unless the user authorizes changes.\n\n";
pub(crate) const SHELL_EXECUTION_GUIDANCE: &str = "## Background Shell Jobs\nEvery agent-requested shell command runs as an independent, noninteractive background job with closed stdin and no terminal. A launch acknowledgment is not an exit result. Never poll or monitor jobs, send process input, open an interactive shell, or use terminal takeover. Galaxy automatically sends the command, logs, and exit status when a job succeeds, fails, or is canceled. Wait for that event before dependent work; independent jobs may be launched together. Do not restart a command merely because it is still running.\nUse noninteractive flags, disable pagers, and keep the job's main process in the foreground: do not append '&', use nohup/disown, or detach child processes. If interaction cannot be avoided, give the exact command to the user to run in their own terminal instead of executing it. Commands typed by users retain their normal interactive terminal behavior. Each job starts in the supplied working directory and inherits the session environment; cd/export changes inside a job do not persist to later jobs. Use explicit paths or a cd within that job when needed. Galaxy stores stdout/stderr in temporary logs; do not hide all output in another file. The user can view retained logs and terminate jobs through /background-jobs. Use interrupt_shell_command only to cancel this conversation's specific background job when requested or when an explicit stop condition is reached.\n\n"; pub(crate) const SHELL_EXECUTION_GUIDANCE: &str = "## Background Shell Jobs\nEvery agent-requested shell command runs as an independent, noninteractive background job with closed stdin and no terminal. A launch acknowledgment is not an exit result. Never poll jobs, send process input, open an interactive shell, or use terminal takeover. Background jobs continue independently; Galaxy automatically sends the command, logs, and exit status when a job succeeds, fails, or is canceled. Wait for that completion delivery before work that depends on the final outcome; independent work may continue. Do not use wait_for_events for background commands or restart a command merely because it is still running. If the user explicitly asks for current status or a log review, inspect the available evidence and answer now. When read_files is available and permitted, read the retained log path for the requested snapshot; do not defer that inspection until command exit or park the user request in a wait.\nUse noninteractive flags, disable pagers, and keep the job's main process in the foreground: do not append '&', use nohup/disown, or detach child processes. If interaction cannot be avoided, give the exact command to the user to run in their own terminal instead of executing it. Commands typed by users retain their normal interactive terminal behavior. Each job starts in the supplied working directory and inherits the session environment; cd/export changes inside a job do not persist to later jobs. Use explicit paths or a cd within that job when needed. Galaxy stores stdout/stderr in temporary logs; do not hide all output in another file. The user can view retained logs and terminate jobs through /background-jobs. Use interrupt_shell_command only to cancel this conversation's specific background job when requested or when an explicit stop condition is reached.\n\n";
fn computer_target_schema() -> serde_json::Value { fn computer_target_schema() -> serde_json::Value {
serde_json::json!({ serde_json::json!({
@@ -1868,11 +1868,12 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
}, },
ToolDefinition { ToolDefinition {
name: "grep".to_string(), name: "grep".to_string(),
description: "Search for regex patterns in files. Use for exact symbols, strings, imports, and call sites in source code or other text files. Scope to known files or directories when possible; use search_codebase for semantic discovery. Uses git grep in git repos (respects .gitignore) or ripgrep otherwise. Returns up to 1,000 matching line locations and reports when additional matches were omitted. Use read_files afterward to see context around matches. Pass ALL patterns you need in one call.".to_string(), description: "Search for patterns in files. Regex is the default; set fixed_strings=true for literal source text containing brackets, parentheses, or other regex metacharacters. Use for exact symbols, strings, imports, and call sites in source code or other text files. Scope to known files or directories when possible; use search_codebase for semantic discovery. Uses git grep in git repos (respects .gitignore) or ripgrep otherwise. Returns up to 1,000 matching line locations and reports when additional matches were omitted. Use read_files afterward to see context around matches. Pass ALL patterns you need in one call.".to_string(),
input_schema: serde_json::json!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
"queries": { "type": "array", "items": { "type": "string" }, "description": "Regex patterns to search for" }, "queries": { "type": "array", "items": { "type": "string" }, "description": "Patterns (literal when fixed_strings=true) to search for" },
"fixed_strings": { "type": "boolean", "default": false, "description": "Treat every query as literal text instead of regex" },
"path": { "type": "string", "description": "Directory to scope the search to" } "path": { "type": "string", "description": "Directory to scope the search to" }
}, },
"required": ["queries"] "required": ["queries"]
@@ -2086,7 +2087,7 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
}, },
ToolDefinition { ToolDefinition {
name: "wait_for_events".to_string(), name: "wait_for_events".to_string(),
description: "Yield after starting child agents or other asynchronous work. Use this when you are waiting for child-agent updates instead of repeating the same investigation yourself.".to_string(), description: "Yield only for an expected update from an already-started child agent or orchestration event source. Call this tool alone after other tool results have committed. Do not use this for background-shell completion, reading or monitoring log files, reminders, or waiting for another user message. Handle a fresh user question or status/log-review request now using available evidence and permitted inspection tools; never park that request in a wait.".to_string(),
input_schema: serde_json::json!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
@@ -697,3 +697,43 @@ fn compatibility_background_acknowledgment_does_not_invent_monitoring() {
}; };
assert_eq!(super::long_running_command_content(&snapshot), content); assert_eq!(super::long_running_command_content(&snapshot), content);
} }
#[test]
fn status_and_log_followups_are_not_parked_behind_event_waits() {
let request = api::Request {
settings: Some(api::request::Settings {
supported_tools: vec![
api::ToolType::RunShellCommand.into(),
api::ToolType::ReadFiles.into(),
api::ToolType::Subagent.into(),
],
..Default::default()
}),
..Default::default()
};
let prompt = extract_system_prompt(&request, &[]).expect("system prompt");
for instruction in [
"Background jobs continue independently",
"Do not use wait_for_events for background commands",
"inspect the available evidence and answer now",
"read the retained log path for the requested snapshot",
"do not defer that inspection until command exit",
] {
assert!(
prompt.contains(instruction),
"missing instruction: {instruction}"
);
}
assert!(!super::PROGRESS_COMMUNICATION_GUIDANCE
.contains("command or subagent, use its wait tool"));
let tools = extract_tools(&request);
let wait = tools
.iter()
.find(|tool| tool.name == "wait_for_events")
.expect("child wait tool");
assert!(wait.description.contains("expected update from an already-started child agent or orchestration event source"));
assert!(wait.description.contains("never park that request in a wait"));
assert!(!wait.description.contains("other asynchronous work"));
assert!(tools.iter().any(|tool| tool.name == "read_files"));
}
@@ -16,6 +16,7 @@ use warp_multi_agent_api::{self as api, ClientAction, ResponseEvent};
use super::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole}; use super::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
use super::diagnostic::BedrockDiagnosticLogger; use super::diagnostic::BedrockDiagnosticLogger;
use crate::ai::agent::api::LegacyEvent; use crate::ai::agent::api::LegacyEvent;
use crate::ai::grep_query::prepare_grep_queries;
use crate::server::server_api::AIApiError; use crate::server::server_api::AIApiError;
fn json_to_prost_struct(value: &serde_json::Value) -> prost_types::Struct { fn json_to_prost_struct(value: &serde_json::Value) -> prost_types::Struct {
@@ -1018,6 +1019,13 @@ pub fn build_tool_call_message(
.collect() .collect()
}) })
.unwrap_or_default(); .unwrap_or_default();
let queries = prepare_grep_queries(
queries,
input
.get("fixed_strings")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false),
);
let path = input let path = input
.get("path") .get("path")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
@@ -522,3 +522,36 @@ fn direct_provider_known_tools_exclude_hosted_only_tools() {
assert!(is_known_tool("recall_tool_history")); assert!(is_known_tool("recall_tool_history"));
assert!(is_known_tool("interrupt_shell_command")); assert!(is_known_tool("interrupt_shell_command"));
} }
#[test]
fn compatibility_grep_literal_mode_uses_the_same_escaped_patterns() {
let tool = tool_from_event(build_tool_call_message(
"task-1",
"grep-1",
"grep",
r#"{"queries":["variantResolutions: []","indexAsset(context"],"fixed_strings":true,"path":"/project/src"}"#,
));
let api::message::tool_call::Tool::Grep(grep) = tool else {
panic!("expected grep tool");
};
assert_eq!(
grep.queries,
vec![r"variantResolutions: \[\]", r"indexAsset\(context"]
);
assert_eq!(grep.path, "/project/src");
}
#[test]
fn compatibility_grep_keeps_default_regex_semantics() {
let tool = tool_from_event(build_tool_call_message(
"task-1",
"grep-1",
"grep",
r#"{"queries":["foo|bar","[unclosed"]}"#,
));
let api::message::tool_call::Tool::Grep(grep) = tool else {
panic!("expected grep tool");
};
assert_eq!(grep.queries, vec!["foo|bar", "[unclosed"]);
}
+142 -9
View File
@@ -17,6 +17,7 @@ use std::{
use chrono::Utc; use chrono::Utc;
use galaxy_core::channel::ChannelState; use galaxy_core::channel::ChannelState;
use galaxyui::{Entity, ModelContext, SingletonEntity, View, ViewContext}; use galaxyui::{Entity, ModelContext, SingletonEntity, View, ViewContext};
use regex::Regex;
use serde::Serialize; use serde::Serialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use settings::Setting; use settings::Setting;
@@ -34,6 +35,15 @@ static REMOTE_LOG_CLIENT: LazyLock<Result<reqwest::Client, String>> = LazyLock::
.map_err(|error| format!("could not create HTTP client: {error}")) .map_err(|error| format!("could not create HTTP client: {error}"))
}); });
const MAX_ERROR_CHARS: usize = 500; const MAX_ERROR_CHARS: usize = 500;
const MAX_ERROR_INPUT_CHARS: usize = 16 * 1024;
static ERROR_URL: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"(?i)https?://[^\s\"'<>]+"#).expect("valid error URL pattern"));
static ERROR_CREDENTIAL: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)[\"'\\]*\b(?:authorization|proxy-authorization|x-api-key|api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|password|secret)\b[\"'\\\s]*[:=]\s*"#)
.expect("valid error credential pattern")
});
static ERROR_BEARER: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r#"(?i)\bbearer\s+[^\s,\"';}\]]+"#).expect("valid bearer pattern"));
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum RemoteLogLevel { pub(crate) enum RemoteLogLevel {
@@ -43,7 +53,7 @@ pub(crate) enum RemoteLogLevel {
} }
impl RemoteLogLevel { impl RemoteLogLevel {
fn as_str(self) -> &'static str { pub(crate) fn as_str(self) -> &'static str {
match self { match self {
Self::Info => "info", Self::Info => "info",
Self::Warn => "warn", Self::Warn => "warn",
@@ -99,6 +109,10 @@ pub(crate) fn log_model_event<M>(ctx: &mut ModelContext<M>, record: RemoteLogRec
where where
M: Entity, M: Entity,
{ {
super::activity::record_log(&record);
if is_raw_payload(&record) && !remote_payloads_enabled(AISettings::as_ref(ctx)) {
return;
}
let Some(config) = RemoteLogConfig::from_settings(AISettings::as_ref(ctx)) else { let Some(config) = RemoteLogConfig::from_settings(AISettings::as_ref(ctx)) else {
return; return;
}; };
@@ -126,6 +140,10 @@ where
/// Views must log without updating AISettings: its singleton cannot be read /// Views must log without updating AISettings: its singleton cannot be read
/// while it is temporarily borrowed by a model update. /// while it is temporarily borrowed by a model update.
pub(crate) fn log_view_event<V: View>(ctx: &mut ViewContext<V>, record: RemoteLogRecord) { pub(crate) fn log_view_event<V: View>(ctx: &mut ViewContext<V>, record: RemoteLogRecord) {
super::activity::record_log(&record);
if is_raw_payload(&record) && !remote_payloads_enabled(AISettings::as_ref(ctx)) {
return;
}
let Some(config) = RemoteLogConfig::from_settings(AISettings::as_ref(ctx)) else { let Some(config) = RemoteLogConfig::from_settings(AISettings::as_ref(ctx)) else {
return; return;
}; };
@@ -202,8 +220,49 @@ fn write_local_log(payload: &RemoteLogPayload) {
} }
pub(crate) fn sanitize_error(error: impl std::fmt::Display) -> String { pub(crate) fn sanitize_error(error: impl std::fmt::Display) -> String {
let compact = error // Stop formatting before scanning/redacting an arbitrarily large upstream
.to_string() // error body. A truncated diagnostic must never traverse the entire body.
struct ErrorPrefix {
text: String,
remaining: usize,
}
impl std::fmt::Write for ErrorPrefix {
fn write_str(&mut self, value: &str) -> std::fmt::Result {
if let Some((boundary, _)) = value.char_indices().nth(self.remaining) {
self.text.push_str(&value[..boundary]);
self.remaining = 0;
Err(std::fmt::Error)
} else {
self.text.push_str(value);
self.remaining -= value.chars().count();
Ok(())
}
}
}
let mut prefix = ErrorPrefix {
text: String::new(),
remaining: MAX_ERROR_INPUT_CHARS,
};
let _ = std::fmt::write(&mut prefix, format_args!("{error}"));
let text = prefix.text;
let safe_urls = ERROR_URL.replace_all(&text, |captures: &regex::Captures<'_>| {
let matched = &captures[0];
let url_text = matched.trim_end_matches([')', ']', '}', ',', ';']);
let suffix = &matched[url_text.len()..];
match reqwest::Url::parse(url_text) {
Ok(mut url) => {
let _ = url.set_username("");
let _ = url.set_password(None);
url.set_query(None);
url.set_fragment(None);
format!("{url}{suffix}")
}
Err(_) => format!("[redacted URL]{suffix}"),
}
});
let safe_credentials = redact_error_credentials(&safe_urls);
let safe_bearers = ERROR_BEARER.replace_all(&safe_credentials, "Bearer [redacted]");
let compact = safe_bearers
.split_whitespace() .split_whitespace()
.map(redact_sensitive_token) .map(redact_sensitive_token)
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -211,6 +270,63 @@ pub(crate) fn sanitize_error(error: impl std::fmt::Display) -> String {
truncate_chars(&compact, MAX_ERROR_CHARS) truncate_chars(&compact, MAX_ERROR_CHARS)
} }
fn redact_error_credentials(text: &str) -> String {
let mut redacted = String::with_capacity(text.len());
let mut cursor = 0;
for assignment in ERROR_CREDENTIAL.find_iter(text) {
// A match inside an already-redacted credential is credential content,
// not a new assignment. Do not copy any of that content back out.
if assignment.start() < cursor {
continue;
}
redacted.push_str(&text[cursor..assignment.end()]);
redacted.push_str("[redacted]");
cursor = assignment.end() + error_credential_value_end(&text[assignment.end()..]);
}
redacted.push_str(&text[cursor..]);
redacted
}
fn error_credential_value_end(value: &str) -> usize {
let escapes = value.bytes().take_while(|byte| *byte == b'\\').count();
if let Some(quote @ (b'"' | b'\'')) = value.as_bytes().get(escapes).copied() {
// JSON strings can themselves contain escaped JSON. A delimiter at
// nesting depth N has 2^N-1 preceding slashes; an escaped quote inside
// its value has a different residue. Preserve the original escaping,
// so a quoted password cannot be broken open by global unescaping.
let span = escapes + 1;
if !span.is_power_of_two() {
return value.len();
}
let mut slashes = 0;
let start = escapes + 1;
for (index, character) in value[start..].char_indices() {
if character == '\\' {
slashes += 1;
continue;
}
if character as u32 == u32::from(quote) && slashes % (2 * span) == escapes {
return start + index + 1;
}
slashes = 0;
}
// An incomplete quoted value may be a truncated provider error body.
return value.len();
}
if value.starts_with('{') || value.starts_with('[') {
let mut values = serde_json::Deserializer::from_str(value).into_iter::<Value>();
return if values.next().is_some_and(|result| result.is_ok()) {
values.byte_offset()
} else {
value.len()
};
}
// An unquoted credential/header has no reliable value terminator. It may
// contain spaces, an authentication scheme, or multiple signed fields.
// Redact through the line boundary instead of guessing and exposing a suffix.
value.find(['\r', '\n']).unwrap_or(value.len())
}
pub(crate) fn raw_model_payload_context<M>( pub(crate) fn raw_model_payload_context<M>(
ctx: &ModelContext<M>, ctx: &ModelContext<M>,
build_payload: impl FnOnce(usize) -> Value, build_payload: impl FnOnce(usize) -> Value,
@@ -219,17 +335,34 @@ where
M: Entity, M: Entity,
{ {
let settings = AISettings::as_ref(ctx); let settings = AISettings::as_ref(ctx);
if !*settings.remote_logging_enabled.value() let remote_enabled = remote_payloads_enabled(settings);
|| !*settings.remote_logging_log_model_payloads.value() let local_enabled = super::activity::is_recording();
|| settings.remote_logging_api_key.value().trim().is_empty() if !remote_enabled && !local_enabled {
{
return None; return None;
} }
let max_chars = (*settings.remote_logging_model_payload_max_chars.value()).max(1); let remote_max = (*settings.remote_logging_model_payload_max_chars.value()).max(1);
let max_chars = if remote_enabled {
remote_max
} else {
super::activity::MAX_PAYLOAD_CHARS
};
Some(build_payload(max_chars)) Some(build_payload(max_chars))
} }
fn remote_payloads_enabled(settings: &AISettings) -> bool {
*settings.remote_logging_enabled.value()
&& *settings.remote_logging_log_model_payloads.value()
&& !settings.remote_logging_api_key.value().trim().is_empty()
}
fn is_raw_payload(record: &RemoteLogRecord) -> bool {
matches!(
record.context.get("event").and_then(Value::as_str),
Some("raw_model_request" | "raw_model_response")
)
}
fn enrich_context(mut context: Value) -> Value { fn enrich_context(mut context: Value) -> Value {
let Value::Object(ref mut map) = context else { let Value::Object(ref mut map) = context else {
return json!({ return json!({
@@ -262,7 +395,7 @@ async fn send_remote_log(config: RemoteLogConfig, payload: RemoteLogPayload) ->
.json(&payload) .json(&payload)
.send() .send()
.await .await
.map_err(|error| format!("request failed: {error}"))?; .map_err(|error| format!("request failed: {}", sanitize_error(error)))?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(format!("server returned HTTP {}", response.status())); return Err(format!("server returned HTTP {}", response.status()));
+103
View File
@@ -24,6 +24,109 @@ fn errors_are_compacted_truncated_and_lightly_redacted() {
assert!(sanitized.chars().count() <= 501); assert!(sanitized.chars().count() <= 501);
} }
#[test]
fn error_redaction_removes_url_credentials_query_tokens_and_header_values() {
let input = "request failed for https://alice:private-password@example.com/v1/chat?api_key=query-value&code=oauth-code#fragment\nAuthorization: Bearer bearer-value\nx-api-key: header-value\n{\"refresh_token\":\"refresh-value\",\"api_key\":\"json-value\"}\nTLS certificate unknown issuer";
let actual = sanitize_error(input);
for secret in [
"alice",
"private-password",
"query-value",
"oauth-code",
"fragment",
"bearer-value",
"header-value",
"refresh-value",
"json-value",
] {
assert!(
!actual.contains(secret),
"credential escaped redaction: {secret}"
);
}
assert!(actual.contains("https://example.com/v1/chat"));
assert!(actual.contains("TLS certificate unknown issuer"));
}
#[test]
fn error_redaction_handles_nested_json_and_preserves_nonsecret_token_counts() {
let actual = sanitize_error(
r#"{\"access_token\":\"private-access\",\"password\":\"private-password\",\"input_tokens\":42} network timed out"#,
);
assert!(!actual.contains("private-access"));
assert!(!actual.contains("private-password"));
assert!(actual.contains("input_tokens"));
assert!(actual.contains("42"));
assert!(actual.contains("network timed out"));
}
#[test]
fn escaped_quotes_and_backslashes_do_not_expose_credential_suffixes() {
for input in [
r#"{"password":"private-\"quoted\"-suffix","input_tokens":42} connection reset"#,
r#"{\"password\":\"private-\\\"quoted\\\"-suffix\",\"input_tokens\":42} connection reset"#,
r#"api_key="private\\path\"quoted-suffix" connection reset"#,
r#"password="private unterminated credential suffix"#,
] {
let actual = sanitize_error(input);
for secret in ["private", "quoted", "suffix", "path"] {
assert!(
!actual.contains(secret),
"credential fragment escaped redaction: {actual}"
);
}
if input.contains("connection reset") {
assert!(actual.contains("connection reset"));
}
}
}
#[test]
fn basic_and_multifield_authorization_values_are_redacted() {
let basic = sanitize_error("Authorization: Basic dXNlcjpwYXNz\nTLS certificate rejected");
assert!(!basic.contains("dXNlcjpwYXNz"));
assert!(basic.contains("TLS certificate rejected"));
let digest = sanitize_error("Proxy-Authorization: Digest username=private-user, response=private-response\nconnection reset");
assert!(!digest.contains("private-user"));
assert!(!digest.contains("private-response"));
assert!(digest.contains("connection reset"));
let password = sanitize_error("password: private password with spaces\nnetwork timeout");
assert!(!password.contains("private password"));
assert!(password.contains("network timeout"));
for prefix in ["Basic ", "Bearer "] {
let password = sanitize_error(format!(
"password: {prefix}private password with spaces\nnetwork timeout"
));
assert!(!password.contains("private password"));
assert!(!password.contains("with spaces"));
assert!(password.contains("network timeout"));
}
let spaced = sanitize_error("Authorization: Bearer\t private-token\nx-api-key: private key with spaces\nnetwork timeout");
assert!(!spaced.contains("private-token"));
assert!(!spaced.contains("private key"));
assert!(spaced.contains("network timeout"));
}
#[test]
fn error_formatting_stops_before_walking_an_unbounded_display() {
use std::cell::Cell;
struct LongError<'a>(&'a Cell<usize>);
impl std::fmt::Display for LongError<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for _ in 0..1_000_000 {
self.0.set(self.0.get() + 1);
f.write_str("é日本🙂 network timed out ")?;
}
Ok(())
}
}
let written = Cell::new(0);
let output = sanitize_error(LongError(&written));
assert!(written.get() < 2_000);
assert!(output.chars().count() <= 501);
assert!(output.contains("network timed out"));
}
#[test] #[test]
fn raw_payload_cap_keeps_tail() { fn raw_payload_cap_keeps_tail() {
assert_eq!( assert_eq!(
+2
View File
@@ -1,5 +1,7 @@
mod child_context; mod child_context;
mod event_translator; mod event_translator;
#[cfg(not(target_family = "wasm"))]
mod model_failure_diagnostics;
mod provider_run_coordinator; mod provider_run_coordinator;
mod rig; mod rig;
mod rig_request; mod rig_request;
@@ -0,0 +1,63 @@
//! Bounded operational evidence retained until a provider run finishes.
//!
//! A failed telemetry POST must not be the only copy of a model failure. The
//! terminal event includes these summaries even when the run later recovers.
//! This is process-local diagnostic state, never provider history/checkpoints.
use std::collections::VecDeque;
use chrono::Utc;
use galaxy_agent_core::AgentError;
use serde_json::{json, Value};
use crate::ai::remote_logging::sanitize_error;
const MAX_FAILURES: usize = 8;
const MAX_ID_CHARS: usize = 256;
#[derive(Default)]
pub(crate) struct ModelFailureDiagnostics {
observed_count: u64,
recent: VecDeque<Value>,
}
impl ModelFailureDiagnostics {
pub(crate) fn record(&mut self, identity: Value, error: &AgentError) {
self.observed_count = self.observed_count.saturating_add(1);
// Select operational identifiers explicitly; never retain a request,
// prompt, provider configuration, or arbitrary caller-supplied context.
let mut record = json!({
"timestamp": Utc::now().to_rfc3339(),
"error_kind": format!("{:?}", error.kind),
"error_recoverable": error.recoverable,
"error": sanitize_error(&error.message),
});
for key in ["provider_run_id", "profile", "runtime_id", "model_id"] {
if let Some(value) = identity.get(key).and_then(Value::as_str) {
record[key] = json!(value.chars().take(MAX_ID_CHARS).collect::<String>());
}
}
for key in ["provider_epoch", "retry_attempt", "elapsed_ms"] {
if let Some(value) = identity.get(key).and_then(Value::as_u64) {
record[key] = json!(value);
}
}
if self.recent.len() == MAX_FAILURES {
self.recent.pop_front();
}
self.recent.push_back(record);
}
pub(crate) fn snapshot(&self) -> Value {
json!({
"scope": "current_process",
"observed_count": self.observed_count,
"truncated": self.observed_count > self.recent.len() as u64,
"recent": self.recent,
})
}
}
#[cfg(test)]
#[path = "model_failure_diagnostics_tests.rs"]
mod tests;
@@ -0,0 +1,58 @@
use galaxy_agent_core::AgentErrorKind;
use super::*;
#[test]
fn retains_sanitized_causes_after_recovery_without_request_content() {
let mut diagnostics = ModelFailureDiagnostics::default();
let mut error = AgentError::new(AgentErrorKind::Transport, "sk-private connection failed");
error.recoverable = true;
diagnostics.record(
json!({
"provider_run_id": "run-1", "provider_epoch": 6,
"retry_attempt": 0, "elapsed_ms": 100,
"model_id": "test-model", "runtime_id": "rig:openai", "profile": "base",
"prompt": "private prompt", "api_key": "private credential",
}),
&error,
);
let snapshot = diagnostics.snapshot();
let serialized = snapshot.to_string();
assert_eq!(snapshot["observed_count"], 1);
assert_eq!(
snapshot["recent"][0]["error"],
"[redacted] connection failed"
);
assert_eq!(snapshot["recent"][0]["error_kind"], "Transport");
assert_eq!(snapshot["recent"][0]["provider_epoch"], 6);
assert_eq!(snapshot["recent"][0]["error_recoverable"], true);
assert!(!serialized.contains("private prompt"));
assert!(!serialized.contains("private credential"));
assert!(!serialized.contains("sk-private"));
}
#[test]
fn retention_keeps_newest_failures_with_explicit_omission_accounting() {
let mut diagnostics = ModelFailureDiagnostics::default();
for epoch in 0..MAX_FAILURES + 2 {
diagnostics.record(
json!({"provider_epoch": epoch, "model_id": "é".repeat(10_000)}),
&AgentError::new(AgentErrorKind::Provider, "overloaded"),
);
}
let snapshot = diagnostics.snapshot();
assert_eq!(snapshot["scope"], "current_process");
assert_eq!(snapshot["observed_count"], MAX_FAILURES as u64 + 2);
assert_eq!(snapshot["truncated"], true);
let recent = snapshot["recent"].as_array().unwrap();
assert_eq!(recent.len(), MAX_FAILURES);
assert_eq!(recent[0]["provider_epoch"], 2);
assert_eq!(
recent[0]["model_id"].as_str().unwrap().chars().count(),
MAX_ID_CHARS
);
assert_eq!(
ModelFailureDiagnostics::default().snapshot()["observed_count"],
0
);
}
+87 -2
View File
@@ -12,12 +12,17 @@ use galaxy_agent_core::{
ModelFailureDisposition, PendingToolBatch, ProviderModelCall, ProviderRequestProfile, ModelFailureDisposition, PendingToolBatch, ProviderModelCall, ProviderRequestProfile,
ProviderRun, ProviderRunFailureKind, ProviderRunId, ProviderRunLimits, ProviderRunOutcome, ProviderRun, ProviderRunFailureKind, ProviderRunId, ProviderRunLimits, ProviderRunOutcome,
ProviderRunPhase, ProviderRunProtocolError, ProviderRunState, ProviderRunStep, RunEpoch, ProviderRunPhase, ProviderRunProtocolError, ProviderRunState, ProviderRunStep, RunEpoch,
RuntimeKind, StopReason, TaskListSnapshot, ToolEvent, TurnCommand, TurnControl, TurnRequest, Usage, RuntimeKind, StopReason, TaskListSnapshot, ToolEvent, TurnCommand, TurnControl, TurnRequest,
Usage,
}; };
use instant::Instant; use instant::Instant;
use warpui::r#async::Timer; use warpui::r#async::Timer;
#[cfg(not(target_family = "wasm"))]
use super::model_failure_diagnostics::ModelFailureDiagnostics;
use super::task_progress::{add_task_list_context, initial_task_list, task_list_from_plan_batch}; use super::task_progress::{add_task_list_context, initial_task_list, task_list_from_plan_batch};
#[cfg(not(target_family = "wasm"))]
use crate::ai::activity;
use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent::conversation::AIConversationId;
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
use crate::ai::model_output_logging; use crate::ai::model_output_logging;
@@ -285,6 +290,10 @@ pub(crate) struct ProviderRunCoordinator {
model_event_idle_timeout: Duration, model_event_idle_timeout: Duration,
context_compaction_requested: bool, context_compaction_requested: bool,
max_context_tokens: Option<u32>, max_context_tokens: Option<u32>,
#[cfg(not(target_family = "wasm"))]
activity_session_id: Option<String>,
#[cfg(not(target_family = "wasm"))]
model_failure_diagnostics: ModelFailureDiagnostics,
} }
impl ProviderRunCoordinator { impl ProviderRunCoordinator {
@@ -327,6 +336,10 @@ impl ProviderRunCoordinator {
model_event_idle_timeout: PROVIDER_MODEL_EVENT_IDLE_TIMEOUT, model_event_idle_timeout: PROVIDER_MODEL_EVENT_IDLE_TIMEOUT,
context_compaction_requested: false, context_compaction_requested: false,
max_context_tokens: None, max_context_tokens: None,
#[cfg(not(target_family = "wasm"))]
activity_session_id: None,
#[cfg(not(target_family = "wasm"))]
model_failure_diagnostics: ModelFailureDiagnostics::default(),
}) })
} }
@@ -336,6 +349,35 @@ impl ProviderRunCoordinator {
self.model_event_idle_timeout = event_idle; self.model_event_idle_timeout = event_idle;
} }
/// Local inspection identity only; never changes provider requests or checkpoints.
#[cfg(not(target_family = "wasm"))]
pub(crate) fn set_activity_session_id(&mut self, conversation_id: AIConversationId) {
self.activity_session_id = Some(conversation_id.to_string());
}
#[cfg(not(target_family = "wasm"))]
pub(crate) fn model_failure_diagnostics(&self) -> serde_json::Value {
self.model_failure_diagnostics.snapshot()
}
#[cfg(not(target_family = "wasm"))]
fn activity_context(
&self,
call: &ProviderModelCall,
profile: &ProviderRunProfile,
) -> serde_json::Value {
serde_json::json!({
"conversation_id": self.activity_session_id,
"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,
"snapshot_kind": "provider_neutral",
})
}
pub(crate) fn run(&self) -> &ProviderRun { pub(crate) fn run(&self) -> &ProviderRun {
&self.run &self.run
} }
@@ -730,6 +772,15 @@ impl ProviderRunCoordinator {
profile.max_context_tokens.or(self.max_context_tokens), profile.max_context_tokens.or(self.max_context_tokens),
self.run.task_list(), self.run.task_list(),
); );
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record_serializable(
"model_request",
"Model request",
self.activity_context(&call, &profile),
&request,
);
}
let started_at = Instant::now(); let started_at = Instant::now();
let (attempt_sender, attempt_control) = turn_control(); let (attempt_sender, attempt_control) = turn_control();
let start_future = profile.runtime.start_turn(request, attempt_control).fuse(); let start_future = profile.runtime.start_turn(request, attempt_control).fuse();
@@ -779,6 +830,8 @@ impl ProviderRunCoordinator {
}; };
futures::pin_mut!(stream); futures::pin_mut!(stream);
let mut buffer = ModelTurnBuffer::default(); let mut buffer = ModelTurnBuffer::default();
#[cfg(not(target_family = "wasm"))]
let mut usage_reported = false;
loop { loop {
let next_event = stream.next().fuse(); let next_event = stream.next().fuse();
@@ -1014,6 +1067,10 @@ impl ProviderRunCoordinator {
return Ok(()); return Ok(());
} }
buffer.usage.clone_from(&usage); buffer.usage.clone_from(&usage);
#[cfg(not(target_family = "wasm"))]
{
usage_reported = true;
}
if !self if !self
.project_or_fail_acknowledged( .project_or_fail_acknowledged(
ProviderRunProjection::ModelEvent { ProviderRunProjection::ModelEvent {
@@ -1056,6 +1113,19 @@ impl ProviderRunCoordinator {
{ {
return Ok(()); return Ok(());
} }
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
let mut context = self.activity_context(&call, &profile);
context["elapsed_ms"] = serde_json::json!(elapsed_millis(started_at));
context["stop_reason"] = serde_json::json!(&reason);
context["usage_reported"] = serde_json::json!(usage_reported);
activity::record_serializable(
"model_response",
"Model response",
context,
&buffer,
);
}
if reason == StopReason::Cancelled { if reason == StopReason::Cancelled {
self.run.cancel("provider model call was cancelled")?; self.run.cancel("provider model call was cancelled")?;
return Ok(()); return Ok(());
@@ -1147,6 +1217,12 @@ impl ProviderRunCoordinator {
where where
F: FnMut(ProviderRunProjection) -> BoxFuture<'static, Result<(), String>>, F: FnMut(ProviderRunProjection) -> BoxFuture<'static, Result<(), String>>,
{ {
#[cfg(not(target_family = "wasm"))]
{
let mut identity = self.activity_context(call, profile);
identity["elapsed_ms"] = serde_json::json!(elapsed_millis(started_at));
self.model_failure_diagnostics.record(identity, &error);
}
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
model_output_logging::log(serde_json::json!({ model_output_logging::log(serde_json::json!({
"event": "model_error", "event": "model_error",
@@ -1159,6 +1235,15 @@ impl ProviderRunCoordinator {
"retry_attempt": call.retry_attempt, "retry_attempt": call.retry_attempt,
"payload": &error, "payload": &error,
})); }));
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
let mut context = self.activity_context(call, profile);
context["elapsed_ms"] = serde_json::json!(elapsed_millis(started_at));
context["error_kind"] = serde_json::json!(&error.kind);
context["error"] =
serde_json::json!(crate::ai::remote_logging::sanitize_error(&error.message));
activity::record("model_error", "Model request failed", context);
}
let error_message = error.message.clone(); let error_message = error.message.clone();
if error.kind == AgentErrorKind::ContextWindowExceeded { if error.kind == AgentErrorKind::ContextWindowExceeded {
self.run.prepare_context_compaction(&call.work_id)?; self.run.prepare_context_compaction(&call.work_id)?;
@@ -1243,7 +1328,7 @@ impl ProviderRunCoordinator {
} }
} }
#[derive(Default)] #[derive(Default, serde::Serialize)]
struct ModelTurnBuffer { struct ModelTurnBuffer {
started: bool, started: bool,
text: String, text: String,
+5 -1
View File
@@ -16,6 +16,7 @@ use crate::ai::agent::{
RunAgentsRequest, SearchCodebaseRequest, ShellCommandDelay, RunAgentsRequest, SearchCodebaseRequest, ShellCommandDelay,
}; };
use crate::ai::document::ai_document_model::AIDocumentId; use crate::ai::document::ai_document_model::AIDocumentId;
use crate::ai::grep_query::prepare_grep_queries;
const MAX_BATCH_READ_ITEMS: usize = 6; const MAX_BATCH_READ_ITEMS: usize = 6;
const MAX_BATCH_SEARCH_ITEMS: usize = 3; const MAX_BATCH_SEARCH_ITEMS: usize = 3;
@@ -83,7 +84,10 @@ pub(crate) fn action_from_tool_call(
title: Some(required_string(input, "summary")?), title: Some(required_string(input, "summary")?),
}, },
"grep" => AIAgentActionType::Grep { "grep" => AIAgentActionType::Grep {
queries: limited_required_strings(input, "queries", MAX_BATCH_SEARCH_ITEMS)?, queries: prepare_grep_queries(
limited_required_strings(input, "queries", MAX_BATCH_SEARCH_ITEMS)?,
optional_boolean(input, "fixed_strings")?.unwrap_or(false),
),
path: optional_string(input, "path")?.unwrap_or_default(), path: optional_string(input, "path")?.unwrap_or_default(),
}, },
"file_glob" => AIAgentActionType::FileGlob { "file_glob" => AIAgentActionType::FileGlob {
+71
View File
@@ -651,3 +651,74 @@ fn mcp_title_metadata_does_not_change_tool_arguments() {
if input == serde_json::json!({"message": "hello", "_galaxy_tool_title": "a real server argument"})) if input == serde_json::json!({"message": "hello", "_galaxy_tool_title": "a real server argument"}))
); );
} }
#[test]
fn grep_literal_mode_escapes_queries_without_changing_scope() {
let action = action_from_tool_call(
"task",
&call(
"grep",
serde_json::json!({
"queries": ["variantResolutions: []", "indexAsset(context"],
"fixed_strings": true,
"path": "/project/src",
}),
),
&SkillPathOrigin::Local,
&HashMap::new(),
)
.unwrap();
let AIAgentActionType::Grep { queries, path } = action.action else {
panic!("expected grep action");
};
assert_eq!(
queries,
vec![r"variantResolutions: \[\]", r"indexAsset\(context"]
);
assert_eq!(path, "/project/src");
}
#[test]
fn grep_default_and_explicit_regex_modes_preserve_patterns() {
for fixed_strings in [None, Some(false)] {
let mut input = serde_json::json!({"queries": ["[unclosed", "foo|bar"]});
if let Some(value) = fixed_strings {
input["fixed_strings"] = value.into();
}
let action = action_from_tool_call(
"task",
&call("grep", input),
&SkillPathOrigin::Local,
&HashMap::new(),
)
.unwrap();
let AIAgentActionType::Grep { queries, .. } = action.action else {
panic!("expected grep action");
};
assert_eq!(queries, vec!["[unclosed", "foo|bar"]);
}
}
#[test]
fn grep_literal_mode_still_rejects_invalid_booleans_and_excessive_batches() {
for (input, expected) in [
(
serde_json::json!({"queries": ["needle"], "fixed_strings": "true"}),
"fixed_strings",
),
(
serde_json::json!({"queries": ["a", "b", "c", "d"], "fixed_strings": true}),
"queries",
),
] {
let error = action_from_tool_call(
"task",
&call("grep", input),
&SkillPathOrigin::Local,
&HashMap::new(),
)
.unwrap_err();
assert!(error.contains(expected), "unexpected error: {error}");
}
}
@@ -104,6 +104,15 @@ pub const BACKGROUND_JOBS: StaticCommand = StaticCommand {
argument: None, argument: None,
}; };
pub const AI_ACTIVITY: StaticCommand = StaticCommand {
name: "/ai-activity",
description: "Inspect model requests, responses, compaction, and tools across sessions",
icon_path: "bundled/svg/dataflow.svg",
availability: Availability::empty(),
auto_enter_ai_mode: false,
argument: None,
};
pub static ADD_PROMPT: LazyLock<StaticCommand> = LazyLock::new(|| StaticCommand { pub static ADD_PROMPT: LazyLock<StaticCommand> = LazyLock::new(|| StaticCommand {
name: "/add-prompt", name: "/add-prompt",
description: "Add new Agent prompt", description: "Add new Agent prompt",
@@ -713,6 +722,7 @@ fn all_commands() -> Vec<StaticCommand> {
if !cfg!(target_family = "wasm") { if !cfg!(target_family = "wasm") {
commands.extend([EDIT.clone(), EXPORT_TO_FILE.clone()]); commands.extend([EDIT.clone(), EXPORT_TO_FILE.clone()]);
commands.push(AI_ACTIVITY);
} }
if cfg!(all(feature = "local_fs", not(target_family = "wasm"))) { if cfg!(all(feature = "local_fs", not(target_family = "wasm"))) {
commands.push(BACKGROUND_JOBS); commands.push(BACKGROUND_JOBS);
+7
View File
@@ -1861,6 +1861,13 @@ pub fn init(app: &mut AppContext) {
) )
.with_enabled(|| ContextFlag::NetworkLogConsole.is_enabled())]); .with_enabled(|| ContextFlag::NetworkLogConsole.is_enabled())]);
#[cfg(not(target_family = "wasm"))]
app.register_editable_bindings([EditableBinding::new(
"workspace:open_ai_activity",
"Show AI Activity",
WorkspaceAction::OpenAIActivity,
)]);
app.register_editable_bindings([EditableBinding::new( app.register_editable_bindings([EditableBinding::new(
"input:clear_screen", "input:clear_screen",
"Clear screen", "Clear screen",
@@ -209,7 +209,8 @@ impl SlashCommandDataSource {
/// Slash commands that are available in CLI agent rich input mode. /// Slash commands that are available in CLI agent rich input mode.
/// Add command names here to make them accessible when composing prompts /// Add command names here to make them accessible when composing prompts
/// for a running CLI agent (Claude Code, Codex, etc.). /// for a running CLI agent (Claude Code, Codex, etc.).
const CLI_AGENT_INPUT_ALLOWED_COMMANDS: &[&str] = &["/prompts", "/skills", "/background-jobs"]; const CLI_AGENT_INPUT_ALLOWED_COMMANDS: &[&str] =
&["/prompts", "/skills", "/background-jobs", "/ai-activity"];
fn is_cloud_mode(&self, ctx: &AppContext) -> bool { fn is_cloud_mode(&self, ctx: &AppContext) -> bool {
self.is_cloud_mode_v2 self.is_cloud_mode_v2
@@ -782,6 +782,9 @@ impl Input {
_background_jobs if command.name == commands::BACKGROUND_JOBS.name => { _background_jobs if command.name == commands::BACKGROUND_JOBS.name => {
ctx.dispatch_typed_action(&TerminalAction::OpenBackgroundJobs { job_id: None }); ctx.dispatch_typed_action(&TerminalAction::OpenBackgroundJobs { job_id: None });
} }
_ai_activity if command.name == commands::AI_ACTIVITY.name => {
ctx.dispatch_typed_action(&WorkspaceAction::OpenAIActivity);
}
_ if command.name == commands::HOST.name => { _ if command.name == commands::HOST.name => {
if !self.is_cloud_mode_input_v2_composing(ctx) { if !self.is_cloud_mode_input_v2_composing(ctx) {
return false; return false;
+4 -1
View File
@@ -875,6 +875,8 @@ pub enum WorkspaceAction {
/// Opens (or focuses) the in-app network log pane as a right-split of the /// Opens (or focuses) the in-app network log pane as a right-split of the
/// active pane group. Gated on `ContextFlag::NetworkLogConsole`. /// active pane group. Gated on `ContextFlag::NetworkLogConsole`.
OpenNetworkLogPane, OpenNetworkLogPane,
/// Opens the app-wide inspector for model, compaction, and tool activity.
OpenAIActivity,
} }
impl From<&WorkspaceAction> for LoginGatedFeature { impl From<&WorkspaceAction> for LoginGatedFeature {
@@ -1195,7 +1197,8 @@ impl WorkspaceAction {
| ShowHandoffEnvironmentCreationModal | ShowHandoffEnvironmentCreationModal
| ShowCloudModeV2EnvironmentCreationModal | ShowCloudModeV2EnvironmentCreationModal
| OpenCreateAuthSecretModal { .. } | OpenCreateAuthSecretModal { .. }
| OpenNetworkLogPane => false, | OpenNetworkLogPane
| OpenAIActivity => false,
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
ShowHoaOnboardingFlow => false, ShowHoaOnboardingFlow => false,
#[cfg(target_family = "wasm")] #[cfg(target_family = "wasm")]
+59
View File
@@ -155,6 +155,8 @@ use super::util::{
}; };
use super::{util, ActiveSession, TabBarDropTargetData, TabBarLocation, WorkspaceRegistry}; use super::{util, ActiveSession, TabBarDropTargetData, TabBarLocation, WorkspaceRegistry};
use crate::ai::active_agent_views_model::ActiveAgentViewsModel; use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
#[cfg(not(target_family = "wasm"))]
use crate::ai::activity::view::AIActivityView;
use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::api::ServerConversationToken;
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
use crate::ai::agent::conversation::AIAgentHarness; use crate::ai::agent::conversation::AIAgentHarness;
@@ -1060,6 +1062,8 @@ pub struct Workspace {
new_worktree_modal: ModalViewState<Modal<NewWorktreeModal>>, new_worktree_modal: ModalViewState<Modal<NewWorktreeModal>>,
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))] #[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
background_jobs_modal: ModalViewState<Modal<BackgroundJobsView>>, background_jobs_modal: ModalViewState<Modal<BackgroundJobsView>>,
#[cfg(not(target_family = "wasm"))]
ai_activity_modal: ModalViewState<Modal<AIActivityView>>,
close_session_confirmation_dialog: ViewHandle<CloseSessionConfirmationDialog>, close_session_confirmation_dialog: ViewHandle<CloseSessionConfirmationDialog>,
rewind_confirmation_dialog: ViewHandle<RewindConfirmationDialog>, rewind_confirmation_dialog: ViewHandle<RewindConfirmationDialog>,
delete_conversation_confirmation_dialog: ViewHandle<DeleteConversationConfirmationDialog>, delete_conversation_confirmation_dialog: ViewHandle<DeleteConversationConfirmationDialog>,
@@ -2181,6 +2185,43 @@ impl Workspace {
ModalViewState::new(modal) ModalViewState::new(modal)
} }
#[cfg(not(target_family = "wasm"))]
fn build_ai_activity_modal(
ctx: &mut ViewContext<Self>,
) -> ModalViewState<Modal<AIActivityView>> {
let body = ctx.add_typed_action_view(AIActivityView::new);
let modal = ctx.add_typed_action_view(|ctx| {
Modal::new(Some("AI Activity".to_owned()), body, ctx)
.with_modal_style(UiComponentStyles {
width: Some(1120.),
height: Some(760.),
..Default::default()
})
.with_header_style(UiComponentStyles {
height: Some(58.),
..Default::default()
})
.with_body_style(UiComponentStyles {
padding: Some(Coords::uniform(12.)),
height: Some(680.),
..Default::default()
})
.with_max_height_percentage(0.9)
.with_dismiss_on_click()
});
ctx.subscribe_to_view(&modal, |me, _, event, ctx| {
if matches!(event, ModalEvent::Close) {
me.ai_activity_modal.close();
me.ai_activity_modal.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |view, ctx| view.on_close(ctx));
});
me.focus_active_tab(ctx);
ctx.notify();
}
});
ModalViewState::new(modal)
}
fn build_remove_tab_config_confirmation_dialog( fn build_remove_tab_config_confirmation_dialog(
ctx: &mut ViewContext<Self>, ctx: &mut ViewContext<Self>,
) -> ViewHandle<RemoveTabConfigConfirmationDialog> { ) -> ViewHandle<RemoveTabConfigConfirmationDialog> {
@@ -3026,6 +3067,8 @@ impl Workspace {
let new_worktree_modal = Self::build_new_worktree_modal(ctx); let new_worktree_modal = Self::build_new_worktree_modal(ctx);
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))] #[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
let background_jobs_modal = Self::build_background_jobs_modal(ctx); let background_jobs_modal = Self::build_background_jobs_modal(ctx);
#[cfg(not(target_family = "wasm"))]
let ai_activity_modal = Self::build_ai_activity_modal(ctx);
let session_config_modal = Self::build_session_config_modal(ctx); let session_config_modal = Self::build_session_config_modal(ctx);
@@ -3427,6 +3470,8 @@ impl Workspace {
new_worktree_modal, new_worktree_modal,
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))] #[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
background_jobs_modal, background_jobs_modal,
#[cfg(not(target_family = "wasm"))]
ai_activity_modal,
close_session_confirmation_dialog, close_session_confirmation_dialog,
rewind_confirmation_dialog, rewind_confirmation_dialog,
delete_conversation_confirmation_dialog, delete_conversation_confirmation_dialog,
@@ -24012,6 +24057,16 @@ impl TypedActionView for Workspace {
OpenNetworkLogPane => { OpenNetworkLogPane => {
self.open_network_log_pane(ctx); self.open_network_log_pane(ctx);
} }
OpenAIActivity => {
#[cfg(not(target_family = "wasm"))]
{
self.ai_activity_modal.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |view, ctx| view.on_open(ctx));
});
self.ai_activity_modal.open();
ctx.notify();
}
}
FixSettingsWithOz { error_description } => { FixSettingsWithOz { error_description } => {
use crate::ai::skills::SkillManager; use crate::ai::skills::SkillManager;
let modify_settings_skill = SkillManager::as_ref(ctx) let modify_settings_skill = SkillManager::as_ref(ctx)
@@ -26869,6 +26924,10 @@ impl View for Workspace {
if self.background_jobs_modal.is_open() { if self.background_jobs_modal.is_open() {
stack.add_child(self.background_jobs_modal.render()); stack.add_child(self.background_jobs_modal.render());
} }
#[cfg(not(target_family = "wasm"))]
if self.ai_activity_modal.is_open() {
stack.add_child(self.ai_activity_modal.render());
}
if self.workflow_modal.as_ref(app).is_open() { if self.workflow_modal.as_ref(app).is_open() {
stack.add_child(ChildView::new(&self.workflow_modal).finish()); stack.add_child(ChildView::new(&self.workflow_modal).finish());
+1 -1
View File
@@ -10,6 +10,7 @@ async-stream.workspace = true
async-trait.workspace = true async-trait.workspace = true
aws-sdk-bedrockruntime.workspace = true aws-sdk-bedrockruntime.workspace = true
base64.workspace = true base64.workspace = true
bytes.workspace = true
futures.workspace = true futures.workspace = true
galaxy_agent_core.workspace = true galaxy_agent_core.workspace = true
http.workspace = true http.workspace = true
@@ -21,6 +22,5 @@ uuid.workspace = true
[dev-dependencies] [dev-dependencies]
aws-smithy-http-client.workspace = true aws-smithy-http-client.workspace = true
bytes.workspace = true
rig-core = { workspace = true, features = ["test-utils"] } rig-core = { workspace = true, features = ["test-utils"] }
tokio = { workspace = true, features = ["macros", "rt"] } tokio = { workspace = true, features = ["macros", "rt"] }
+11 -2
View File
@@ -13,6 +13,8 @@ use rig_core::providers::anthropic;
use rig_core::providers::anthropic::client::AnthropicExt; use rig_core::providers::anthropic::client::AnthropicExt;
use serde_json::json; use serde_json::json;
use crate::http_client::DiagnosticHttpClient;
pub fn is_claude_subscription_token(credential: &str) -> bool { pub fn is_claude_subscription_token(credential: &str) -> bool {
credential.trim().contains("sk-ant-oat") credential.trim().contains("sk-ant-oat")
} }
@@ -64,15 +66,22 @@ impl ProviderBuilder for SubscriptionBuilder {
} }
} }
pub(crate) fn anthropic_client(credential: &str) -> Result<anthropic::Client, String> { pub(crate) fn anthropic_client(
credential: &str,
) -> Result<anthropic::Client<DiagnosticHttpClient>, String> {
let credential = credential.trim(); let credential = credential.trim();
if !is_claude_subscription_token(credential) { if !is_claude_subscription_token(credential) {
return anthropic::Client::new(credential).map_err(|error| error.to_string()); return anthropic::Client::builder()
.api_key(credential)
.http_client(DiagnosticHttpClient::new())
.build()
.map_err(|error| error.to_string());
} }
let headers = subscription_headers(); let headers = subscription_headers();
Client::<SubscriptionProvider>::builder() Client::<SubscriptionProvider>::builder()
.api_key(SubscriptionToken(credential.to_string())) .api_key(SubscriptionToken(credential.to_string()))
.http_headers(headers) .http_headers(headers)
.http_client(DiagnosticHttpClient::new())
.build() .build()
// Never include a credential or raw header-validation error in diagnostics. // Never include a credential or raw header-validation error in diagnostics.
.map_err(|_| "Could not configure Claude subscription authentication.".to_string()) .map_err(|_| "Could not configure Claude subscription authentication.".to_string())
+2
View File
@@ -11,6 +11,7 @@ use galaxy_agent_core::{
use rig_core::client::CompletionClient; use rig_core::client::CompletionClient;
use rig_core::providers::chatgpt::{self, ChatGPTAuth}; use rig_core::providers::chatgpt::{self, ChatGPTAuth};
use crate::http_client::DiagnosticHttpClient;
use crate::request::build_completion_request; use crate::request::build_completion_request;
use crate::stream::start_model_turn; use crate::stream::start_model_turn;
@@ -155,6 +156,7 @@ impl AgentRuntime for ChatGPTSubscriptionRuntime {
.api_key(auth) .api_key(auth)
.allow_device_flow(false) .allow_device_flow(false)
.originator("galaxy") .originator("galaxy")
.http_client(DiagnosticHttpClient::new())
.build() .build()
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?; .map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
let model = client.completion_model(&self.config.model); let model = client.completion_model(&self.config.model);
+224
View File
@@ -0,0 +1,224 @@
//! Keep useful transport causes before Rig reduces SSE errors to their Display text.
use std::error::Error;
use std::fmt;
use bytes::Bytes;
use futures::StreamExt;
use rig_core::http_client::{
self, HttpClientExt, LazyBody, MultipartForm, Request, Response, StreamingResponse,
};
use rig_core::wasm_compat::WasmCompatSend;
#[derive(Clone, Debug, Default)]
pub(crate) struct DiagnosticHttpClient<H = http_client::ReqwestClient>(H);
impl DiagnosticHttpClient {
pub(crate) fn new() -> Self {
Self(http_client::ReqwestClient::default())
}
}
impl<H: HttpClientExt + Clone + 'static> HttpClientExt for DiagnosticHttpClient<H> {
fn send<T, U>(
&self,
request: Request<T>,
) -> impl Future<Output = http_client::Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
where
T: Into<Bytes> + WasmCompatSend,
U: From<Bytes> + WasmCompatSend + 'static,
{
let client = self.0.clone();
let request = request.map(Into::<Bytes>::into);
async move {
let response = client
.send(request)
.await
.map_err(diagnose_transport_error)?;
Ok(response.map(|body| {
Box::pin(async move { body.await.map_err(diagnose_transport_error) }) as LazyBody<U>
}))
}
}
fn send_multipart<U>(
&self,
request: Request<MultipartForm>,
) -> impl Future<Output = http_client::Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
where
U: From<Bytes> + WasmCompatSend + 'static,
{
let client = self.0.clone();
async move {
let response = client
.send_multipart(request)
.await
.map_err(diagnose_transport_error)?;
Ok(response.map(|body| {
Box::pin(async move { body.await.map_err(diagnose_transport_error) }) as LazyBody<U>
}))
}
}
fn send_streaming<T>(
&self,
request: Request<T>,
) -> impl Future<Output = http_client::Result<StreamingResponse>> + WasmCompatSend
where
T: Into<Bytes> + WasmCompatSend,
{
let client = self.0.clone();
let request = request.map(Into::<Bytes>::into);
async move {
let response = client
.send_streaming(request)
.await
.map_err(diagnose_transport_error)?;
Ok(response.map(|body| {
Box::pin(body.map(|chunk| chunk.map_err(diagnose_transport_error)))
as http_client::sse::BoxedStream
}))
}
}
}
// Only static categories and numeric OS codes leave this module. Transport errors
// can contain proxy passwords, URL userinfo/query keys, or arbitrary source text;
// neither Display nor Debug of the original error is safe to forward verbatim.
// HTTP status/body errors pass through unchanged so Rig's provider classification
// and its body-inspection helpers retain their original semantics.
pub(crate) fn diagnose_transport_error(error: http_client::Error) -> http_client::Error {
match error {
http_client::Error::Instance(source) => http_client::Error::Instance(Box::new(
TransportDiagnostic::from_source(source.as_ref()),
)),
other => other,
}
}
#[derive(Debug)]
struct TransportDiagnostic(String);
impl TransportDiagnostic {
fn from_source(source: &(dyn Error + 'static)) -> Self {
const MAX_DEPTH: usize = 12;
const MAX_CAUSES: usize = 4;
let mut causes = Vec::<String>::new();
let mut source = Some(source);
for _ in 0..MAX_DEPTH {
let Some(error) = source else { break };
let text = bounded_error_text(error)
.split_whitespace()
.filter(|word| !word.contains("://"))
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase();
if let Some(category) = transport_category(&text) {
let category = category.to_string();
if !causes.contains(&category) && causes.len() < MAX_CAUSES {
causes.push(category);
}
}
if let Some(error) = error.downcast_ref::<std::io::Error>() {
let detail = match error.raw_os_error() {
Some(code) => format!("I/O {:?} (OS error {code})", error.kind()),
None => format!("I/O {:?}", error.kind()),
};
if !causes.contains(&detail) && causes.len() < MAX_CAUSES {
causes.push(detail);
}
}
source = error.source();
}
if causes.is_empty() {
causes.push("underlying transport cause unavailable".to_string());
}
Self(format!(
"provider request transport failed: {}",
causes.join("; caused by: ")
))
}
}
impl fmt::Display for TransportDiagnostic {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl Error for TransportDiagnostic {}
fn bounded_error_text(error: &(dyn Error + 'static)) -> String {
struct BoundedText(String);
impl fmt::Write for BoundedText {
fn write_str(&mut self, value: &str) -> fmt::Result {
let remaining = 1024usize.saturating_sub(self.0.chars().count());
match value.char_indices().nth(remaining) {
Some((boundary, _)) => {
self.0.push_str(&value[..boundary]);
Err(fmt::Error)
}
None => {
self.0.push_str(value);
Ok(())
}
}
}
}
let mut text = BoundedText(String::new());
let _ = fmt::write(&mut text, format_args!("{error}"));
text.0
}
fn transport_category(text: &str) -> Option<&'static str> {
if text.contains("unknownissuer")
|| text.contains("unknown issuer")
|| text.contains("unknown ca")
{
Some("TLS certificate issuer is not trusted")
} else if text.contains("notvalidforname")
|| text.contains("certificate")
&& (text.contains("hostname") || text.contains("not valid for name"))
{
Some("TLS certificate hostname mismatch")
} else if text.contains("certificate")
&& (text.contains("expired")
|| text.contains("notvalidyet")
|| text.contains("not valid yet"))
{
Some("TLS certificate is expired or not yet valid")
} else if text.contains("certificate")
&& (text.contains("invalid") || text.contains("verify") || text.contains("validation"))
|| text.contains("cert verify")
{
Some("TLS certificate validation failed")
} else if text.contains("dns")
|| text.contains("failed to lookup address")
|| text.contains("name or service not known")
|| text.contains("nodename nor servname")
{
Some("DNS resolution failed")
} else if text.contains("proxy") && (text.contains("authentication") || text.contains("407")) {
Some("proxy authentication failed")
} else if text.contains("connection refused") {
Some("connection refused")
} else if text.contains("connection reset") {
Some("connection reset")
} else if text.contains("timed out") || text.contains("timeout") {
Some("network operation timed out")
} else if text.contains("unreachable") {
Some("network or destination unreachable")
} else if text.contains("tls") || text.contains("ssl") || text.contains("handshake") {
Some("TLS handshake failed")
} else if text.contains("unexpected eof")
|| text.contains("connection closed")
|| text.contains("incomplete message")
{
Some("connection closed before response completed")
} else {
None
}
}
#[cfg(test)]
#[path = "http_client_tests.rs"]
mod tests;
@@ -0,0 +1,253 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use futures::executor::block_on;
use futures::{StreamExt, future, stream};
use http::StatusCode;
use super::*;
#[derive(Debug)]
struct SourceError {
message: &'static str,
source: Option<Box<SourceError>>,
}
impl fmt::Display for SourceError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.message)
}
}
impl Error for SourceError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
self.source.as_deref().map(|source| source as &dyn Error)
}
}
fn transport_failure() -> http_client::Error {
http_client::Error::Instance(Box::new(SourceError {
message: "error sending request for url (https://user:password@example.test/v1?api_key=secret-certificate)",
source: Some(Box::new(SourceError {
message: "failed to lookup address information: Name or service not known; Authorization: Bearer secret-header",
source: None,
})),
}))
}
#[test]
fn nested_dns_cause_survives_without_url_credentials_queries_or_headers() {
let message = diagnose_transport_error(transport_failure()).to_string();
assert_eq!(
message,
"Http client error: provider request transport failed: DNS resolution failed"
);
}
#[test]
fn tls_certificate_cause_is_specific_and_contains_only_static_diagnostics() {
let error = http_client::Error::Instance(Box::new(SourceError {
message: "error sending request for url (https://example.test/?token=private)",
source: Some(Box::new(SourceError {
message: "invalid peer certificate: UnknownIssuer (private-certificate-content)",
source: None,
})),
}));
assert_eq!(
diagnose_transport_error(error).to_string(),
"Http client error: provider request transport failed: TLS certificate issuer is not trusted"
);
}
#[test]
fn unrecognized_causes_never_forward_arbitrary_source_text() {
let error = http_client::Error::Instance(Box::new(SourceError {
message: "Authorization: Bearer secret; api_key=private; password=hidden",
source: None,
}));
assert_eq!(
diagnose_transport_error(error).to_string(),
"Http client error: provider request transport failed: underlying transport cause unavailable"
);
}
#[test]
fn os_errors_retain_numeric_codes_without_the_original_message() {
let message = diagnose_transport_error(http_client::Error::Instance(Box::new(
std::io::Error::from_raw_os_error(111),
)))
.to_string();
assert_eq!(message.contains("OS error 111"), true);
assert_eq!(message.len() < 400, true);
}
#[test]
fn large_unicode_error_display_stops_before_formatting_later_chunks() {
#[derive(Debug)]
struct LargeError {
payload: String,
later_chunks: AtomicUsize,
}
impl fmt::Display for LargeError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(&self.payload)?;
self.later_chunks.fetch_add(1, Ordering::Relaxed);
formatter.write_str("sensitive trailing data")
}
}
impl Error for LargeError {}
let error = LargeError {
payload: "🌌".repeat(100_000),
later_chunks: AtomicUsize::new(0),
};
assert_eq!(bounded_error_text(&error), "🌌".repeat(1024));
assert_eq!(error.later_chunks.load(Ordering::Relaxed), 0);
}
#[test]
fn http_status_and_provider_body_remain_available_for_classification() {
for status in [
StatusCode::UNAUTHORIZED,
StatusCode::TOO_MANY_REQUESTS,
StatusCode::SERVICE_UNAVAILABLE,
] {
let error = diagnose_transport_error(http_client::Error::InvalidStatusCodeWithMessage(
status,
"provider error body".to_owned(),
));
match error {
http_client::Error::InvalidStatusCodeWithMessage(actual_status, body) => {
assert_eq!(actual_status, status);
assert_eq!(body, "provider error body");
}
other => panic!("HTTP response changed error variant: {other:?}"),
}
}
}
#[derive(Clone, Debug)]
struct FakeHttpClient {
calls: Arc<AtomicUsize>,
fail_before_response: bool,
}
impl HttpClientExt for FakeHttpClient {
fn send<T, U>(
&self,
_request: Request<T>,
) -> impl Future<Output = http_client::Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
where
T: Into<Bytes> + WasmCompatSend,
U: From<Bytes> + WasmCompatSend + 'static,
{
self.calls.fetch_add(1, Ordering::Relaxed);
future::ready(if self.fail_before_response {
Err(transport_failure())
} else {
Ok(Response::new(
Box::pin(async { Err(transport_failure()) }) as LazyBody<U>
))
})
}
fn send_multipart<U>(
&self,
request: Request<MultipartForm>,
) -> impl Future<Output = http_client::Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
where
U: From<Bytes> + WasmCompatSend + 'static,
{
self.send(request.map(|_| Bytes::new()))
}
fn send_streaming<T>(
&self,
request: Request<T>,
) -> impl Future<Output = http_client::Result<StreamingResponse>> + WasmCompatSend
where
T: Into<Bytes> + WasmCompatSend,
{
self.calls.fetch_add(1, Ordering::Relaxed);
// The interceptor must forward the approved payload and credential header unchanged.
assert_eq!(request.headers()["authorization"], "Bearer synthetic-key");
assert_eq!(
request.into_body().into(),
Bytes::from_static(b"approved request")
);
future::ready(if self.fail_before_response {
Err(transport_failure())
} else {
let body = stream::iter([
Ok(Bytes::from_static(b"original SSE bytes")),
Err(transport_failure()),
]);
Ok(Response::builder()
.status(StatusCode::OK)
.header("x-request-id", "original-request-id")
.body(Box::pin(body) as http_client::sse::BoxedStream)
.unwrap())
})
}
}
fn request() -> Request<Bytes> {
Request::builder()
.uri("https://example.test/v1?secret=private")
.header("authorization", "Bearer synthetic-key")
.body(Bytes::from_static(b"approved request"))
.unwrap()
}
#[test]
fn streaming_adapter_preserves_response_and_chunks_without_internal_retries() {
block_on(async {
let calls = Arc::new(AtomicUsize::new(0));
let client = DiagnosticHttpClient(FakeHttpClient {
calls: calls.clone(),
fail_before_response: false,
});
let response = client.send_streaming(request()).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()["x-request-id"], "original-request-id");
let mut body = response.into_body();
assert_eq!(
body.next().await.unwrap().unwrap(),
Bytes::from_static(b"original SSE bytes")
);
assert_eq!(
body.next()
.await
.unwrap()
.unwrap_err()
.to_string()
.contains("DNS resolution failed"),
true
);
assert_eq!(body.next().await.is_none(), true);
assert_eq!(calls.load(Ordering::Relaxed), 1);
});
}
#[test]
fn adapter_preserves_causes_before_headers_and_during_lazy_body_reads() {
block_on(async {
let calls = Arc::new(AtomicUsize::new(0));
let client = DiagnosticHttpClient(FakeHttpClient {
calls: calls.clone(),
fail_before_response: true,
});
let error = client.send_streaming(request()).await.err().unwrap();
assert_eq!(error.to_string().contains("DNS resolution failed"), true);
assert_eq!(calls.load(Ordering::Relaxed), 1);
let client = DiagnosticHttpClient(FakeHttpClient {
calls: calls.clone(),
fail_before_response: false,
});
let response = client.send::<_, Bytes>(request()).await.unwrap();
let error = response.into_body().await.unwrap_err();
assert_eq!(error.to_string().contains("DNS resolution failed"), true);
assert_eq!(calls.load(Ordering::Relaxed), 2);
});
}
+1
View File
@@ -3,6 +3,7 @@
mod anthropic_auth; mod anthropic_auth;
mod bedrock; mod bedrock;
mod chatgpt; mod chatgpt;
mod http_client;
mod native; mod native;
mod openai_compatible; mod openai_compatible;
mod request; mod request;
+10 -2
View File
@@ -13,6 +13,7 @@ use rig_core::http_client::{self, HttpClientExt};
use rig_core::providers::gemini; use rig_core::providers::gemini;
use crate::anthropic_auth::{anthropic_client, prepare_subscription_request, restore_tool_names}; use crate::anthropic_auth::{anthropic_client, prepare_subscription_request, restore_tool_names};
use crate::http_client::DiagnosticHttpClient;
use crate::request::build_completion_request; use crate::request::build_completion_request;
use crate::stream::{start_model_completion, start_model_turn}; use crate::stream::{start_model_completion, start_model_turn};
@@ -124,7 +125,11 @@ fn anthropic_reasoning_efforts(entry: &serde_json::Value) -> Vec<String> {
} }
pub async fn discover_gemini_models(api_key: &str) -> Result<Vec<RigModelInfo>, String> { pub async fn discover_gemini_models(api_key: &str) -> Result<Vec<RigModelInfo>, String> {
let client = gemini::Client::new(api_key).map_err(|error| error.to_string())?; let client = gemini::Client::builder()
.api_key(api_key)
.http_client(DiagnosticHttpClient::new())
.build()
.map_err(|error| error.to_string())?;
let models = client let models = client
.list_models() .list_models()
.await .await
@@ -349,7 +354,10 @@ impl AgentRuntime for GeminiRuntime {
"A Gemini API key is required", "A Gemini API key is required",
)); ));
} }
let client = gemini::Client::new(&self.config.api_key) let client = gemini::Client::builder()
.api_key(self.config.api_key.as_str())
.http_client(DiagnosticHttpClient::new())
.build()
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?; .map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
let model = client.completion_model(&self.config.model); let model = client.completion_model(&self.config.model);
let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens); let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens);
@@ -7,6 +7,7 @@ use rig_core::client::CompletionClient;
use rig_core::completion::{CompletionModel, CompletionRequest}; use rig_core::completion::{CompletionModel, CompletionRequest};
use rig_core::providers::openai; use rig_core::providers::openai;
use crate::http_client::DiagnosticHttpClient;
use crate::request::build_completion_request as build_provider_completion_request; use crate::request::build_completion_request as build_provider_completion_request;
use crate::stream::start_model_turn as start_provider_model_turn; use crate::stream::start_model_turn as start_provider_model_turn;
@@ -63,6 +64,7 @@ impl AgentRuntime for OpenAICompatibleRuntime {
// compatibility with unauthenticated local OpenAI-compatible servers. // compatibility with unauthenticated local OpenAI-compatible servers.
.api_key(self.config.api_key.as_deref().unwrap_or_default()) .api_key(self.config.api_key.as_deref().unwrap_or_default())
.base_url(&self.config.base_url) .base_url(&self.config.base_url)
.http_client(DiagnosticHttpClient::new())
.build() .build()
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?; .map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
let model = client.completion_model(&self.config.model); let model = client.completion_model(&self.config.model);
+2 -2
View File
@@ -484,9 +484,9 @@ fn map_completion_error(error: CompletionError) -> AgentError {
} }
}; };
let mut mapped = AgentError::new(kind, error.to_string()); let mut mapped = AgentError::new(kind, error.to_string());
if is_authentication_failure { if kind == AgentErrorKind::Authentication {
mapped.user_message = Some( mapped.user_message = Some(
"AWS credentials for Bedrock are missing or expired. Refresh your AWS login and try again." "Provider authentication failed. Check the credentials and access permissions for this connection, then try again."
.to_string(), .to_string(),
); );
} }
@@ -15,6 +15,22 @@ fn flattened_sse_http_client_error_is_recoverable_transport() {
assert!(mapped.recoverable); assert!(mapped.recoverable);
} }
#[test]
fn safe_transport_diagnostics_survive_rig_flattening_and_remain_retryable() {
let error = crate::http_client::diagnose_transport_error(
rig_core::http_client::Error::Instance(Box::new(std::io::Error::new(
std::io::ErrorKind::ConnectionRefused,
"private-token-not-for-diagnostics",
))),
);
// Rig uses Display text for SSE failures without a provider response status.
let mapped = map_completion_error(CompletionError::ProviderError(error.to_string()));
assert_eq!(mapped.kind, AgentErrorKind::Transport);
assert_eq!(mapped.recoverable, true);
assert_eq!(mapped.message.contains("ConnectionRefused"), true);
assert_eq!(mapped.message.contains("private-token"), false);
}
#[test] #[test]
fn flattened_streamed_provider_overload_is_recoverable() { fn flattened_streamed_provider_overload_is_recoverable() {
let error = CompletionError::ProviderError( let error = CompletionError::ProviderError(
@@ -117,6 +117,22 @@ fn flattened_bedrock_credential_errors_are_authentication_failures() {
} }
} }
#[test]
fn non_aws_authentication_errors_do_not_recommend_an_aws_login() {
for error in [
CompletionError::from_http_response(http::StatusCode::UNAUTHORIZED, "Invalid API key"),
CompletionError::ProviderError("AccessDenied: model access is not permitted".to_owned()),
] {
let mapped = map_completion_error(error);
assert_eq!(mapped.kind, AgentErrorKind::Authentication);
assert_eq!(mapped.recoverable, false);
let message = mapped.user_message.unwrap();
assert_eq!(message.contains("Provider authentication failed"), true);
assert_eq!(message.contains("AWS"), false);
assert_eq!(message.contains("Bedrock"), false);
}
}
#[test] #[test]
fn unrelated_provider_failures_do_not_become_authentication_failures() { fn unrelated_provider_failures_do_not_become_authentication_failures() {
for message in [ for message in [