Compare commits
6
Commits
02af0af55f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fa7f2f00c | ||
|
|
38f1dcdef6 | ||
|
|
4b968f34f8 | ||
|
|
a5eda544c4 | ||
|
|
62df16c605 | ||
|
|
be5433177d |
@@ -50,6 +50,13 @@ Environment variables:
|
||||
- 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.
|
||||
|
||||
### 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
|
||||
|
||||
When investigating Galaxy behavior, retrieve recent remote logs from
|
||||
@@ -87,6 +94,12 @@ zsh -lc '
|
||||
conclusions about retries or repeated discovery.
|
||||
- Inspect `context.app.source_commit` and `source_modified` to identify the running
|
||||
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
|
||||
`context.raw_payload.payload`. Check `truncated` and the accompanying size fields
|
||||
before parsing or treating missing content as evidence. These are Galaxy request
|
||||
|
||||
@@ -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;
|
||||
@@ -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}")));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,8 @@ pub struct BackgroundJobLaunch {
|
||||
pub command: String,
|
||||
pub working_directory: PathBuf,
|
||||
pub shell: Option<ShellLaunchData>,
|
||||
/// Partial session metadata overrides, not a complete exported environment.
|
||||
/// The execution host's environment and shell profile supply other values.
|
||||
pub environment: Option<HashMap<String, String>>,
|
||||
pub host_id: Option<HostId>,
|
||||
}
|
||||
@@ -291,7 +293,7 @@ impl BackgroundJobsModel {
|
||||
if !cwd.is_dir() {
|
||||
return Err("The command working directory is not a directory".to_owned());
|
||||
}
|
||||
let mut command = runtime::command_for_launch(&launch)?;
|
||||
let mut command = runtime::command_for_launch(&launch, &cwd)?;
|
||||
command.current_dir(&cwd);
|
||||
ensure_private_directory(&self.log_directory)
|
||||
.map_err(|error| format!("Cannot create temporary job log directory: {error}"))?;
|
||||
@@ -467,6 +469,13 @@ impl BackgroundJobsModel {
|
||||
id: BackgroundJobId,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) -> 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)
|
||||
&& !self.remote_cancellations.contains_key(&id)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::OsString;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitStatus;
|
||||
use std::sync::Arc;
|
||||
#[cfg(unix)]
|
||||
@@ -304,7 +306,141 @@ impl JobOutcome {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn command_for_launch(launch: &BackgroundJobLaunch) -> Result<Command, String> {
|
||||
const BACKGROUND_ENV_PREFIX: &str = "_GALAXY_BACKGROUND_JOB_";
|
||||
const POWERSHELL_SETUP_KEY: &str = "_GALAXY_BACKGROUND_JOB_SETUP_KEY";
|
||||
const NONINTERACTIVE_ENVIRONMENT: &[(&str, &str)] = &[
|
||||
("CI", "1"),
|
||||
("TERM", "dumb"),
|
||||
("NO_COLOR", "1"),
|
||||
("PAGER", "cat"),
|
||||
("GIT_PAGER", "cat"),
|
||||
("GH_PAGER", "cat"),
|
||||
("SYSTEMD_PAGER", "cat"),
|
||||
("AWS_PAGER", ""),
|
||||
("GIT_TERMINAL_PROMPT", "0"),
|
||||
("GCM_INTERACTIVE", "Never"),
|
||||
("SSH_ASKPASS_REQUIRE", "never"),
|
||||
];
|
||||
|
||||
// Commands are passed as untouched data to constant setup scripts and execute
|
||||
// noninteractively in the owned process tree. Bash/fish use a profile bootstrap;
|
||||
// zsh keeps rc-defined shell state in its noninteractive command worker.
|
||||
// Private environment shuttles preserve explicit overrides through rc files
|
||||
// without exposing their values in argv, files, or captured environment output.
|
||||
const BASH_PROFILE_SETUP: &str = r#"
|
||||
builtin set +xv
|
||||
builtin set +m
|
||||
__galaxy_job_cwd=$1
|
||||
__galaxy_job_prefix=$2
|
||||
__galaxy_job_count=$3
|
||||
builtin shift 3
|
||||
while [ "$__galaxy_job_count" -gt 0 ]; do
|
||||
__galaxy_job_key=$1
|
||||
__galaxy_job_slot="$__galaxy_job_prefix$__galaxy_job_key"
|
||||
builtin declare -p "$__galaxy_job_slot" >/dev/null 2>&1 || exit 126
|
||||
builtin export "$__galaxy_job_key=${!__galaxy_job_slot}" || exit 126
|
||||
builtin unset "$__galaxy_job_slot"
|
||||
builtin shift
|
||||
__galaxy_job_count=$((__galaxy_job_count - 1))
|
||||
done
|
||||
# BASH_ENV/ENV would initialize another profile in the noninteractive child.
|
||||
builtin unset SSH_ASKPASS SUDO_ASKPASS BASH_ENV ENV
|
||||
builtin cd -- "$__galaxy_job_cwd" || exit 126
|
||||
builtin exec -- "$@"
|
||||
"#;
|
||||
|
||||
const ZSH_PROFILE_SETUP: &str = r#"
|
||||
builtin unsetopt XTRACE VERBOSE MONITOR
|
||||
__galaxy_job_cwd=$1
|
||||
__galaxy_job_prefix=$2
|
||||
__galaxy_job_count=$3
|
||||
builtin shift 3
|
||||
__galaxy_job_keys=()
|
||||
while [ "$__galaxy_job_count" -gt 0 ]; do
|
||||
__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"
|
||||
builtin typeset -p "$__galaxy_job_slot" >/dev/null 2>&1 || exit 126
|
||||
builtin export "$__galaxy_job_key=${(P)__galaxy_job_slot}" || exit 126
|
||||
builtin unset "$__galaxy_job_slot"
|
||||
done
|
||||
builtin unset SSH_ASKPASS SUDO_ASKPASS BASH_ENV ENV
|
||||
builtin cd -- "$__galaxy_job_cwd" || exit 126
|
||||
builtin eval -- "$__galaxy_job_command"
|
||||
"#;
|
||||
|
||||
const FISH_PROFILE_SETUP: &str = r#"
|
||||
builtin set -e fish_trace
|
||||
builtin set -l __galaxy_job_cwd "$argv[1]"
|
||||
builtin set -l __galaxy_job_prefix "$argv[2]"
|
||||
builtin set -l __galaxy_job_count "$argv[3]"
|
||||
builtin set -e argv[1..3]
|
||||
while builtin test "$__galaxy_job_count" -gt 0
|
||||
builtin set -l __galaxy_job_key "$argv[1]"
|
||||
builtin set -l __galaxy_job_slot "$__galaxy_job_prefix$__galaxy_job_key"
|
||||
builtin set -q "$__galaxy_job_slot"; or exit 126
|
||||
builtin set -gx "$__galaxy_job_key" "$$__galaxy_job_slot"; or exit 126
|
||||
builtin set -e "$__galaxy_job_slot"
|
||||
builtin set -e argv[1]
|
||||
builtin set __galaxy_job_count (builtin math "$__galaxy_job_count - 1")
|
||||
end
|
||||
for __galaxy_job_key in SSH_ASKPASS SUDO_ASKPASS BASH_ENV ENV
|
||||
builtin set -e "$__galaxy_job_key"
|
||||
end
|
||||
builtin cd -- "$__galaxy_job_cwd"; or exit 126
|
||||
exec $argv
|
||||
"#;
|
||||
|
||||
// PowerShell loads profiles in the noninteractive bootstrap, then starts a
|
||||
// profile-free command host. A real host boundary preserves the original
|
||||
// command's exit semantics, including nonterminating errors and explicit exit.
|
||||
const POWERSHELL_PROFILE_SETUP: &str = r#"
|
||||
Microsoft.PowerShell.Core\Set-PSDebug -Off
|
||||
try {
|
||||
$__galaxyJobKey = [Environment]::GetEnvironmentVariable('_GALAXY_BACKGROUND_JOB_SETUP_KEY', 'Process')
|
||||
if ([String]::IsNullOrEmpty($__galaxyJobKey)) { throw 'Missing background job setup' }
|
||||
$__galaxyJobPayload = [Environment]::GetEnvironmentVariable($__galaxyJobKey, 'Process')
|
||||
[Environment]::SetEnvironmentVariable($__galaxyJobKey, $null, 'Process')
|
||||
[Environment]::SetEnvironmentVariable('_GALAXY_BACKGROUND_JOB_SETUP_KEY', $null, 'Process')
|
||||
$__galaxyJob = ConvertFrom-Json -InputObject $__galaxyJobPayload -ErrorAction Stop
|
||||
$__galaxyJobPayload = $null
|
||||
foreach ($__galaxyJobEntry in $__galaxyJob.environment.PSObject.Properties) {
|
||||
[Environment]::SetEnvironmentVariable($__galaxyJobEntry.Name, [String]$__galaxyJobEntry.Value, 'Process')
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable('SSH_ASKPASS', $null, 'Process')
|
||||
[Environment]::SetEnvironmentVariable('SUDO_ASKPASS', $null, 'Process')
|
||||
Set-Location -LiteralPath $__galaxyJob.cwd -ErrorAction Stop
|
||||
$__galaxyJobProgram = [String]$__galaxyJob.program
|
||||
$__galaxyJobCommand = [String]$__galaxyJob.encoded_command
|
||||
$__galaxyJob = $null
|
||||
} catch {
|
||||
[Console]::Error.WriteLine('Galaxy could not initialize the background command shell.')
|
||||
exit 126
|
||||
}
|
||||
$PSNativeCommandUseErrorActionPreference = $false
|
||||
$LASTEXITCODE = $null
|
||||
& $__galaxyJobProgram -NoLogo -NoProfile -NonInteractive -EncodedCommand $__galaxyJobCommand
|
||||
if ($null -eq $LASTEXITCODE) { exit 126 }
|
||||
exit $LASTEXITCODE
|
||||
"#;
|
||||
|
||||
pub(super) fn command_for_launch(
|
||||
launch: &BackgroundJobLaunch,
|
||||
approved_directory: &Path,
|
||||
) -> Result<Command, String> {
|
||||
let (program, shell_type) = match &launch.shell {
|
||||
Some(ShellLaunchData::Executable { executable_path, shell_type })
|
||||
| Some(ShellLaunchData::MSYS2 { executable_path, shell_type }) => (executable_path.clone(), Some(*shell_type)),
|
||||
@@ -312,7 +448,7 @@ pub(super) fn command_for_launch(launch: &BackgroundJobLaunch) -> Result<Command
|
||||
Some(ShellLaunchData::DockerSandbox { .. }) => return Err("Background jobs cannot run container commands on the host. Run this command yourself inside the sandbox.".to_owned()),
|
||||
None => {
|
||||
#[cfg(unix)]
|
||||
{ (std::path::PathBuf::from("/bin/sh"), None) }
|
||||
{ fallback_unix_shell(std::env::var_os("SHELL")) }
|
||||
#[cfg(windows)]
|
||||
{ (std::path::PathBuf::from(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into())), None) }
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
@@ -325,22 +461,42 @@ pub(super) fn command_for_launch(launch: &BackgroundJobLaunch) -> Result<Command
|
||||
let mut command = Command::new_with_process_group(&program);
|
||||
#[cfg(windows)]
|
||||
command.creation_flags(windows::Win32::System::Threading::CREATE_SUSPENDED.0);
|
||||
let environment = background_environment(launch.environment.as_ref(), cfg!(windows))?;
|
||||
// Precmd currently provides partial metadata (for example KUBECONFIG), not
|
||||
// a complete export snapshot. Preserve the host's HOME, PATH and other vars.
|
||||
if let Some(overrides) = &launch.environment {
|
||||
command.envs(overrides);
|
||||
}
|
||||
// CI/TERM guards can deliberately skip rc initialization. Apply the full
|
||||
// job environment after profiles, while suppressing auth prompts throughout.
|
||||
command
|
||||
.env("SSH_ASKPASS_REQUIRE", "never")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GCM_INTERACTIVE", "Never");
|
||||
match shell_type {
|
||||
Some(ShellType::Bash) => {
|
||||
command.args(["--noprofile", "--norc", "-c"]);
|
||||
}
|
||||
Some(ShellType::Zsh) => {
|
||||
command.args(["-f", "-c"]);
|
||||
}
|
||||
Some(ShellType::Fish) => {
|
||||
command.args(["--no-config", "-c"]);
|
||||
Some(kind @ (ShellType::Bash | ShellType::Zsh | ShellType::Fish)) => {
|
||||
configure_profile_bootstrap(
|
||||
&mut command,
|
||||
launch,
|
||||
approved_directory,
|
||||
&program,
|
||||
kind,
|
||||
&environment,
|
||||
)?;
|
||||
}
|
||||
Some(ShellType::PowerShell) => {
|
||||
command.args(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command"]);
|
||||
configure_powershell_bootstrap(
|
||||
&mut command,
|
||||
launch,
|
||||
approved_directory,
|
||||
&program,
|
||||
&environment,
|
||||
)?;
|
||||
}
|
||||
None => {
|
||||
command.envs(&environment);
|
||||
#[cfg(unix)]
|
||||
command.arg("-c");
|
||||
command.arg("-c").arg(&launch.command);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use command::windows::CommandExt;
|
||||
@@ -348,28 +504,6 @@ pub(super) fn command_for_launch(launch: &BackgroundJobLaunch) -> Result<Command
|
||||
}
|
||||
}
|
||||
}
|
||||
if shell_type.is_some() || cfg!(unix) {
|
||||
command.arg(&launch.command);
|
||||
}
|
||||
let mut environment = HashMap::from([
|
||||
("CI", "1"),
|
||||
("TERM", "dumb"),
|
||||
("NO_COLOR", "1"),
|
||||
("PAGER", "cat"),
|
||||
("GIT_PAGER", "cat"),
|
||||
("GH_PAGER", "cat"),
|
||||
("SYSTEMD_PAGER", "cat"),
|
||||
("AWS_PAGER", ""),
|
||||
("GIT_TERMINAL_PROMPT", "0"),
|
||||
("GCM_INTERACTIVE", "Never"),
|
||||
("SSH_ASKPASS_REQUIRE", "never"),
|
||||
]);
|
||||
if let Some(snapshot) = &launch.environment {
|
||||
// The shell integration supplies its complete exported environment. Inheriting
|
||||
// the app's environment here would restore variables explicitly unset in that shell.
|
||||
command.env_clear().envs(snapshot);
|
||||
}
|
||||
command.envs(environment.drain());
|
||||
command.env_remove("SSH_ASKPASS").env_remove("SUDO_ASKPASS");
|
||||
command.stdin(command::Stdio::null());
|
||||
// On Unix ProcessGuard kills the group before dropping/reaping Child. A separate
|
||||
@@ -378,3 +512,156 @@ pub(super) fn command_for_launch(launch: &BackgroundJobLaunch) -> Result<Command
|
||||
command.kill_on_drop(true);
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn fallback_unix_shell(host_shell: Option<OsString>) -> (PathBuf, Option<ShellType>) {
|
||||
if let Some(shell) = host_shell.filter(|shell| Path::new(shell).is_absolute()) {
|
||||
if let Some(kind) = shell.to_str().and_then(ShellType::from_name) {
|
||||
return (PathBuf::from(shell), Some(kind));
|
||||
}
|
||||
}
|
||||
(PathBuf::from("/bin/sh"), None)
|
||||
}
|
||||
|
||||
fn background_environment(
|
||||
overrides: Option<&HashMap<String, String>>,
|
||||
case_insensitive: bool,
|
||||
) -> Result<HashMap<String, String>, String> {
|
||||
let mut environment = overrides.cloned().unwrap_or_default();
|
||||
if environment
|
||||
.iter()
|
||||
.any(|(name, value)| !valid_override_name(name) || value.contains('\0'))
|
||||
{
|
||||
return Err("The background command has an invalid environment override".to_owned());
|
||||
}
|
||||
for (name, value) in NONINTERACTIVE_ENVIRONMENT {
|
||||
if case_insensitive {
|
||||
environment.retain(|key, _| !key.eq_ignore_ascii_case(name));
|
||||
}
|
||||
environment.insert((*name).to_owned(), (*value).to_owned());
|
||||
}
|
||||
Ok(environment)
|
||||
}
|
||||
|
||||
fn valid_override_name(name: &str) -> bool {
|
||||
let mut bytes = name.bytes();
|
||||
let valid_start = bytes
|
||||
.next()
|
||||
.is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_');
|
||||
valid_start
|
||||
&& bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
|
||||
&& !name.to_ascii_uppercase().starts_with(BACKGROUND_ENV_PREFIX)
|
||||
&& !name.to_ascii_lowercase().starts_with("__galaxy_job_")
|
||||
&& name != "argv"
|
||||
&& name != "fish_trace"
|
||||
}
|
||||
|
||||
fn configure_profile_bootstrap(
|
||||
command: &mut Command,
|
||||
launch: &BackgroundJobLaunch,
|
||||
approved_directory: &Path,
|
||||
program: &Path,
|
||||
shell_type: ShellType,
|
||||
environment: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let msys = matches!(&launch.shell, Some(ShellLaunchData::MSYS2 { .. }));
|
||||
let prefix = format!("{BACKGROUND_ENV_PREFIX}{}_", uuid::Uuid::new_v4().simple());
|
||||
let mut keys = environment.keys().collect::<Vec<_>>();
|
||||
keys.sort();
|
||||
let (setup, inner_flags): (&str, &[&str]) = match shell_type {
|
||||
ShellType::Bash => (BASH_PROFILE_SETUP, &["--noprofile", "--norc", "-c"]),
|
||||
ShellType::Zsh => (ZSH_PROFILE_SETUP, &[]),
|
||||
ShellType::Fish => (FISH_PROFILE_SETUP, &["--no-config", "-c"]),
|
||||
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"]);
|
||||
if shell_type == ShellType::Bash {
|
||||
// No terminal is attached: profile initialization must not create
|
||||
// separate interactive job-control groups before the owned exec.
|
||||
command.arg("+m");
|
||||
}
|
||||
command.args(["-c", setup]);
|
||||
// Bash reserves this as $0; fish uses every following argument as $argv.
|
||||
if shell_type == ShellType::Bash {
|
||||
command.arg("galaxy-background-job");
|
||||
}
|
||||
}
|
||||
command.arg(shell_path_argument(approved_directory, msys)?);
|
||||
command.arg(&prefix).arg(keys.len().to_string());
|
||||
for key in keys {
|
||||
command.arg(key);
|
||||
command.env(format!("{prefix}{key}"), &environment[key]);
|
||||
}
|
||||
if shell_type != ShellType::Zsh {
|
||||
command.arg(shell_path_argument(program, msys)?);
|
||||
command.args(inner_flags);
|
||||
}
|
||||
command.arg(&launch.command);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shell_path_argument(path: &Path, msys: bool) -> Result<OsString, String> {
|
||||
if !msys {
|
||||
return Ok(path.as_os_str().to_owned());
|
||||
}
|
||||
let value = path
|
||||
.to_str()
|
||||
.ok_or("The MSYS2 background command path is not valid Unicode")?;
|
||||
let value = if let Some(unc) = value.strip_prefix(r"\\?\UNC\") {
|
||||
format!(r"\\{unc}")
|
||||
} else {
|
||||
value.strip_prefix(r"\\?\").unwrap_or(value).to_owned()
|
||||
};
|
||||
Ok(galaxy_util::path::convert_windows_path_to_msys2(&value).into())
|
||||
}
|
||||
|
||||
fn configure_powershell_bootstrap(
|
||||
command: &mut Command,
|
||||
launch: &BackgroundJobLaunch,
|
||||
approved_directory: &Path,
|
||||
program: &Path,
|
||||
environment: &HashMap<String, String>,
|
||||
) -> Result<(), String> {
|
||||
let cwd = approved_directory
|
||||
.to_str()
|
||||
.ok_or("The PowerShell command directory is not valid Unicode")?;
|
||||
let program = program
|
||||
.to_str()
|
||||
.ok_or("The PowerShell executable path is not valid Unicode")?;
|
||||
let payload = serde_json::to_string(&serde_json::json!({
|
||||
"program": program,
|
||||
"encoded_command": encode_powershell_command(&launch.command),
|
||||
"cwd": cwd,
|
||||
"environment": environment,
|
||||
}))
|
||||
.map_err(|_| "Could not prepare the background command environment".to_owned())?;
|
||||
let key = format!(
|
||||
"{BACKGROUND_ENV_PREFIX}{}_SETUP",
|
||||
uuid::Uuid::new_v4().simple()
|
||||
);
|
||||
command.env(POWERSHELL_SETUP_KEY, &key).env(&key, payload);
|
||||
let encoded = encode_powershell_command(POWERSHELL_PROFILE_SETUP);
|
||||
command.args(["-NoLogo", "-NonInteractive", "-EncodedCommand", &encoded]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encode_powershell_command(script: &str) -> String {
|
||||
use base64::Engine as _;
|
||||
|
||||
base64::engine::general_purpose::STANDARD.encode(
|
||||
script
|
||||
.encode_utf16()
|
||||
.flat_map(u16::to_le_bytes)
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "runtime_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn partial_environment_overrides_preserve_inheritance_and_protected_guards() {
|
||||
let overrides = HashMap::from([
|
||||
("KUBECONFIG".to_owned(), "/session/config".to_owned()),
|
||||
("CI".to_owned(), "profile-value".to_owned()),
|
||||
]);
|
||||
let environment = background_environment(Some(&overrides), false).unwrap();
|
||||
assert_eq!(
|
||||
environment.get("KUBECONFIG").map(String::as_str),
|
||||
Some("/session/config")
|
||||
);
|
||||
for (key, value) in NONINTERACTIVE_ENVIRONMENT {
|
||||
assert_eq!(environment.get(*key).map(String::as_str), Some(*value));
|
||||
}
|
||||
// These remain inherited from the execution host, not fabricated or cleared
|
||||
// when shell integration supplies an empty/partial map.
|
||||
assert!(!background_environment(Some(&HashMap::new()), false)
|
||||
.unwrap()
|
||||
.contains_key("HOME"));
|
||||
assert!(!environment.contains_key("PATH"));
|
||||
let windows =
|
||||
background_environment(Some(&HashMap::from([("ci".into(), "wrong".into())])), true)
|
||||
.unwrap();
|
||||
assert_eq!(windows.get("CI").map(String::as_str), Some("1"));
|
||||
assert!(!windows.contains_key("ci"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_override_names_cannot_inject_shell_syntax_or_private_setup() {
|
||||
for name in [
|
||||
"",
|
||||
"1BAD",
|
||||
"A;echo bad",
|
||||
"A=B",
|
||||
"A\0B",
|
||||
"_GALAXY_BACKGROUND_JOB_SETUP_KEY",
|
||||
"__galaxy_job_count",
|
||||
"argv",
|
||||
] {
|
||||
let overrides = HashMap::from([(name.to_owned(), "test-value".to_owned())]);
|
||||
assert!(background_environment(Some(&overrides), false).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn fallback_shell_uses_only_a_recognized_absolute_host_shell() {
|
||||
assert_eq!(
|
||||
fallback_unix_shell(Some("/usr/bin/zsh".into())),
|
||||
(PathBuf::from("/usr/bin/zsh"), Some(ShellType::Zsh))
|
||||
);
|
||||
assert_eq!(
|
||||
fallback_unix_shell(Some("/usr/bin/fish".into())),
|
||||
(PathBuf::from("/usr/bin/fish"), Some(ShellType::Fish))
|
||||
);
|
||||
for value in [None, Some("bash".into()), Some("/bin/unknown-shell".into())] {
|
||||
assert_eq!(fallback_unix_shell(value), (PathBuf::from("/bin/sh"), None));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn msys_bootstrap_paths_use_posix_drive_and_unc_forms() {
|
||||
assert_eq!(
|
||||
shell_path_argument(Path::new(r"C:\Program Files\Git\bin\bash.exe"), true).unwrap(),
|
||||
OsString::from("/c/Program Files/Git/bin/bash.exe")
|
||||
);
|
||||
assert_eq!(
|
||||
shell_path_argument(Path::new(r"\\?\C:\Users\name\project"), true).unwrap(),
|
||||
OsString::from("/c/Users/name/project")
|
||||
);
|
||||
assert_eq!(
|
||||
shell_path_argument(Path::new(r"\\?\UNC\server\share\project"), true).unwrap(),
|
||||
OsString::from("//server/share/project")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn fixture_launch(
|
||||
shell: PathBuf,
|
||||
shell_type: ShellType,
|
||||
command: &str,
|
||||
cwd: &Path,
|
||||
) -> BackgroundJobLaunch {
|
||||
BackgroundJobLaunch {
|
||||
conversation_id: crate::ai::agent::conversation::AIConversationId::new(),
|
||||
action_id: "profile-test-action".to_owned().into(),
|
||||
command: command.to_owned(),
|
||||
working_directory: cwd.to_path_buf(),
|
||||
shell: Some(ShellLaunchData::Executable {
|
||||
executable_path: shell,
|
||||
shell_type,
|
||||
}),
|
||||
environment: Some(HashMap::new()),
|
||||
host_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_executable(path: &Path, contents: &str) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::write(path, contents).unwrap();
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn profile_bootstrap_keeps_command_noninteractive_and_restores_overrides_cwd_and_guards() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let cwd = std::fs::canonicalize(root.path()).unwrap();
|
||||
let shell = cwd.join("fixture-bash");
|
||||
// Exercise actual bash initialization state and argv forwarding without
|
||||
// sourcing /etc/profile, the developer's HOME, or any real user rc files.
|
||||
write_executable(
|
||||
&shell,
|
||||
r#"#!/bin/sh
|
||||
printf '%s\n' "$@" >> "$GALAXY_TEST_ARGV"
|
||||
if [ "$1" = '-i' ] && [ "$2" = '-l' ] && [ "$3" = '+m' ] && [ "$4" = '-c' ]; then
|
||||
printf '%s' "$$" > "$GALAXY_TEST_PID"
|
||||
shift 4
|
||||
GALAXY_TEST_SETUP=$1
|
||||
export GALAXY_TEST_SETUP
|
||||
shift
|
||||
exec /bin/bash --noprofile --norc -i +m -c '. "$GALAXY_TEST_PROFILE"; builtin eval "$GALAXY_TEST_SETUP"' "$@"
|
||||
fi
|
||||
exec /bin/bash "$@"
|
||||
"#,
|
||||
);
|
||||
let profile = cwd.join("fixture-profile");
|
||||
std::fs::write(
|
||||
&profile,
|
||||
r#"
|
||||
case $- in *i*) ;; *) return ;; esac
|
||||
if [ "$CI" = 1 ] || [ "$TERM" = dumb ]; then return; fi
|
||||
export GALAXY_TEST_PROFILE_LOADED=loaded
|
||||
export GALAXY_TEST_OVERRIDE=profile
|
||||
export CI=profile
|
||||
export SSH_ASKPASS=profile
|
||||
export BASH_ENV="$GALAXY_TEST_BAD_STARTUP"
|
||||
export PATH="$GALAXY_TEST_TOOLS:$PATH"
|
||||
cd /
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let tools = cwd.join("tools");
|
||||
std::fs::create_dir(&tools).unwrap();
|
||||
write_executable(
|
||||
&tools.join("galaxy-fixture-tool"),
|
||||
"#!/bin/sh\nprintf 'tool-found\\n'\n",
|
||||
);
|
||||
let bad_startup = cwd.join("unexpected-noninteractive-startup");
|
||||
std::fs::write(&bad_startup, "exit 97\n").unwrap();
|
||||
let text = r#"printf '%s\n' "$PWD" "$GALAXY_TEST_PROFILE_LOADED" "$GALAXY_TEST_OVERRIDE" "$CI" "${SSH_ASKPASS-unset}" "$$"
|
||||
case $- in *i*) exit 99 ;; esac
|
||||
if IFS= read -r ignored; then exit 98; fi
|
||||
galaxy-fixture-tool
|
||||
printf '%s\n' "a'b \"quoted\"" "line1
|
||||
line2"
|
||||
"#;
|
||||
let mut launch = fixture_launch(shell, ShellType::Bash, text, &cwd);
|
||||
let secret = "synthetic-session-value-not-for-argv";
|
||||
launch.environment = Some(HashMap::from([
|
||||
("GALAXY_TEST_OVERRIDE".into(), secret.into()),
|
||||
("CI".into(), "profile-startup".into()),
|
||||
("TERM".into(), "profile-startup".into()),
|
||||
]));
|
||||
let mut command = command_for_launch(&launch, &cwd).unwrap();
|
||||
let argv_log = cwd.join("argv");
|
||||
let pid_file = cwd.join("pid");
|
||||
command
|
||||
.current_dir(&cwd)
|
||||
.env("GALAXY_TEST_PROFILE", profile)
|
||||
.env("GALAXY_TEST_TOOLS", tools)
|
||||
.env("GALAXY_TEST_ARGV", &argv_log)
|
||||
.env("GALAXY_TEST_PID", &pid_file)
|
||||
.env("GALAXY_TEST_BAD_STARTUP", bad_startup)
|
||||
.env_remove("BASH_ENV");
|
||||
let output = command.output().await.unwrap();
|
||||
assert!(output.status.success());
|
||||
let original_pid = std::fs::read_to_string(pid_file).unwrap();
|
||||
assert_eq!(String::from_utf8(output.stdout).unwrap(), format!("{}\nloaded\n{secret}\n1\nunset\n{original_pid}\ntool-found\na'b \"quoted\"\nline1\nline2\n", cwd.display()));
|
||||
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)]
|
||||
#[tokio::test]
|
||||
async fn powershell_setup_preserves_explicit_exit_native_failure_and_script_text() {
|
||||
let Some(pwsh) = std::env::var_os("PATH").and_then(|paths| {
|
||||
std::env::split_paths(&paths)
|
||||
.map(|path| path.join("pwsh"))
|
||||
.find(|path| path.is_file())
|
||||
}) else {
|
||||
// Optional host tool: the Unix bootstrap coverage does not require PowerShell.
|
||||
return;
|
||||
};
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let shell = root.path().join("fixture-pwsh");
|
||||
// Disable real profiles in the test host only. Production configuration
|
||||
// intentionally omits -NoProfile and uses the same constant setup script.
|
||||
write_executable(
|
||||
&shell,
|
||||
"#!/bin/sh\nexec \"$GALAXY_TEST_PWSH\" -NoProfile \"$@\"\n",
|
||||
);
|
||||
for (script, expected_code, expected_stdout) in [
|
||||
("exit 17", 17, ""),
|
||||
("& /bin/sh -c 'exit 9'", 1, ""),
|
||||
("Write-Error 'failure'", 1, ""),
|
||||
(
|
||||
"Write-Error 'failure'; Write-Output 'success'",
|
||||
0,
|
||||
"success\n",
|
||||
),
|
||||
(
|
||||
"param()\nWrite-Output 'quoted \"value\"'\n# trailing comment",
|
||||
0,
|
||||
"quoted \"value\"\n",
|
||||
),
|
||||
] {
|
||||
let launch = fixture_launch(shell.clone(), ShellType::PowerShell, script, root.path());
|
||||
let mut command = command_for_launch(&launch, root.path()).unwrap();
|
||||
command
|
||||
.current_dir(root.path())
|
||||
.env("GALAXY_TEST_PWSH", &pwsh);
|
||||
let output = command.output().await.unwrap();
|
||||
assert_eq!(output.status.code(), Some(expected_code));
|
||||
assert_eq!(String::from_utf8(output.stdout).unwrap(), expected_stdout);
|
||||
}
|
||||
}
|
||||
@@ -144,3 +144,88 @@ fn corrupt_history_is_reported_instead_of_replaced() {
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
executor: ModelHandle<BlocklistAIActionExecutor>,
|
||||
active_session: ModelHandle<ActiveSession>,
|
||||
@@ -2029,6 +2041,22 @@ impl BlocklistAIActionModel {
|
||||
action.id,
|
||||
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"))]
|
||||
log_tool_event(
|
||||
@@ -2616,6 +2644,41 @@ impl BlocklistAIActionModel {
|
||||
{
|
||||
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() {
|
||||
self.finished_tool_results
|
||||
.entry(conversation_id)
|
||||
|
||||
@@ -93,7 +93,7 @@ impl GrepError {
|
||||
error: GrepErrorType::NonZeroExitCode,
|
||||
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 {
|
||||
error: GrepErrorType::NonZeroExitCode,
|
||||
output: None,
|
||||
@@ -102,11 +102,43 @@ impl GrepError {
|
||||
GrepError {
|
||||
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)]
|
||||
fn create_redacted_grep_error_event(
|
||||
should_collect_ugc: bool,
|
||||
|
||||
@@ -125,3 +125,45 @@ fn parse_grep_output_reports_when_results_reach_the_per_file_limit() {
|
||||
);
|
||||
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
|
||||
.map_err(|e| anyhow::anyhow!("Remote read failed: {e}"))?;
|
||||
|
||||
if !response.failed_files.is_empty() && response.file_contexts.is_empty() {
|
||||
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,
|
||||
})
|
||||
Ok(remote_read_files_result(response))
|
||||
}),
|
||||
on_complete: Box::new(|res: Result<ReadFilesResult, anyhow::Error>, _ctx| {
|
||||
let action_result =
|
||||
@@ -254,16 +204,7 @@ impl ReadFilesExecutor {
|
||||
result.file_contexts.extend(file_result.file_contexts);
|
||||
result.missing_files.extend(file_result.missing_files);
|
||||
}
|
||||
if result.missing_files.is_empty() {
|
||||
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}"
|
||||
)))
|
||||
}
|
||||
Ok(local_read_files_result(result))
|
||||
}),
|
||||
on_complete: Box::new(|res: Result<ReadFilesResult, anyhow::Error>, _ctx| {
|
||||
let action_result = res.unwrap_or_else(|e| ReadFilesResult::Error(e.to_string()));
|
||||
@@ -284,3 +225,125 @@ impl ReadFilesExecutor {
|
||||
impl Entity for ReadFilesExecutor {
|
||||
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));
|
||||
let started = Instant::now();
|
||||
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),
|
||||
);
|
||||
None
|
||||
@@ -1010,18 +1021,41 @@ async fn assess(
|
||||
request: TurnRequest,
|
||||
sender: TurnCommandSender,
|
||||
control: TurnControl,
|
||||
#[cfg(not(target_family = "wasm"))] activity_identity: serde_json::Value,
|
||||
) -> anyhow::Result<Assessment> {
|
||||
anyhow::ensure!(
|
||||
serde_json::to_vec(&request)?.len() <= MAX_REVIEW_INPUT_BYTES,
|
||||
"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 model_id = request.model.as_str().to_string();
|
||||
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 text = String::new();
|
||||
let mut reasoning_bytes = 0usize;
|
||||
let mut output_tokens = None;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let mut response_usage = None;
|
||||
while let Some(event) = stream.next().await {
|
||||
match event? {
|
||||
AgentEvent::TextDelta { text: delta } => {
|
||||
@@ -1031,12 +1065,25 @@ async fn assess(
|
||||
);
|
||||
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 } => {
|
||||
#[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())
|
||||
}
|
||||
AgentEvent::Tool { .. } => {
|
||||
@@ -1050,6 +1097,10 @@ async fn assess(
|
||||
}
|
||||
AgentEvent::UsageUpdated { usage } => {
|
||||
output_tokens = Some(usage.output_tokens);
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
response_usage = Some(usage);
|
||||
}
|
||||
}
|
||||
AgentEvent::TurnStarted { .. }
|
||||
| AgentEvent::KeepAlive
|
||||
@@ -1066,6 +1117,18 @@ async fn assess(
|
||||
let timeout = FutureExt::fuse(Timer::after(REVIEW_TIMEOUT));
|
||||
futures::pin_mut!(review, timeout);
|
||||
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);
|
||||
result
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ use persistence::model::ProviderConversationContext;
|
||||
use session_sharing_protocol::common::ParticipantId;
|
||||
use settings::Setting;
|
||||
pub use slash_command::*;
|
||||
use uuid::Uuid;
|
||||
use warp_multi_agent_api::{message, Task, ToolType};
|
||||
use warpui::r#async::{SpawnedFutureHandle, Timer};
|
||||
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::queued_query::{QueuedQueryId, QueuedQueryModel};
|
||||
use super::{BlocklistAIInputModel, ResponseStreamId};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::activity;
|
||||
use crate::ai::agent::api::{self, ServerConversationToken};
|
||||
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
|
||||
use crate::ai::agent::task::TaskId;
|
||||
@@ -416,6 +419,49 @@ fn progressive_summary_messages(summary: String) -> Vec<ConversationMessage> {
|
||||
async fn collect_progressive_summary(
|
||||
provider_config: ProviderConfig,
|
||||
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)> {
|
||||
let (command_sender, control) = turn_control();
|
||||
let startup = async move {
|
||||
@@ -1046,6 +1092,7 @@ enum ProviderCommandResult {
|
||||
fn convert_provider_tool_batch(
|
||||
action_context: &ProviderActionContext,
|
||||
batch: &PendingToolBatch,
|
||||
has_expected_orchestration_event: bool,
|
||||
) -> (Vec<(AIAgentAction, bool)>, Vec<ToolResult>) {
|
||||
let mut actions = Vec::new();
|
||||
let mut invalid_results = Vec::new();
|
||||
@@ -1054,7 +1101,18 @@ fn convert_provider_tool_batch(
|
||||
.iter()
|
||||
.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((
|
||||
action,
|
||||
matches!(pending.state, PendingToolCallState::RecoveryPending),
|
||||
@@ -2212,7 +2270,12 @@ fn provider_run_terminal_remote_log_record(
|
||||
stream_id: &ResponseStreamId,
|
||||
run: &ProviderRun,
|
||||
outcome: &ProviderRunOutcome,
|
||||
model_failure_diagnostics: serde_json::Value,
|
||||
) -> 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 {
|
||||
ProviderRunOutcome::Completed(completion) => (
|
||||
RemoteLogLevel::Info,
|
||||
@@ -2251,10 +2314,13 @@ fn provider_run_terminal_remote_log_record(
|
||||
"profile": run.profile().as_str(),
|
||||
"model_turn_count": run.model_turns(),
|
||||
"model_retry_count": run.model_retries(),
|
||||
"model_failure_diagnostics": model_failure_diagnostics,
|
||||
"outcome": outcome_name,
|
||||
"stop_reason": stop_reason,
|
||||
"failure_kind": failure_kind,
|
||||
"error": error,
|
||||
"error_kind": source_error.map(|error| format!("{:?}", error.kind)),
|
||||
"error_recoverable": source_error.map(|error| error.recoverable),
|
||||
"llm_finished": llm_finished,
|
||||
"provider_run_finished": 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();
|
||||
|
||||
(
|
||||
@@ -7483,6 +7560,7 @@ impl BlocklistAIController {
|
||||
}),
|
||||
},
|
||||
);
|
||||
let summary_activity_identity = identity.clone();
|
||||
ctx.spawn(
|
||||
async move {
|
||||
let mut errors = Vec::new();
|
||||
@@ -7493,7 +7571,17 @@ impl BlocklistAIController {
|
||||
);
|
||||
request.system_prompt = Some(PROGRESSIVE_SUMMARY_PROMPT.to_string());
|
||||
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)) => {
|
||||
return (plan, Ok((summary, usage, candidate.request_model_id)));
|
||||
}
|
||||
@@ -7764,6 +7852,31 @@ impl BlocklistAIController {
|
||||
if let Some((usage, summarizer_model_id)) = recorded_usage {
|
||||
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 {
|
||||
self.fail_active_provider_run(
|
||||
conversation_id,
|
||||
@@ -7931,6 +8044,20 @@ impl BlocklistAIController {
|
||||
conversation_id: AIConversationId,
|
||||
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
|
||||
// by, or replaced with, a detached summary from the previous generation.
|
||||
// Cancellation also discards summaries before they can fail recovery.
|
||||
@@ -7952,6 +8079,18 @@ impl BlocklistAIController {
|
||||
.remove(&conversation_id)
|
||||
.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, _| {
|
||||
if let Some(conversation) = history.conversation_mut(&conversation_id) {
|
||||
conversation.set_has_pending_progressive_summary(false);
|
||||
@@ -7987,6 +8126,8 @@ impl BlocklistAIController {
|
||||
let Some(mut run) = slot.run.take() else {
|
||||
return;
|
||||
};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
run.coordinator.set_activity_session_id(conversation_id);
|
||||
let checkpoint_template = match ActiveProviderRunCheckpoint::from_active_run(&run) {
|
||||
Ok(checkpoint) => checkpoint,
|
||||
Err(error) => {
|
||||
@@ -8619,6 +8760,25 @@ impl BlocklistAIController {
|
||||
batch: PendingToolBatch,
|
||||
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
|
||||
.active_provider_runs
|
||||
.get_mut(&conversation_id)
|
||||
@@ -8636,8 +8796,11 @@ impl BlocklistAIController {
|
||||
.map(|title| (pending.call.id.clone(), title))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let (mut converted_actions, invalid_results) =
|
||||
convert_provider_tool_batch(&run.action_context, &batch);
|
||||
let (mut converted_actions, invalid_results) = convert_provider_tool_batch(
|
||||
&run.action_context,
|
||||
&batch,
|
||||
has_expected_orchestration_event,
|
||||
);
|
||||
// Seed each worker from the current durable transcript, including discoveries
|
||||
// made in this run that have not reached the persisted conversation yet.
|
||||
let history = BlocklistAIHistoryModel::as_ref(ctx);
|
||||
@@ -9398,6 +9561,7 @@ impl BlocklistAIController {
|
||||
&stream_id,
|
||||
run.coordinator.run(),
|
||||
&outcome,
|
||||
run.coordinator.model_failure_diagnostics(),
|
||||
),
|
||||
);
|
||||
let completed_cleanly = matches!(
|
||||
@@ -9529,6 +9693,17 @@ impl BlocklistAIController {
|
||||
if BlocklistAIHistoryModel::as_ref(ctx).conversation_status(&conversation_id)
|
||||
!= 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;
|
||||
}
|
||||
|
||||
@@ -9554,6 +9729,13 @@ impl BlocklistAIController {
|
||||
"[progressive-summary] Discarding stale idle summary for conversation {:?}; the snapshotted prefix no longer matches",
|
||||
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;
|
||||
}
|
||||
if !summary_reduces_context {
|
||||
@@ -9561,6 +9743,13 @@ impl BlocklistAIController {
|
||||
"[progressive-summary] Idle summary did not reduce context for conversation {:?}",
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -9587,6 +9776,16 @@ impl BlocklistAIController {
|
||||
conversation_id,
|
||||
});
|
||||
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!(
|
||||
"[progressive-summary] Applied background summary after active run completion {:?}: ~{} tokens ({:.1}% of {} context), {} tail messages preserved",
|
||||
conversation_id,
|
||||
@@ -11361,6 +11560,21 @@ impl BlocklistAIController {
|
||||
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 {
|
||||
role: MessageRole::User,
|
||||
content: MessageContent::Text(summarize_content),
|
||||
@@ -11376,7 +11590,11 @@ impl BlocklistAIController {
|
||||
);
|
||||
request.system_prompt = Some(summarize_prompt.to_string());
|
||||
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)) => {
|
||||
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_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!(
|
||||
"[progressive-summary] Post-summary: ~{} tokens ({:.1}% of {} context), {} messages retained",
|
||||
new_context_tokens,
|
||||
|
||||
@@ -430,11 +430,19 @@ impl ResponseStream {
|
||||
&self,
|
||||
request_id: Uuid,
|
||||
payload_kind: &str,
|
||||
raw_payload: impl AsRef<str>,
|
||||
raw_payload: &impl std::fmt::Debug,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
) {
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
@@ -956,7 +964,7 @@ impl ResponseStream {
|
||||
self.log_raw_model_response(
|
||||
request_id,
|
||||
event_type_name,
|
||||
format!("{response_event:#?}"),
|
||||
response_event,
|
||||
ctx,
|
||||
);
|
||||
if let Some(event_type) = &response_event.r#type {
|
||||
@@ -1059,7 +1067,7 @@ impl ResponseStream {
|
||||
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
fn raw_model_request_payload(params: &api::RequestParams, max_chars: usize) -> serde_json::Value {
|
||||
use serde::ser::{SerializeMap, Serializer};
|
||||
use serde::ser::{SerializeMap, SerializeSeq, Serializer};
|
||||
|
||||
// collect_str writes debug output directly through serde_json's escaping
|
||||
// writer, without format! allocating another copy of potentially large input.
|
||||
@@ -1070,7 +1078,18 @@ fn raw_model_request_payload(params: &api::RequestParams, max_chars: usize) -> s
|
||||
}
|
||||
}
|
||||
|
||||
remote_logging::bounded_payload_context(max_chars, |writer| {
|
||||
struct NewestFirst<'a, T>(&'a [T]);
|
||||
impl<T: serde::Serialize> serde::Serialize for NewestFirst<'_, T> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut sequence = serializer.serialize_seq(Some(self.0.len()))?;
|
||||
for message in self.0.iter().rev() {
|
||||
sequence.serialize_element(message)?;
|
||||
}
|
||||
sequence.end()
|
||||
}
|
||||
}
|
||||
|
||||
remote_logging::prefix_bounded_payload_context(max_chars, |writer| {
|
||||
let mut serializer = serde_json::Serializer::pretty(writer);
|
||||
let mut map = serializer.serialize_map(None)?;
|
||||
map.serialize_entry("model_id", ¶ms.model.as_str())?;
|
||||
@@ -1079,10 +1098,14 @@ fn raw_model_request_payload(params: &api::RequestParams, max_chars: usize) -> s
|
||||
map.serialize_entry("computer_use_model_id", ¶ms.computer_use_model.as_str())?;
|
||||
map.serialize_entry("input", &DebugPayload(¶ms.input))?;
|
||||
map.serialize_entry("tool_results", &DebugPayload(¶ms.tool_results))?;
|
||||
map.serialize_entry("tasks", &DebugPayload(¶ms.tasks))?;
|
||||
map.serialize_entry("message_history", ¶ms.message_history)?;
|
||||
map.serialize_entry("progressive_summary", ¶ms.progressive_summary)?;
|
||||
map.serialize_entry("tool_result_archive", ¶ms.tool_result_archive)?;
|
||||
// Preserve the newest request context before old task/debug archives can
|
||||
// consume the diagnostic budget. This ordering affects diagnostics only.
|
||||
map.serialize_entry("message_history_order", "newest_first")?;
|
||||
map.serialize_entry(
|
||||
"message_history",
|
||||
&NewestFirst(params.message_history.as_slice()),
|
||||
)?;
|
||||
map.serialize_entry("global_rules", ¶ms.global_rules)?;
|
||||
map.serialize_entry("mcp_context", &DebugPayload(¶ms.mcp_context))?;
|
||||
map.serialize_entry("autonomy_level", &DebugPayload(¶ms.autonomy_level))?;
|
||||
@@ -1115,7 +1138,9 @@ fn raw_model_request_payload(params: &api::RequestParams, max_chars: usize) -> s
|
||||
"omitted_sensitive_fields",
|
||||
&["api_keys", "custom_model_providers", "custom_model_routers"],
|
||||
)?;
|
||||
map.end()
|
||||
map.serialize_entry("tasks", &DebugPayload(¶ms.tasks))?;
|
||||
map.serialize_entry("tool_result_archive", ¶ms.tool_result_archive)?;
|
||||
SerializeMap::end(map)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ fn provider_terminal_logs_distinguish_clean_completion_from_failure() {
|
||||
&ProviderRunOutcome::Completed(galaxy_agent_core::ProviderRunCompletion {
|
||||
stop_reason: StopReason::Completed,
|
||||
}),
|
||||
serde_json::json!({"observed_count": 0, "recent": []}),
|
||||
);
|
||||
let failed = super::provider_run_terminal_remote_log_record(
|
||||
conversation_id,
|
||||
@@ -164,8 +165,14 @@ fn provider_terminal_logs_distinguish_clean_completion_from_failure() {
|
||||
&ProviderRunOutcome::Failed(ProviderRunFailure {
|
||||
kind: ProviderRunFailureKind::RetryLimitExceeded,
|
||||
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);
|
||||
@@ -176,6 +183,9 @@ fn provider_terminal_logs_distinguish_clean_completion_from_failure() {
|
||||
assert_eq!(failed.context["llm_finished"], false);
|
||||
assert_eq!(failed.context["outcome"], "failed");
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -1445,7 +1455,8 @@ fn malformed_provider_tool_inputs_become_correlated_errors_without_dropping_vali
|
||||
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[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 {
|
||||
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());
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,7 +118,17 @@ impl CrosscheckReviewer {
|
||||
// Spawn the reviewer request asynchronously
|
||||
let conversation_id_for_task = conversation_id;
|
||||
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| {
|
||||
me.handle_reviewer_response(
|
||||
conversation_id_for_task,
|
||||
@@ -206,6 +216,70 @@ impl CrosscheckReviewer {
|
||||
agent_output: String,
|
||||
model_id: String,
|
||||
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> {
|
||||
match provider_config {
|
||||
ProviderConfig::OpenAI(config)
|
||||
@@ -217,7 +291,8 @@ impl CrosscheckReviewer {
|
||||
reasoning_effort: config.reasoning_effort,
|
||||
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
|
||||
.complete_text(
|
||||
prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT.to_string(),
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
//! The side panel Warp AI implementation lives in `super::ai_assistant`.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) mod acp;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub(crate) mod activity;
|
||||
pub(crate) mod active_agent_views_model;
|
||||
pub(crate) mod agent;
|
||||
pub(crate) mod agent_conversations_model;
|
||||
@@ -79,6 +81,7 @@ pub mod connected_self_hosted_workers;
|
||||
pub mod execution_profiles;
|
||||
pub mod facts;
|
||||
pub(crate) mod generate_block_title;
|
||||
pub(crate) mod grep_query;
|
||||
pub(crate) mod generate_code_review_content;
|
||||
pub(crate) mod loading;
|
||||
pub mod mcp;
|
||||
|
||||
@@ -136,11 +136,12 @@ fn apply_file_diffs() -> ToolDefinition {
|
||||
fn grep() -> ToolDefinition {
|
||||
ToolDefinition {
|
||||
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!({
|
||||
"type": "object",
|
||||
"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" }
|
||||
},
|
||||
"required": ["queries"]
|
||||
|
||||
@@ -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.
|
||||
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 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 {
|
||||
serde_json::json!({
|
||||
@@ -1868,11 +1868,12 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
|
||||
},
|
||||
ToolDefinition {
|
||||
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!({
|
||||
"type": "object",
|
||||
"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" }
|
||||
},
|
||||
"required": ["queries"]
|
||||
@@ -2086,7 +2087,7 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
|
||||
},
|
||||
ToolDefinition {
|
||||
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!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -697,3 +697,43 @@ fn compatibility_background_acknowledgment_does_not_invent_monitoring() {
|
||||
};
|
||||
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::diagnostic::BedrockDiagnosticLogger;
|
||||
use crate::ai::agent::api::LegacyEvent;
|
||||
use crate::ai::grep_query::prepare_grep_queries;
|
||||
use crate::server::server_api::AIApiError;
|
||||
|
||||
fn json_to_prost_struct(value: &serde_json::Value) -> prost_types::Struct {
|
||||
@@ -1018,6 +1019,13 @@ pub fn build_tool_call_message(
|
||||
.collect()
|
||||
})
|
||||
.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
|
||||
.get("path")
|
||||
.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("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"]);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use std::{
|
||||
use chrono::Utc;
|
||||
use galaxy_core::channel::ChannelState;
|
||||
use galaxyui::{Entity, ModelContext, SingletonEntity, View, ViewContext};
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
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}"))
|
||||
});
|
||||
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)]
|
||||
pub(crate) enum RemoteLogLevel {
|
||||
@@ -43,7 +53,7 @@ pub(crate) enum RemoteLogLevel {
|
||||
}
|
||||
|
||||
impl RemoteLogLevel {
|
||||
fn as_str(self) -> &'static str {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Info => "info",
|
||||
Self::Warn => "warn",
|
||||
@@ -99,6 +109,10 @@ pub(crate) fn log_model_event<M>(ctx: &mut ModelContext<M>, record: RemoteLogRec
|
||||
where
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
@@ -126,6 +140,10 @@ where
|
||||
/// Views must log without updating AISettings: its singleton cannot be read
|
||||
/// while it is temporarily borrowed by a model update.
|
||||
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 {
|
||||
return;
|
||||
};
|
||||
@@ -202,8 +220,49 @@ fn write_local_log(payload: &RemoteLogPayload) {
|
||||
}
|
||||
|
||||
pub(crate) fn sanitize_error(error: impl std::fmt::Display) -> String {
|
||||
let compact = error
|
||||
.to_string()
|
||||
// Stop formatting before scanning/redacting an arbitrarily large upstream
|
||||
// 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: ®ex::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()
|
||||
.map(redact_sensitive_token)
|
||||
.collect::<Vec<_>>()
|
||||
@@ -211,6 +270,63 @@ pub(crate) fn sanitize_error(error: impl std::fmt::Display) -> String {
|
||||
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>(
|
||||
ctx: &ModelContext<M>,
|
||||
build_payload: impl FnOnce(usize) -> Value,
|
||||
@@ -219,17 +335,34 @@ where
|
||||
M: Entity,
|
||||
{
|
||||
let settings = AISettings::as_ref(ctx);
|
||||
if !*settings.remote_logging_enabled.value()
|
||||
|| !*settings.remote_logging_log_model_payloads.value()
|
||||
|| settings.remote_logging_api_key.value().trim().is_empty()
|
||||
{
|
||||
let remote_enabled = remote_payloads_enabled(settings);
|
||||
let local_enabled = super::activity::is_recording();
|
||||
if !remote_enabled && !local_enabled {
|
||||
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))
|
||||
}
|
||||
|
||||
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 {
|
||||
let Value::Object(ref mut map) = context else {
|
||||
return json!({
|
||||
@@ -262,7 +395,7 @@ async fn send_remote_log(config: RemoteLogConfig, payload: RemoteLogPayload) ->
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("request failed: {error}"))?;
|
||||
.map_err(|error| format!("request failed: {}", sanitize_error(error)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("server returned HTTP {}", response.status()));
|
||||
@@ -311,6 +444,89 @@ fn truncate_chars(value: &str, max_chars: usize) -> String {
|
||||
truncated
|
||||
}
|
||||
|
||||
/// Stop serialization once the diagnostic prefix is full. Unlike the tail
|
||||
/// collector below, this does not walk the remaining history or tool archive.
|
||||
/// A truncated prefix is diagnostic text, not a complete serialized request.
|
||||
pub(crate) fn prefix_bounded_payload_context(
|
||||
max_chars: usize,
|
||||
serialize: impl FnOnce(&mut dyn std::io::Write) -> serde_json::Result<()>,
|
||||
) -> Value {
|
||||
struct PrefixWriter {
|
||||
bytes: Vec<u8>,
|
||||
max_chars: usize,
|
||||
max_bytes: usize,
|
||||
chars: usize,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
impl std::io::Write for PrefixWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
if self.truncated {
|
||||
return Err(std::io::Error::other("diagnostic prefix limit reached"));
|
||||
}
|
||||
let mut included = 0;
|
||||
for byte in bytes {
|
||||
let starts_character = byte & 0xc0 != 0x80;
|
||||
if (starts_character && self.chars == self.max_chars)
|
||||
|| self.bytes.len().saturating_add(included) == self.max_bytes
|
||||
{
|
||||
self.bytes.extend_from_slice(&bytes[..included]);
|
||||
self.truncated = true;
|
||||
return if included == 0 {
|
||||
Err(std::io::Error::other("diagnostic prefix limit reached"))
|
||||
} else {
|
||||
// Respect Write's short-write contract. serde's write_all
|
||||
// immediately retries the remainder and receives the error.
|
||||
Ok(included)
|
||||
};
|
||||
}
|
||||
if starts_character {
|
||||
self.chars += 1;
|
||||
}
|
||||
included += 1;
|
||||
}
|
||||
self.bytes.extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let mut writer = PrefixWriter {
|
||||
bytes: Vec::new(),
|
||||
max_chars,
|
||||
max_bytes: max_chars.saturating_mul(4),
|
||||
chars: 0,
|
||||
truncated: false,
|
||||
};
|
||||
let result = serialize(&mut writer);
|
||||
if let Err(error) = result {
|
||||
if !writer.truncated {
|
||||
return json!({ "serialization_error": sanitize_error(error) });
|
||||
}
|
||||
}
|
||||
// Writers may split a UTF-8 character across calls. Keep only a complete
|
||||
// prefix if serialization stopped before the last character was finished.
|
||||
let valid_length = match std::str::from_utf8(&writer.bytes) {
|
||||
Ok(_) => writer.bytes.len(),
|
||||
Err(error) => error.valid_up_to(),
|
||||
};
|
||||
writer.bytes.truncate(valid_length);
|
||||
let payload = String::from_utf8(writer.bytes).expect("validated diagnostic UTF-8 prefix");
|
||||
let included_chars = payload.chars().count();
|
||||
json!({
|
||||
"payload": payload,
|
||||
"payload_total_chars": if writer.truncated { None } else { Some(included_chars) },
|
||||
"payload_included_chars": included_chars,
|
||||
"payload_max_chars": max_chars,
|
||||
"truncated": writer.truncated,
|
||||
"truncation_strategy": if writer.truncated { Some("prefix") } else { None },
|
||||
"serialization_stopped_early": writer.truncated,
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialize directly into a bounded UTF-8 tail instead of materializing the full
|
||||
/// request and its archive as a JSON Value and then another large String.
|
||||
pub(crate) fn bounded_payload_context(
|
||||
|
||||
@@ -24,6 +24,109 @@ fn errors_are_compacted_truncated_and_lightly_redacted() {
|
||||
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]
|
||||
fn raw_payload_cap_keeps_tail() {
|
||||
assert_eq!(
|
||||
@@ -64,3 +167,79 @@ fn streamed_json_matches_serialized_payload_without_materializing_the_archive()
|
||||
});
|
||||
assert_eq!(actual, tail_limited_payload_context(&expected, 100));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_payload_preserves_utf8_and_reports_unknown_truncated_size() {
|
||||
let payload = "é日本🙂 ending";
|
||||
for limit in [0, 1, 3, 4, payload.chars().count(), payload.len()] {
|
||||
for chunk_size in [1, 2, 7, payload.len()] {
|
||||
let actual = super::prefix_bounded_payload_context(limit, |writer| {
|
||||
for chunk in payload.as_bytes().chunks(chunk_size) {
|
||||
writer.write_all(chunk).map_err(serde_json::Error::io)?;
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
let expected = payload.chars().take(limit).collect::<String>();
|
||||
let truncated = limit < payload.chars().count();
|
||||
assert_eq!(actual["payload"], expected);
|
||||
assert_eq!(actual["payload_included_chars"], expected.chars().count());
|
||||
assert_eq!(actual["truncated"], truncated);
|
||||
assert_eq!(actual["serialization_stopped_early"], truncated);
|
||||
assert_eq!(
|
||||
actual["payload_total_chars"],
|
||||
if truncated {
|
||||
serde_json::Value::Null
|
||||
} else {
|
||||
json!(payload.chars().count())
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_payload_stops_pretty_debug_serialization_at_the_budget() {
|
||||
use std::cell::Cell;
|
||||
|
||||
struct LargeDebug<'a>(&'a Cell<usize>);
|
||||
impl std::fmt::Debug for LargeDebug<'_> {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
for _ in 0..1_000_000 {
|
||||
self.0.set(self.0.get() + 1);
|
||||
formatter.write_str("large archive entry ")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
struct DebugPayload<'a>(LargeDebug<'a>);
|
||||
impl serde::Serialize for DebugPayload<'_> {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.collect_str(&format_args!("{:#?}", self.0))
|
||||
}
|
||||
}
|
||||
|
||||
let visited = Cell::new(0);
|
||||
let actual = super::prefix_bounded_payload_context(64, |writer| {
|
||||
serde_json::to_writer(writer, &DebugPayload(LargeDebug(&visited)))
|
||||
});
|
||||
|
||||
assert!(visited.get() < 10);
|
||||
assert_eq!(actual["payload_included_chars"], 64);
|
||||
assert_eq!(actual["truncated"], true);
|
||||
assert_eq!(actual["payload_total_chars"], serde_json::Value::Null);
|
||||
assert_eq!(actual["truncation_strategy"], "prefix");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_payload_keeps_real_serialization_errors_visible() {
|
||||
let actual = super::prefix_bounded_payload_context(100, |_writer| {
|
||||
Err(serde_json::Error::io(std::io::Error::other(
|
||||
"test serializer failed",
|
||||
)))
|
||||
});
|
||||
assert!(actual["serialization_error"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("test serializer failed"));
|
||||
assert!(actual.get("payload").is_none());
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
mod child_context;
|
||||
mod event_translator;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
mod model_failure_diagnostics;
|
||||
mod provider_run_coordinator;
|
||||
mod rig;
|
||||
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
|
||||
);
|
||||
}
|
||||
@@ -12,12 +12,17 @@ use galaxy_agent_core::{
|
||||
ModelFailureDisposition, PendingToolBatch, ProviderModelCall, ProviderRequestProfile,
|
||||
ProviderRun, ProviderRunFailureKind, ProviderRunId, ProviderRunLimits, ProviderRunOutcome,
|
||||
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 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};
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::activity;
|
||||
use crate::ai::agent::conversation::AIConversationId;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::model_output_logging;
|
||||
@@ -285,6 +290,10 @@ pub(crate) struct ProviderRunCoordinator {
|
||||
model_event_idle_timeout: Duration,
|
||||
context_compaction_requested: bool,
|
||||
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 {
|
||||
@@ -327,6 +336,10 @@ impl ProviderRunCoordinator {
|
||||
model_event_idle_timeout: PROVIDER_MODEL_EVENT_IDLE_TIMEOUT,
|
||||
context_compaction_requested: false,
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
&self.run
|
||||
}
|
||||
@@ -730,6 +772,15 @@ impl ProviderRunCoordinator {
|
||||
profile.max_context_tokens.or(self.max_context_tokens),
|
||||
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 (attempt_sender, attempt_control) = turn_control();
|
||||
let start_future = profile.runtime.start_turn(request, attempt_control).fuse();
|
||||
@@ -779,6 +830,8 @@ impl ProviderRunCoordinator {
|
||||
};
|
||||
futures::pin_mut!(stream);
|
||||
let mut buffer = ModelTurnBuffer::default();
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
let mut usage_reported = false;
|
||||
|
||||
loop {
|
||||
let next_event = stream.next().fuse();
|
||||
@@ -1014,6 +1067,10 @@ impl ProviderRunCoordinator {
|
||||
return Ok(());
|
||||
}
|
||||
buffer.usage.clone_from(&usage);
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
{
|
||||
usage_reported = true;
|
||||
}
|
||||
if !self
|
||||
.project_or_fail_acknowledged(
|
||||
ProviderRunProjection::ModelEvent {
|
||||
@@ -1056,6 +1113,19 @@ impl ProviderRunCoordinator {
|
||||
{
|
||||
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 {
|
||||
self.run.cancel("provider model call was cancelled")?;
|
||||
return Ok(());
|
||||
@@ -1147,6 +1217,12 @@ impl ProviderRunCoordinator {
|
||||
where
|
||||
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"))]
|
||||
model_output_logging::log(serde_json::json!({
|
||||
"event": "model_error",
|
||||
@@ -1159,6 +1235,15 @@ impl ProviderRunCoordinator {
|
||||
"retry_attempt": call.retry_attempt,
|
||||
"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();
|
||||
if error.kind == AgentErrorKind::ContextWindowExceeded {
|
||||
self.run.prepare_context_compaction(&call.work_id)?;
|
||||
@@ -1243,7 +1328,7 @@ impl ProviderRunCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
#[derive(Default, serde::Serialize)]
|
||||
struct ModelTurnBuffer {
|
||||
started: bool,
|
||||
text: String,
|
||||
|
||||
@@ -844,6 +844,11 @@ fn provider_safe_mcp_tool_name(server_id: Option<&str>, tool_name: &str) -> Stri
|
||||
format!("{prefix}{component}{separator}{hash}")
|
||||
}
|
||||
|
||||
fn deduplicate_context_entries<T: Clone + Eq + std::hash::Hash>(entries: &mut Vec<T>) {
|
||||
let mut seen = HashSet::with_capacity(entries.len());
|
||||
entries.retain(|entry| seen.insert(entry.clone()));
|
||||
}
|
||||
|
||||
fn build_system_prompt(
|
||||
inputs: &[AIAgentInput],
|
||||
tools: &[ToolDefinition],
|
||||
@@ -988,6 +993,13 @@ fn build_system_prompt(
|
||||
AIAgentContext::Image(_) => {}
|
||||
}
|
||||
}
|
||||
// Grouped inputs, such as background-job completions, can share the same context.
|
||||
// Emit each exact context entry once, retaining its first position and all distinct
|
||||
// content. Conversational inputs and tool results remain separate and unchanged.
|
||||
deduplicate_context_entries(&mut environment);
|
||||
deduplicate_context_entries(&mut project_rules);
|
||||
deduplicate_context_entries(&mut attached_context);
|
||||
deduplicate_context_entries(&mut available_skills);
|
||||
if !environment.is_empty() {
|
||||
prompt.push_str("## Environment\n");
|
||||
for item in environment {
|
||||
|
||||
@@ -81,6 +81,177 @@ fn native_context_reaches_rig_without_a_proto_context_conversion() {
|
||||
assert!(prompt.contains("Indexed codebase: galaxy (/repo)"));
|
||||
}
|
||||
|
||||
fn shared_prompt_context(
|
||||
directory: &str,
|
||||
rule: &str,
|
||||
attachment: &str,
|
||||
skill_description: &str,
|
||||
) -> Vec<AIAgentContext> {
|
||||
vec![
|
||||
AIAgentContext::Directory {
|
||||
pwd: Some(directory.to_string()),
|
||||
home_dir: Some("/home/user".to_string()),
|
||||
are_file_symbols_indexed: false,
|
||||
},
|
||||
AIAgentContext::ProjectRules {
|
||||
root_path: "/repo".to_string(),
|
||||
active_rules: vec![FileContext::new(
|
||||
"/repo/AGENTS.md".to_string(),
|
||||
AnyFileContent::StringContent(rule.to_string()),
|
||||
None,
|
||||
None,
|
||||
)],
|
||||
additional_rule_paths: Vec::new(),
|
||||
},
|
||||
AIAgentContext::File(FileContext::new(
|
||||
"/repo/context.txt".to_string(),
|
||||
AnyFileContent::StringContent(attachment.to_string()),
|
||||
None,
|
||||
None,
|
||||
)),
|
||||
AIAgentContext::Skills {
|
||||
skills: vec![SkillDescriptor {
|
||||
reference: SkillReference::Path(LocalOrRemotePath::Local(PathBuf::from(
|
||||
"/repo/.agents/skills/context-probe/SKILL.md",
|
||||
))),
|
||||
name: "context-probe".to_string(),
|
||||
description: skill_description.to_string(),
|
||||
scope: SkillScope::Project,
|
||||
provider: SkillProvider::Agents,
|
||||
icon_override: None,
|
||||
}],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_completions_share_context_without_dropping_inputs_or_tool_results() {
|
||||
let context = shared_prompt_context(
|
||||
"/repo",
|
||||
"shared-rule-probe",
|
||||
"shared-attachment-probe",
|
||||
"shared-skill-description-probe",
|
||||
);
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.input = (0..4)
|
||||
.map(|index| AIAgentInput::CommandCompletionAssessment {
|
||||
prompt: format!("Completion evidence for job {index}"),
|
||||
context: Arc::from(context.clone()),
|
||||
completed_command: crate::ai::agent::RunningCommand {
|
||||
command: format!("command-{index}"),
|
||||
block_id: format!("background-job:{index}").into(),
|
||||
grid_contents: String::new(),
|
||||
cursor: String::new(),
|
||||
requested_command_id: None,
|
||||
is_alt_screen_active: false,
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
// Repeated user restrictions remain distinct conversational inputs.
|
||||
params.input.extend([
|
||||
user_query_with_context("Do not deploy", context.clone()),
|
||||
user_query_with_context("Do not deploy", context),
|
||||
]);
|
||||
params.message_history = vec![galaxy_agent_core::ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
content: MessageContent::ToolUse {
|
||||
tool_use_id: "retained-call".to_string(),
|
||||
name: "read_files".to_string(),
|
||||
input: serde_json::json!({ "files": ["/repo/file.rs"] }),
|
||||
},
|
||||
}];
|
||||
params.tool_results = vec![ToolResult {
|
||||
call_id: "retained-call".to_string(),
|
||||
content: "retained-tool-result-probe".to_string(),
|
||||
status: ToolResultStatus::Success,
|
||||
images: Vec::new(),
|
||||
}];
|
||||
|
||||
let prepared = prepare_rig_turn(
|
||||
&config(),
|
||||
params,
|
||||
vec![ToolType::ReadFiles, ToolType::ReadSkill],
|
||||
Vec::new(),
|
||||
);
|
||||
let prompt = prepared.request.system_prompt.expect("system prompt");
|
||||
for entry in [
|
||||
"Working directory: /repo",
|
||||
"Home directory: /home/user",
|
||||
"shared-rule-probe",
|
||||
"shared-attachment-probe",
|
||||
"shared-skill-description-probe",
|
||||
] {
|
||||
assert_eq!(
|
||||
prompt.matches(entry).count(),
|
||||
1,
|
||||
"repeated context: {entry}"
|
||||
);
|
||||
}
|
||||
for index in 0..4 {
|
||||
assert!(prepared.request.messages.iter().any(|message| {
|
||||
matches!(&message.content, MessageContent::Text(text)
|
||||
if text == &format!("Completion evidence for job {index}"))
|
||||
}));
|
||||
}
|
||||
assert_eq!(
|
||||
prepared
|
||||
.request
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|message| {
|
||||
matches!(&message.content, MessageContent::Text(text) if text == "Do not deploy")
|
||||
})
|
||||
.count(),
|
||||
2,
|
||||
);
|
||||
assert!(prepared.request.messages.iter().any(|message| {
|
||||
matches!(&message.content, MessageContent::ToolResult { tool_use_id, content, .. }
|
||||
if tool_use_id == "retained-call" && content == "retained-tool-result-probe")
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_context_content_keeps_its_first_order_even_with_shared_paths() {
|
||||
let first = shared_prompt_context(
|
||||
"/repo/first",
|
||||
"first-rule-probe",
|
||||
"first-attachment-probe",
|
||||
"first-skill-description-probe",
|
||||
);
|
||||
let second = shared_prompt_context(
|
||||
"/repo/second",
|
||||
"second-rule-probe",
|
||||
"second-attachment-probe",
|
||||
"second-skill-description-probe",
|
||||
);
|
||||
let mut params = RequestParams::new_for_test();
|
||||
params.input = vec![
|
||||
user_query_with_context("First instruction", first.clone()),
|
||||
user_query_with_context("Second instruction", second),
|
||||
user_query_with_context("Third instruction", first),
|
||||
];
|
||||
|
||||
let prepared = prepare_rig_turn(&config(), params, vec![ToolType::ReadSkill], Vec::new());
|
||||
let prompt = prepared.request.system_prompt.expect("system prompt");
|
||||
for (first, second) in [
|
||||
(
|
||||
"Working directory: /repo/first",
|
||||
"Working directory: /repo/second",
|
||||
),
|
||||
("first-rule-probe", "second-rule-probe"),
|
||||
("first-attachment-probe", "second-attachment-probe"),
|
||||
(
|
||||
"first-skill-description-probe",
|
||||
"second-skill-description-probe",
|
||||
),
|
||||
] {
|
||||
assert_eq!(prompt.matches(first).count(), 1);
|
||||
assert_eq!(prompt.matches(second).count(), 1);
|
||||
assert!(prompt.find(first).unwrap() < prompt.find(second).unwrap());
|
||||
}
|
||||
assert_eq!(prepared.request.messages.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn volatile_request_time_follows_the_cacheable_system_prompt_prefix() {
|
||||
let mut params = RequestParams::new_for_test();
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::ai::agent::{
|
||||
RunAgentsRequest, SearchCodebaseRequest, ShellCommandDelay,
|
||||
};
|
||||
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_SEARCH_ITEMS: usize = 3;
|
||||
@@ -83,7 +84,10 @@ pub(crate) fn action_from_tool_call(
|
||||
title: Some(required_string(input, "summary")?),
|
||||
},
|
||||
"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(),
|
||||
},
|
||||
"file_glob" => AIAgentActionType::FileGlob {
|
||||
|
||||
@@ -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"}))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
#[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,
|
||||
};
|
||||
|
||||
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 {
|
||||
name: "/add-prompt",
|
||||
description: "Add new Agent prompt",
|
||||
@@ -713,6 +722,7 @@ fn all_commands() -> Vec<StaticCommand> {
|
||||
|
||||
if !cfg!(target_family = "wasm") {
|
||||
commands.extend([EDIT.clone(), EXPORT_TO_FILE.clone()]);
|
||||
commands.push(AI_ACTIVITY);
|
||||
}
|
||||
if cfg!(all(feature = "local_fs", not(target_family = "wasm"))) {
|
||||
commands.push(BACKGROUND_JOBS);
|
||||
|
||||
@@ -1861,6 +1861,13 @@ pub fn init(app: &mut AppContext) {
|
||||
)
|
||||
.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(
|
||||
"input:clear_screen",
|
||||
"Clear screen",
|
||||
|
||||
@@ -209,7 +209,8 @@ impl SlashCommandDataSource {
|
||||
/// Slash commands that are available in CLI agent rich input mode.
|
||||
/// Add command names here to make them accessible when composing prompts
|
||||
/// 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 {
|
||||
self.is_cloud_mode_v2
|
||||
|
||||
@@ -782,6 +782,9 @@ impl Input {
|
||||
_background_jobs if command.name == commands::BACKGROUND_JOBS.name => {
|
||||
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 !self.is_cloud_mode_input_v2_composing(ctx) {
|
||||
return false;
|
||||
|
||||
@@ -93,9 +93,9 @@ impl ActiveSession {
|
||||
self.current_working_directory.as_ref()
|
||||
}
|
||||
|
||||
/// Snapshot the shell integration's environment for an independent command process.
|
||||
/// Partial environment metadata reported by shell integration (for example KUBECONFIG).
|
||||
/// This is not a complete export snapshot and must not clear a child process's environment.
|
||||
/// This does not execute in or change the user's interactive shell.
|
||||
/// `None` means integration has not supplied a snapshot; an empty snapshot is intentional.
|
||||
pub fn environment_variables(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
|
||||
@@ -875,6 +875,8 @@ pub enum WorkspaceAction {
|
||||
/// Opens (or focuses) the in-app network log pane as a right-split of the
|
||||
/// active pane group. Gated on `ContextFlag::NetworkLogConsole`.
|
||||
OpenNetworkLogPane,
|
||||
/// Opens the app-wide inspector for model, compaction, and tool activity.
|
||||
OpenAIActivity,
|
||||
}
|
||||
|
||||
impl From<&WorkspaceAction> for LoginGatedFeature {
|
||||
@@ -1195,7 +1197,8 @@ impl WorkspaceAction {
|
||||
| ShowHandoffEnvironmentCreationModal
|
||||
| ShowCloudModeV2EnvironmentCreationModal
|
||||
| OpenCreateAuthSecretModal { .. }
|
||||
| OpenNetworkLogPane => false,
|
||||
| OpenNetworkLogPane
|
||||
| OpenAIActivity => false,
|
||||
#[cfg(debug_assertions)]
|
||||
ShowHoaOnboardingFlow => false,
|
||||
#[cfg(target_family = "wasm")]
|
||||
|
||||
@@ -155,6 +155,8 @@ use super::util::{
|
||||
};
|
||||
use super::{util, ActiveSession, TabBarDropTargetData, TabBarLocation, WorkspaceRegistry};
|
||||
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;
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
use crate::ai::agent::conversation::AIAgentHarness;
|
||||
@@ -1060,6 +1062,8 @@ pub struct Workspace {
|
||||
new_worktree_modal: ModalViewState<Modal<NewWorktreeModal>>,
|
||||
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||
background_jobs_modal: ModalViewState<Modal<BackgroundJobsView>>,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
ai_activity_modal: ModalViewState<Modal<AIActivityView>>,
|
||||
close_session_confirmation_dialog: ViewHandle<CloseSessionConfirmationDialog>,
|
||||
rewind_confirmation_dialog: ViewHandle<RewindConfirmationDialog>,
|
||||
delete_conversation_confirmation_dialog: ViewHandle<DeleteConversationConfirmationDialog>,
|
||||
@@ -2181,6 +2185,43 @@ impl Workspace {
|
||||
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(
|
||||
ctx: &mut ViewContext<Self>,
|
||||
) -> ViewHandle<RemoveTabConfigConfirmationDialog> {
|
||||
@@ -3026,6 +3067,8 @@ impl Workspace {
|
||||
let new_worktree_modal = Self::build_new_worktree_modal(ctx);
|
||||
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||
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);
|
||||
|
||||
@@ -3427,6 +3470,8 @@ impl Workspace {
|
||||
new_worktree_modal,
|
||||
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
|
||||
background_jobs_modal,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
ai_activity_modal,
|
||||
close_session_confirmation_dialog,
|
||||
rewind_confirmation_dialog,
|
||||
delete_conversation_confirmation_dialog,
|
||||
@@ -24012,6 +24057,16 @@ impl TypedActionView for Workspace {
|
||||
OpenNetworkLogPane => {
|
||||
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 } => {
|
||||
use crate::ai::skills::SkillManager;
|
||||
let modify_settings_skill = SkillManager::as_ref(ctx)
|
||||
@@ -26869,6 +26924,10 @@ impl View for Workspace {
|
||||
if self.background_jobs_modal.is_open() {
|
||||
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() {
|
||||
stack.add_child(ChildView::new(&self.workflow_modal).finish());
|
||||
|
||||
@@ -10,6 +10,7 @@ async-stream.workspace = true
|
||||
async-trait.workspace = true
|
||||
aws-sdk-bedrockruntime.workspace = true
|
||||
base64.workspace = true
|
||||
bytes.workspace = true
|
||||
futures.workspace = true
|
||||
galaxy_agent_core.workspace = true
|
||||
http.workspace = true
|
||||
@@ -21,6 +22,5 @@ uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
aws-smithy-http-client.workspace = true
|
||||
bytes.workspace = true
|
||||
rig-core = { workspace = true, features = ["test-utils"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
|
||||
@@ -13,6 +13,8 @@ use rig_core::providers::anthropic;
|
||||
use rig_core::providers::anthropic::client::AnthropicExt;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::http_client::DiagnosticHttpClient;
|
||||
|
||||
pub fn is_claude_subscription_token(credential: &str) -> bool {
|
||||
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();
|
||||
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();
|
||||
Client::<SubscriptionProvider>::builder()
|
||||
.api_key(SubscriptionToken(credential.to_string()))
|
||||
.http_headers(headers)
|
||||
.http_client(DiagnosticHttpClient::new())
|
||||
.build()
|
||||
// Never include a credential or raw header-validation error in diagnostics.
|
||||
.map_err(|_| "Could not configure Claude subscription authentication.".to_string())
|
||||
|
||||
@@ -11,6 +11,7 @@ use galaxy_agent_core::{
|
||||
use rig_core::client::CompletionClient;
|
||||
use rig_core::providers::chatgpt::{self, ChatGPTAuth};
|
||||
|
||||
use crate::http_client::DiagnosticHttpClient;
|
||||
use crate::request::build_completion_request;
|
||||
use crate::stream::start_model_turn;
|
||||
|
||||
@@ -155,6 +156,7 @@ impl AgentRuntime for ChatGPTSubscriptionRuntime {
|
||||
.api_key(auth)
|
||||
.allow_device_flow(false)
|
||||
.originator("galaxy")
|
||||
.http_client(DiagnosticHttpClient::new())
|
||||
.build()
|
||||
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
|
||||
let model = client.completion_model(&self.config.model);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
mod anthropic_auth;
|
||||
mod bedrock;
|
||||
mod chatgpt;
|
||||
mod http_client;
|
||||
mod native;
|
||||
mod openai_compatible;
|
||||
mod request;
|
||||
|
||||
@@ -13,6 +13,7 @@ use rig_core::http_client::{self, HttpClientExt};
|
||||
use rig_core::providers::gemini;
|
||||
|
||||
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::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> {
|
||||
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
|
||||
.list_models()
|
||||
.await
|
||||
@@ -349,7 +354,10 @@ impl AgentRuntime for GeminiRuntime {
|
||||
"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()))?;
|
||||
let model = client.completion_model(&self.config.model);
|
||||
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::providers::openai;
|
||||
|
||||
use crate::http_client::DiagnosticHttpClient;
|
||||
use crate::request::build_completion_request as build_provider_completion_request;
|
||||
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.
|
||||
.api_key(self.config.api_key.as_deref().unwrap_or_default())
|
||||
.base_url(&self.config.base_url)
|
||||
.http_client(DiagnosticHttpClient::new())
|
||||
.build()
|
||||
.map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?;
|
||||
let model = client.completion_model(&self.config.model);
|
||||
|
||||
@@ -484,9 +484,9 @@ fn map_completion_error(error: CompletionError) -> AgentError {
|
||||
}
|
||||
};
|
||||
let mut mapped = AgentError::new(kind, error.to_string());
|
||||
if is_authentication_failure {
|
||||
if kind == AgentErrorKind::Authentication {
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,22 @@ fn flattened_sse_http_client_error_is_recoverable_transport() {
|
||||
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]
|
||||
fn flattened_streamed_provider_overload_is_recoverable() {
|
||||
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]
|
||||
fn unrelated_provider_failures_do_not_become_authentication_failures() {
|
||||
for message in [
|
||||
|
||||
Reference in New Issue
Block a user