Add app-wide AI activity inspector

This commit is contained in:
Ryan Ward
2026-11-04 22:18:31 +00:00
parent a5eda544c4
commit 4b968f34f8
18 changed files with 1879 additions and 23 deletions
+7
View File
@@ -50,6 +50,13 @@ Environment variables:
- Per-event Bedrock diagnostic logs written to `bedrock-diagnostics.log` in the active Warp log directory - Per-event Bedrock diagnostic logs written to `bedrock-diagnostics.log` in the active Warp log directory
- Set `GALAXY_TOOL_DIAGNOSTICS=1` to enable verbose local tool queue/execution debug logs and cancellation backtraces. These diagnostics are disabled during routine operation. - Set `GALAXY_TOOL_DIAGNOSTICS=1` to enable verbose local tool queue/execution debug logs and cancellation backtraces. These diagnostics are disabled during routine operation.
### Local AI Activity Inspector
- Open `/ai-activity` or **Show AI Activity** in the Command Palette to start an app-wide, in-memory recording of model requests/results, compaction, tools, permission assessments, crosscheck reviews, and background-job lifecycle events.
- Each event carries its owning conversation/session where available. Filter by activity or click a session label; click the activity to inspect wrapped, selectable details. Recording continues after closing the inspector until paused, and Clear removes retained events without affecting running work.
- Capture is off until first opened, independent of remote-logging settings, and bounded to 2,000 events / 16 MiB of retained entries and 262,144 characters per capture. Truncation and eviction are explicit. Transport headers/provider credentials are excluded; user-supplied model/tool content may appear in the local capture.
- Requests are Galaxy's provider-neutral snapshots, not exact upstream HTTP payloads. ACP exposes only the traffic Galaxy exchanges with the external agent; the external agent's internal model requests and compaction are not visible. Provider token usage is marked as reported only when supplied by the runtime.
### Remote Logging: Pulling Galaxy Logs ### Remote Logging: Pulling Galaxy Logs
When investigating Galaxy behavior, retrieve recent remote logs from When investigating Galaxy behavior, retrieve recent remote logs from
+395
View File
@@ -0,0 +1,395 @@
//! Local, opt-in inspection of Galaxy's AI traffic and execution lifecycle.
//!
//! Payloads describe Galaxy's provider-neutral requests/results, not HTTP wire
//! traffic. No transport headers or provider credentials are captured here.
#[cfg(not(target_family = "wasm"))]
pub(crate) mod view;
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock, Mutex};
use chrono::{DateTime, Utc};
use serde::Serialize;
use serde_json::{json, Value};
/// Limits bound both retention and the work spent serializing a single event.
pub(crate) const MAX_PAYLOAD_CHARS: usize = 256 * 1024;
const MAX_ITEMS: usize = 2_000;
const MAX_RETAINED_BYTES: usize = 16 * 1024 * 1024;
const MAX_LABELS: usize = 4_096;
static RECORDING: AtomicBool = AtomicBool::new(false);
static STORE: LazyLock<Mutex<ActivityStore>> = LazyLock::new(Mutex::default);
#[derive(Clone)]
pub(crate) struct ActivityEntry {
pub id: u64,
pub timestamp: DateTime<Utc>,
pub session_id: Option<String>,
pub session_label: String,
pub category: String,
pub event: String,
pub summary: String,
pub model: Option<String>,
pub duration_ms: Option<u64>,
pub detail: String,
}
pub(crate) struct ActivitySnapshot {
pub recording: bool,
pub revision: u64,
pub dropped: usize,
pub entries: Vec<Arc<ActivityEntry>>,
}
#[derive(Default)]
struct ActivityStore {
entries: VecDeque<Arc<ActivityEntry>>,
labels: HashMap<String, String>,
label_order: VecDeque<String>,
sessions: HashMap<String, String>,
correlation_order: VecDeque<String>,
retained_bytes: usize,
next_id: u64,
revision: u64,
dropped: usize,
recording: bool,
generation: u64,
}
pub(crate) fn is_recording() -> bool {
RECORDING.load(Ordering::Relaxed)
}
pub(crate) fn set_recording(recording: bool) {
if let Ok(mut store) = STORE.lock() {
store.set_recording(recording);
RECORDING.store(recording, Ordering::Relaxed);
}
}
/// Stops neither running work nor recording; it only clears retained diagnostics.
pub(crate) fn clear() {
if let Ok(mut store) = STORE.lock() {
store.clear();
}
}
pub(crate) fn snapshot() -> ActivitySnapshot {
let store = STORE.lock().unwrap_or_else(|error| error.into_inner());
ActivitySnapshot {
recording: store.recording,
revision: store.revision,
dropped: store.dropped,
entries: store.entries.iter().cloned().collect(),
}
}
/// Titles are bounded separately from payloads, and retained across Clear so
/// subsequent tool/background events keep their owning session's name.
pub(crate) fn register_session(session_id: impl Into<String>, label: impl Into<String>) {
if !is_recording() {
return;
}
let session_id = session_id.into();
if session_id.is_empty() || session_id.len() > 256 {
return;
}
let label = bounded_text(&label.into(), 200);
if label.trim().is_empty() {
return;
}
if let Ok(mut store) = STORE.lock() {
if store.recording {
store.register_session(session_id, label);
}
}
}
pub(crate) fn record(event: &str, summary: impl Into<String>, context: Value) {
if !is_recording() {
return;
}
record_serializable(event, summary, context, &());
}
/// Serialize by reference with early termination, without first cloning a
/// conversation or materializing an unbounded serde_json::Value.
pub(crate) fn record_serializable(
event: &str,
summary: impl Into<String>,
context: Value,
payload: &(impl Serialize + ?Sized),
) {
if !is_recording() {
return;
}
let Some(generation) = STORE
.lock()
.ok()
.and_then(|store| store.capture_generation())
else {
return;
};
let timestamp = Utc::now();
#[derive(Serialize)]
struct Capture<'a, T: Serialize + ?Sized> {
context: &'a Value,
payload: &'a T,
}
let captured =
super::remote_logging::prefix_bounded_payload_context(MAX_PAYLOAD_CHARS, |writer| {
serde_json::to_writer_pretty(
writer,
&Capture {
context: &context,
payload,
},
)
});
let mut detail = captured["payload"].as_str().unwrap_or_default().to_owned();
if captured["truncated"].as_bool() == Some(true) {
detail.push_str(
"\n\n[Capture truncated at 262,144 characters; remaining payload was not serialized.]",
);
}
if let Some(error) = captured.get("serialization_error") {
detail = format!("Capture could not be serialized: {error}");
}
let Ok(mut store) = STORE.lock() else {
return;
};
store.push_captured(
generation,
event,
summary.into(),
&context,
detail,
timestamp,
);
}
impl ActivityStore {
fn set_recording(&mut self, recording: bool) {
if self.recording != recording {
self.generation = self.generation.wrapping_add(1);
self.recording = recording;
self.revision = self.revision.wrapping_add(1);
}
}
fn clear(&mut self) {
self.entries.clear();
self.retained_bytes = 0;
self.dropped = 0;
self.generation = self.generation.wrapping_add(1);
self.revision = self.revision.wrapping_add(1);
}
fn capture_generation(&self) -> Option<u64> {
self.recording.then_some(self.generation)
}
fn register_session(&mut self, session_id: String, label: String) {
let label = format!(
"{} · {}",
bounded_text(&label, 185),
session_id.chars().take(8).collect::<String>()
);
if self.labels.get(&session_id) == Some(&label) {
return;
}
if !self.labels.contains_key(&session_id) {
if self.labels.len() >= MAX_LABELS {
if let Some(oldest) = self.label_order.pop_front() {
self.labels.remove(&oldest);
}
}
self.label_order.push_back(session_id.clone());
}
self.labels.insert(session_id.clone(), label.clone());
for entry in &mut self.entries {
if entry.session_id.as_deref() == Some(&session_id) {
self.retained_bytes -= entry_size(entry);
Arc::make_mut(entry).session_label.clone_from(&label);
self.retained_bytes += entry_size(entry);
}
}
self.evict();
self.revision = self.revision.wrapping_add(1);
}
fn push_captured(
&mut self,
generation: u64,
event: &str,
summary: String,
context: &Value,
detail: String,
timestamp: DateTime<Utc>,
) {
// Clear and pause/resume invalidate captures that started before the
// transition, even when serialization finishes after recording resumes.
if self.capture_generation() != Some(generation) {
return;
}
let mut session_id = session_id(context);
let correlation = correlation_keys(context);
if session_id.is_none() {
session_id = correlation
.iter()
.find_map(|key| self.sessions.get(key).cloned());
}
if let Some(id) = &session_id {
for key in correlation {
if !self.sessions.contains_key(&key) {
if self.sessions.len() >= MAX_LABELS {
if let Some(oldest) = self.correlation_order.pop_front() {
self.sessions.remove(&oldest);
}
}
self.correlation_order.push_back(key.clone());
}
self.sessions.insert(key, id.clone());
}
}
let session_label = session_id.as_ref().map_or_else(
|| "App background activity".to_owned(),
|id| {
self.labels.get(id).cloned().unwrap_or_else(|| {
format!("Session {}", id.chars().take(8).collect::<String>())
})
},
);
let entry = Arc::new(ActivityEntry {
id: self.next_id,
timestamp,
session_id,
session_label,
category: category(event).to_owned(),
event: bounded_text(event, 160),
summary: bounded_text(&summary, 400),
model: context
.get("model_id")
.or_else(|| context.get("model"))
.and_then(Value::as_str)
.map(|model| bounded_text(model, 200)),
duration_ms: context
.get("elapsed_ms")
.or_else(|| context.get("duration_ms"))
.and_then(Value::as_u64),
detail,
});
self.next_id = self.next_id.wrapping_add(1);
self.retained_bytes += entry_size(&entry);
self.entries.push_back(entry);
self.evict();
self.revision = self.revision.wrapping_add(1);
}
fn evict(&mut self) {
while self.entries.len() > MAX_ITEMS || self.retained_bytes > MAX_RETAINED_BYTES {
if let Some(evicted) = self.entries.pop_front() {
self.retained_bytes -= entry_size(&evicted);
self.dropped += 1;
}
}
}
}
/// Entry retention counts metadata as well as captured content. The two
/// identity registries are separately bounded by count and string length.
fn entry_size(entry: &ActivityEntry) -> usize {
std::mem::size_of::<ActivityEntry>()
+ entry.session_id.as_ref().map_or(0, String::capacity)
+ entry.session_label.capacity()
+ entry.category.capacity()
+ entry.event.capacity()
+ entry.summary.capacity()
+ entry.model.as_ref().map_or(0, String::capacity)
+ entry.detail.capacity()
}
fn correlation_keys(context: &Value) -> Vec<String> {
["stream_id", "request_id", "provider_run_id", "run_id"]
.into_iter()
.filter_map(|key| {
let value = context.get(key)?.as_str()?;
// Overlong identifiers cannot be allowed to grow the registry or
// be truncated into another session's correlation key.
(!value.is_empty() && value.len() <= 256).then(|| format!("{key}:{value}"))
})
.collect()
}
fn session_id(context: &Value) -> Option<String> {
[
context.get("conversation_id"),
context.pointer("/tool/conversation_id"),
context.pointer("/identifiers/client_conversation_id"),
context.get("parent_conversation_id"),
]
.into_iter()
.flatten()
.filter_map(Value::as_str)
.find(|id| !id.is_empty() && id.len() <= 256)
.map(str::to_owned)
}
fn category(event: &str) -> &'static str {
if event.contains("compact") || event.contains("summar") {
"Compaction"
} else if event.contains("risk_review")
|| event.contains("risk_assessment")
|| event.contains("permission")
{
"Permissions"
} else if event.contains("tool") || event.contains("action") {
"Tools"
} else if event.contains("command") || event.contains("job") {
"Background jobs"
} else if event.contains("model") || event.contains("llm") {
"Completions"
} else {
"Lifecycle"
}
}
fn bounded_text(text: &str, limit: usize) -> String {
text.chars().take(limit).collect()
}
/// Reuses existing operational metadata while keeping remote logging consent
/// and credentials independent from this local inspector.
pub(crate) fn record_log(record: &super::remote_logging::RemoteLogRecord) {
if !is_recording() {
return;
}
let event = record
.context
.get("event")
.and_then(Value::as_str)
.unwrap_or("activity");
record_serializable(
event,
&record.message,
json!({
"conversation_id": session_id(&record.context),
"stream_id": record.context.get("stream_id"),
"request_id": record.context.get("request_id"),
"provider_run_id": record.context.get("provider_run_id"),
"run_id": record.context.get("run_id"),
"duration_ms": record.context.get("duration_ms"),
"model_id": record.context.get("model_id"),
"elapsed_ms": record.context.get("elapsed_ms"),
"level": record.level.as_str(),
}),
&record.context,
);
}
#[cfg(test)]
#[path = "mod_tests.rs"]
mod tests;
+190
View File
@@ -0,0 +1,190 @@
use super::*;
fn recording_store() -> ActivityStore {
let mut store = ActivityStore::default();
store.set_recording(true);
store
}
fn capture(store: &mut ActivityStore, context: Value, detail: String) {
store.push_captured(
store.capture_generation().unwrap(),
"model_response",
"Model completed".to_owned(),
&context,
detail,
Utc::now(),
);
}
#[test]
fn clear_and_pause_resume_invalidate_in_flight_captures() {
let mut store = recording_store();
let first_generation = store.capture_generation().unwrap();
store.clear();
store.push_captured(
first_generation,
"tool_result",
"Late".to_owned(),
&json!({}),
"old".to_owned(),
Utc::now(),
);
assert!(store.entries.is_empty());
let second_generation = store.capture_generation().unwrap();
store.set_recording(false);
assert!(store.capture_generation().is_none());
store.set_recording(true);
store.push_captured(
second_generation,
"tool_result",
"Late".to_owned(),
&json!({}),
"old".to_owned(),
Utc::now(),
);
assert!(store.entries.is_empty());
capture(&mut store, json!({}), "new".to_owned());
assert_eq!(store.entries.len(), 1);
}
#[test]
fn retention_evicts_oldest_entries_by_count_and_allocated_size() {
let mut store = recording_store();
for _ in 0..MAX_ITEMS + 3 {
capture(&mut store, json!({}), String::new());
}
assert_eq!(store.entries.len(), MAX_ITEMS);
assert_eq!(store.dropped, 3);
assert_eq!(store.entries.front().unwrap().id, 3);
store.clear();
let detail = "x".repeat(1024 * 1024);
for _ in 0..20 {
capture(&mut store, json!({}), detail.clone());
}
assert!(store.retained_bytes <= MAX_RETAINED_BYTES);
assert_eq!(
store.retained_bytes,
store
.entries
.iter()
.map(|entry| entry_size(entry))
.sum::<usize>()
);
assert!(store.dropped > 0);
// Metadata also consumes the budget, so sixteen 1 MiB payloads do not fit.
assert!(store.entries.len() < 16);
}
#[test]
fn session_names_propagate_to_prior_entries_and_correlated_responses() {
let mut store = recording_store();
capture(
&mut store,
json!({"conversation_id": "session-12345", "stream_id": "stream-1"}),
String::new(),
);
store.register_session("session-12345".to_owned(), "Inspect build".to_owned());
let previous_snapshot = store.entries.front().unwrap().clone();
capture(&mut store, json!({"stream_id": "stream-1"}), String::new());
let response = store.entries.back().unwrap();
assert_eq!(response.session_id.as_deref(), Some("session-12345"));
assert_eq!(response.session_label, "Inspect build · session-");
assert_eq!(previous_snapshot.session_label, response.session_label);
let bytes_before_rename = store.retained_bytes;
store.register_session(
"session-12345".to_owned(),
"Longer build inspection title".to_owned(),
);
assert!(store.retained_bytes > bytes_before_rename);
assert_eq!(
store.retained_bytes,
store
.entries
.iter()
.map(|entry| entry_size(entry))
.sum::<usize>()
);
}
#[test]
fn correlation_rejects_oversized_ids_and_preserves_explicit_ownership() {
let mut store = recording_store();
let overlong = "s".repeat(100_000);
capture(
&mut store,
json!({"conversation_id": "session-a", "stream_id": overlong}),
String::new(),
);
assert!(store.sessions.is_empty());
capture(&mut store, json!({"stream_id": overlong}), String::new());
assert!(store.entries.back().unwrap().session_id.is_none());
capture(
&mut store,
json!({"conversation_id": "session-a", "request_id": "request-1"}),
String::new(),
);
capture(
&mut store,
json!({"conversation_id": "session-b", "request_id": "request-1"}),
String::new(),
);
assert_eq!(
store.entries.back().unwrap().session_id.as_deref(),
Some("session-b")
);
assert_eq!(
session_id(&json!({"conversation_id": "", "tool": {"conversation_id": "owner"}})),
Some("owner".to_owned())
);
}
#[test]
fn risk_assessments_share_the_permissions_filter() {
assert_eq!(category("tool_risk_assessment_finished"), "Permissions");
assert_eq!(category("risk_review_request"), "Permissions");
assert_eq!(category("provider_compaction_requested"), "Compaction");
assert_eq!(
category("shell_command_terminal_completed"),
"Background jobs"
);
}
#[test]
fn long_recordings_keep_accepting_new_session_and_request_identities() {
let mut store = recording_store();
for index in 0..MAX_LABELS + 2 {
let owner = format!("session-{index}");
store.register_session(owner.clone(), format!("Task {index}"));
capture(
&mut store,
json!({"conversation_id": owner, "request_id": format!("request-{index}")}),
String::new(),
);
}
assert_eq!(store.labels.len(), MAX_LABELS);
assert_eq!(store.label_order.len(), MAX_LABELS);
assert_eq!(store.sessions.len(), MAX_LABELS);
assert_eq!(store.correlation_order.len(), MAX_LABELS);
assert!(!store.labels.contains_key("session-0"));
assert!(!store.sessions.contains_key("request_id:request-0"));
let latest = MAX_LABELS + 1;
capture(
&mut store,
json!({"request_id": format!("request-{latest}")}),
String::new(),
);
let response = store.entries.back().unwrap();
assert_eq!(
response.session_id.as_deref(),
Some(format!("session-{latest}").as_str())
);
assert!(response
.session_label
.starts_with(&format!("Task {latest}")));
}
+692
View File
@@ -0,0 +1,692 @@
//! App-wide live browser for local AI diagnostics.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use galaxyui::elements::{
Border, ChildView, Clipped, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox,
Container, CrossAxisAlignment, Expanded, Flex, Hoverable, MainAxisAlignment, MainAxisSize,
MouseStateHandle, Padding, ParentElement, RowBackground, ScrollbarWidth, Table,
TableColumnWidth, TableConfig, TableHeader, TableStateHandle, TableVerticalSizing, Text,
};
use galaxyui::fonts::{Properties, Weight};
use galaxyui::platform::Cursor;
use galaxyui::r#async::Timer;
use galaxyui::text_layout::ClipConfig;
use galaxyui::{
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, ViewHandle,
};
use super::{ActivityEntry, ActivitySnapshot};
use crate::appearance::Appearance;
use crate::editor::{
EditorOptions, EditorView, Event as EditorEvent, InteractionState, PlainTextEditorViewAction,
SingleLineEditorOptions, TextOptions,
};
use crate::modal::ModalAction;
use crate::view_components::action_button::{ActionButton, ButtonSize, NakedTheme, SecondaryTheme};
const PAGE_SIZE: usize = 200;
#[derive(Clone, Debug)]
pub enum AIActivityAction {
ToggleRecording,
Clear,
Select(u64),
FilterSession(Option<String>),
FilterCategory(Option<String>),
ShowMore,
}
pub struct AIActivityView {
snapshot: ActivitySnapshot,
search: ViewHandle<EditorView>,
detail: ViewHandle<EditorView>,
recording_button: ViewHandle<ActionButton>,
clear_button: ViewHandle<ActionButton>,
all_sessions_button: ViewHandle<ActionButton>,
more_button: ViewHandle<ActionButton>,
category_buttons: Vec<(Option<String>, ViewHandle<ActionButton>)>,
selected: Option<Arc<ActivityEntry>>,
session_filter: Option<String>,
category_filter: Option<String>,
rows: HashMap<u64, (MouseStateHandle, MouseStateHandle)>,
display_limit: usize,
visible: bool,
refresh_scheduled: bool,
list_scroll: ClippedScrollStateHandle,
detail_scroll: ClippedScrollStateHandle,
filters_scroll: ClippedScrollStateHandle,
table_state: TableStateHandle,
}
impl AIActivityView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let search = ctx.add_typed_action_view(|ctx| {
let mut editor = EditorView::single_line(SingleLineEditorOptions::default(), ctx);
editor.set_placeholder_text("Search activity, model, or session", ctx);
editor
});
ctx.subscribe_to_view(&search, |me, _, event, ctx| match event {
EditorEvent::Edited(_) => {
me.display_limit = PAGE_SIZE;
me.synchronize_rows(ctx);
}
EditorEvent::Escape => ctx.dispatch_typed_action(&ModalAction::Close),
_ => {}
});
let appearance = Appearance::as_ref(ctx);
let font = appearance.monospace_font_family();
let size = appearance.monospace_font_size();
let detail = ctx.add_typed_action_view(|ctx| {
let mut editor = EditorView::new(
EditorOptions {
text: TextOptions {
font_family_override: Some(font),
font_size_override: Some(size),
..Default::default()
},
autogrow: true,
soft_wrap: true,
supports_vim_mode: false,
use_settings_line_height_ratio: true,
..Default::default()
},
ctx,
);
editor.set_interaction_state(InteractionState::Selectable, ctx);
editor
});
ctx.subscribe_to_view(&detail, |_, _, event, ctx| {
if matches!(event, EditorEvent::Escape) {
ctx.dispatch_typed_action(&ModalAction::Close);
}
});
let recording_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Pause recording", SecondaryTheme)
.with_size(ButtonSize::Small)
.on_click(|ctx| ctx.dispatch_typed_action(AIActivityAction::ToggleRecording))
});
let clear_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Clear", SecondaryTheme)
.with_size(ButtonSize::Small)
.on_click(|ctx| ctx.dispatch_typed_action(AIActivityAction::Clear))
});
let all_sessions_button = ctx.add_typed_action_view(|_| {
ActionButton::new("All sessions", NakedTheme)
.with_size(ButtonSize::Small)
.on_click(|ctx| ctx.dispatch_typed_action(AIActivityAction::FilterSession(None)))
});
let more_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Show more", SecondaryTheme)
.with_size(ButtonSize::Small)
.on_click(|ctx| ctx.dispatch_typed_action(AIActivityAction::ShowMore))
});
let category_buttons = [
(None, "All activity"),
(Some("Completions"), "Models"),
(Some("Compaction"), "Compaction"),
(Some("Tools"), "Tools"),
(Some("Permissions"), "Permissions"),
(Some("Background jobs"), "Jobs"),
(Some("Lifecycle"), "Lifecycle"),
]
.into_iter()
.map(|(category, label)| {
let category = category.map(str::to_owned);
let value = category.clone();
let button = ctx.add_typed_action_view(|_| {
ActionButton::new(label, NakedTheme)
.with_size(ButtonSize::Small)
.on_click(move |ctx| {
ctx.dispatch_typed_action(AIActivityAction::FilterCategory(value.clone()))
})
});
(category, button)
})
.collect();
Self {
// Workspace modals are constructed while hidden. Do not retain global
// payloads until this inspector is actually opened.
snapshot: ActivitySnapshot {
recording: super::is_recording(),
revision: 0,
dropped: 0,
entries: Vec::new(),
},
search,
detail,
recording_button,
clear_button,
all_sessions_button,
more_button,
category_buttons,
selected: None,
session_filter: None,
category_filter: None,
rows: HashMap::new(),
display_limit: PAGE_SIZE,
visible: false,
refresh_scheduled: false,
list_scroll: Default::default(),
detail_scroll: Default::default(),
filters_scroll: Default::default(),
table_state: Default::default(),
}
}
pub fn on_open(&mut self, ctx: &mut ViewContext<Self>) {
// Opening the inspector is the explicit opt-in to in-memory payload capture.
if super::snapshot().revision == 0 {
super::set_recording(true);
}
self.visible = true;
self.refresh(ctx);
self.update_recording_button(ctx);
self.synchronize_rows(ctx);
self.schedule_refresh(ctx);
ctx.focus(&self.search);
}
pub fn on_close(&mut self, ctx: &mut ViewContext<Self>) {
self.visible = false;
self.snapshot.entries.clear();
self.rows.clear();
self.clear_detail(ctx);
self.list_scroll = Default::default();
self.table_state = Default::default();
// Leave any pending refresh scheduled: it will observe `visible`, or
// resume on a rapid reopen, without creating a second polling chain.
ctx.notify();
}
fn schedule_refresh(&mut self, ctx: &mut ViewContext<Self>) {
if self.refresh_scheduled || !self.visible {
return;
}
self.refresh_scheduled = true;
ctx.spawn(
async { Timer::after(Duration::from_millis(500)).await },
|me, _, ctx| {
me.refresh_scheduled = false;
if me.visible {
me.refresh(ctx);
me.schedule_refresh(ctx);
}
},
);
}
fn refresh(&mut self, ctx: &mut ViewContext<Self>) {
let snapshot = super::snapshot();
let changed = snapshot.revision != self.snapshot.revision
|| snapshot.recording != self.snapshot.recording;
self.snapshot = snapshot;
if changed {
if self.selected.as_ref().is_some_and(|selected| {
!self
.snapshot
.entries
.iter()
.any(|entry| entry.id == selected.id)
}) {
self.clear_detail(ctx);
}
self.update_recording_button(ctx);
self.synchronize_rows(ctx);
}
}
fn clear_detail(&mut self, ctx: &mut ViewContext<Self>) {
self.selected = None;
self.detail.update(ctx, |editor, ctx| {
editor.set_interaction_state(InteractionState::Editable, ctx);
editor.clear_buffer_and_reset_undo_stack(ctx);
editor.set_interaction_state(InteractionState::Selectable, ctx);
});
self.detail_scroll = Default::default();
}
fn update_recording_button(&self, ctx: &mut ViewContext<Self>) {
self.recording_button.update(ctx, |button, ctx| {
button.set_label(
if self.snapshot.recording {
"Pause recording"
} else {
"Record"
},
ctx,
);
});
}
fn filtered_entries(&self, app: &AppContext) -> Vec<Arc<ActivityEntry>> {
let query = self
.search
.as_ref(app)
.buffer_text(app)
.trim()
.to_lowercase();
self.snapshot
.entries
.iter()
.rev()
.filter(|entry| {
self.session_filter
.as_ref()
.is_none_or(|session| entry.session_id.as_ref() == Some(session))
&& self
.category_filter
.as_ref()
.is_none_or(|category| entry.category == *category)
&& (query.is_empty()
|| [
&entry.session_label,
&entry.summary,
&entry.event,
&entry.category,
]
.into_iter()
.any(|field| field.to_lowercase().contains(&query))
|| entry
.session_id
.as_ref()
.is_some_and(|id| id.to_lowercase().contains(&query))
|| entry
.model
.as_ref()
.is_some_and(|model| model.to_lowercase().contains(&query)))
})
.cloned()
.collect()
}
fn synchronize_rows(&mut self, ctx: &mut ViewContext<Self>) {
let ids = self
.filtered_entries(ctx)
.into_iter()
.take(self.display_limit)
.map(|entry| entry.id)
.collect::<HashSet<_>>();
self.rows.retain(|id, _| ids.contains(id));
for id in ids {
self.rows.entry(id).or_default();
}
for (category, button) in &self.category_buttons {
button.update(ctx, |button, ctx| {
button.set_active(*category == self.category_filter, ctx)
});
}
self.all_sessions_button.update(ctx, |button, ctx| {
button.set_active(self.session_filter.is_none(), ctx);
});
ctx.notify();
}
fn select_entry(&mut self, id: u64, ctx: &mut ViewContext<Self>) {
let Some(entry) = self
.snapshot
.entries
.iter()
.find(|entry| entry.id == id)
.cloned()
else {
return;
};
let session_id = entry.session_id.as_deref().unwrap_or("Unscoped");
let text = format!(
"{}\nSession: {}\nSession ID: {}\nTime: {}\nEvent: {}\n\n{}",
entry.summary,
entry.session_label,
session_id,
entry
.timestamp
.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S%.3f %Z"),
entry.event,
readable_detail(&entry),
);
self.detail.update(ctx, |editor, ctx| {
editor.set_interaction_state(InteractionState::Editable, ctx);
editor.clear_buffer_and_reset_undo_stack(ctx);
editor.insert_with_styles(&text, &[], PlainTextEditorViewAction::ReplaceBuffer, ctx);
editor.set_interaction_state(InteractionState::Selectable, ctx);
});
self.selected = Some(entry);
self.detail_scroll = Default::default();
ctx.notify();
}
fn render_table(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let entries = self
.filtered_entries(app)
.into_iter()
.take(self.display_limit)
.collect::<Vec<_>>();
if entries.is_empty() {
let message = if self.snapshot.entries.is_empty() {
"Waiting for AI activity. Continue a conversation in any session to capture requests, responses, tools, and compaction."
} else {
"No activity matches these filters."
};
return Container::new(
Text::new(
message,
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.soft_wrap(true)
.with_color(theme.sub_text_color(theme.background()).into())
.finish(),
)
.with_padding(Padding::uniform(20.))
.finish();
}
let headers = [
("Time", TableColumnWidth::Fixed(102.)),
("Session", TableColumnWidth::Flex(1.)),
("Type", TableColumnWidth::Fixed(100.)),
("Activity", TableColumnWidth::Flex(1.8)),
("Model", TableColumnWidth::Flex(1.)),
("Duration", TableColumnWidth::Fixed(88.)),
]
.into_iter()
.map(|(label, width)| {
TableHeader::new(
Text::new(
label,
appearance.ui_font_family(),
appearance.ui_font_size() - 1.,
)
.with_style(Properties::default().weight(Weight::Semibold))
.with_color(theme.nonactive_ui_text_color().into())
.finish(),
)
.with_width(width)
})
.collect();
let rows = self.rows.clone();
let selected = self.selected.as_ref().map(|entry| entry.id);
let table = Table::new(self.table_state.clone(), 0., 0.)
.with_headers(headers)
.with_row_count(entries.len())
.with_row_render_fn(move |index, app| {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let entry = &entries[index];
let id = entry.id;
let Some((row_mouse, session_mouse)) = rows.get(&id) else {
return Vec::new();
};
let text = |value: String| {
Text::new_inline(
value,
appearance.ui_font_family(),
appearance.ui_font_size() - 1.,
)
.with_clip(ClipConfig::ellipsis())
.with_color(theme.active_ui_text_color().into())
.finish()
};
let session_id = entry.session_id.clone();
let session = text(entry.session_label.clone());
let session = if session_id.is_some() {
Hoverable::new(session_mouse.clone(), move |_| session)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AIActivityAction::FilterSession(
session_id.clone(),
))
})
.with_cursor(Cursor::PointingHand)
.finish()
} else {
session
};
let summary = entry.summary.clone();
let activity = Hoverable::new(row_mouse.clone(), move |_| {
Text::new_inline(
summary,
appearance.ui_font_family(),
appearance.ui_font_size(),
)
.with_clip(ClipConfig::ellipsis())
.with_style(Properties::default().weight(if selected == Some(id) {
Weight::Semibold
} else {
Weight::Normal
}))
.with_color(theme.active_ui_text_color().into())
.finish()
})
.on_click(move |ctx, _, _| ctx.dispatch_typed_action(AIActivityAction::Select(id)))
.with_cursor(Cursor::PointingHand)
.finish();
vec![
text(
entry
.timestamp
.with_timezone(&chrono::Local)
.format("%H:%M:%S%.3f")
.to_string(),
),
session,
text(entry.category.clone()),
activity,
text(entry.model.clone().unwrap_or_else(|| "—".to_owned())),
text(
entry
.duration_ms
.map(|ms| format!("{:.2} s", ms as f64 / 1000.))
.unwrap_or_else(|| "—".to_owned()),
),
]
})
.with_config(TableConfig {
border_width: 1.,
border_color: theme.outline().into(),
outer_border: true,
column_dividers: false,
row_dividers: true,
cell_padding: 10.,
header_background: theme.surface_2().into(),
row_background: RowBackground::uniform(theme.surface_1().into()),
fixed_header: false,
vertical_sizing: TableVerticalSizing::ExpandToContent,
measure_body_cells_for_intrinsic_widths: false,
});
ClippedScrollable::vertical(
self.list_scroll.clone(),
table.finish(),
ScrollbarWidth::Auto,
theme.nonactive_ui_detail().into(),
theme.active_ui_detail().into(),
galaxyui::elements::Fill::None,
)
.finish()
}
}
/// Legacy/ACP and projected tool arguments arrive as bounded Debug strings.
/// Unwrap those strings for reading while retaining their operational metadata.
/// A truncated capture is deliberately left as text rather than parsed as JSON.
fn readable_detail(entry: &ActivityEntry) -> String {
let Ok(capture) = serde_json::from_str::<serde_json::Value>(&entry.detail) else {
return entry.detail.clone();
};
let payload = &capture["payload"];
let raw = if entry.event == "tool_call" {
payload.as_str()
} else if matches!(
entry.event.as_str(),
"raw_model_request" | "raw_model_response"
) {
payload
.pointer("/raw_payload/payload")
.and_then(serde_json::Value::as_str)
} else {
None
};
let Some(raw) = raw else {
return entry.detail.clone();
};
let decoded = serde_json::from_str::<String>(raw).unwrap_or_else(|_| raw.to_owned());
let mut metadata = capture;
if entry.event == "tool_call" {
metadata["payload"] = serde_json::json!("See captured content below");
} else if let Some(raw_payload) = metadata
.pointer_mut("/payload/raw_payload")
.and_then(serde_json::Value::as_object_mut)
{
raw_payload.remove("payload");
}
format!(
"{}\n\nCaptured content:\n{decoded}",
serde_json::to_string_pretty(&metadata).unwrap_or_default()
)
}
impl Entity for AIActivityView {
type Event = ();
}
impl View for AIActivityView {
fn ui_name() -> &'static str {
"AIActivityView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let small_text = |text: String| {
Text::new(
text,
appearance.ui_font_family(),
appearance.ui_font_size() - 1.,
)
.soft_wrap(true)
.with_color(theme.sub_text_color(theme.background()).into())
.finish()
};
let search = Container::new(Clipped::new(ChildView::new(&self.search).finish()).finish())
.with_horizontal_padding(10.)
.with_vertical_padding(8.)
.with_border(Border::all(1.).with_border_color(theme.outline().into()))
.finish();
let mut filters = Flex::row()
.with_spacing(4.)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
for (_, button) in &self.category_buttons {
filters.add_child(ChildView::new(button).finish());
}
filters.add_child(ChildView::new(&self.all_sessions_button).finish());
let filters = ConstrainedBox::new(
ClippedScrollable::horizontal(
self.filters_scroll.clone(),
filters.finish(),
ScrollbarWidth::Auto,
theme.nonactive_ui_detail().into(),
theme.active_ui_detail().into(),
galaxyui::elements::Fill::None,
)
.finish(),
)
.with_height(36.)
.finish();
let session_label = self.session_filter.as_ref().map(|id| {
self.snapshot
.entries
.iter()
.find(|entry| entry.session_id.as_ref() == Some(id))
.map(|entry| entry.session_label.clone())
.unwrap_or_else(|| id.clone())
});
let count = self.filtered_entries(app).len();
let mut footer = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(small_text(format!(
"{} of {count} events · {}{}",
self.display_limit.min(count),
session_label
.map(|label| format!("Session: {label}"))
.unwrap_or_else(|| "All sessions · click a session to filter".to_owned()),
if self.snapshot.dropped > 0 {
format!(" · {} older events discarded", self.snapshot.dropped)
} else {
String::new()
}
)));
if count > self.display_limit {
footer.add_child(ChildView::new(&self.more_button).finish());
}
let detail = if self.selected.is_some() {
ClippedScrollable::vertical(
self.detail_scroll.clone(),
Container::new(ChildView::new(&self.detail).finish())
.with_padding(Padding::uniform(12.))
.finish(),
ScrollbarWidth::Auto,
theme.nonactive_ui_detail().into(),
theme.active_ui_detail().into(),
galaxyui::elements::Fill::None,
)
.finish()
} else {
Container::new(small_text("Click an activity to inspect its request, response, or lifecycle details. Text is selectable and wrapped.".to_owned()))
.with_padding(Padding::uniform(16.)).finish()
};
Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch)
.with_main_axis_size(MainAxisSize::Max).with_spacing(10.)
.with_child(Flex::row().with_spacing(8.).with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_child(Expanded::new(1., search).finish())
.with_child(ChildView::new(&self.recording_button).finish())
.with_child(ChildView::new(&self.clear_button).finish()).finish())
.with_child(filters)
.with_child(small_text(format!("{} · Local Galaxy request/result snapshots, up to 2,000 events / 16 MiB and 256 Ki characters per event. Recording continues when this window closes.",
if self.snapshot.recording { "Recording all sessions" } else { "Recording paused" })))
.with_child(Expanded::new(1., self.render_table(app)).finish())
.with_child(footer.finish())
.with_child(Expanded::new(1., Container::new(detail)
.with_background(theme.background())
.with_border(Border::all(1.).with_border_color(theme.outline().into())).finish()).finish())
.finish()
}
}
impl TypedActionView for AIActivityView {
type Action = AIActivityAction;
fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext<Self>) {
match action {
AIActivityAction::ToggleRecording => {
super::set_recording(!super::snapshot().recording);
self.refresh(ctx);
}
AIActivityAction::Clear => {
super::clear();
self.clear_detail(ctx);
self.refresh(ctx);
}
AIActivityAction::Select(id) => self.select_entry(*id, ctx),
AIActivityAction::FilterSession(session) => {
self.session_filter = session.clone();
self.display_limit = PAGE_SIZE;
self.list_scroll = Default::default();
self.synchronize_rows(ctx);
}
AIActivityAction::FilterCategory(category) => {
self.category_filter = category.clone();
self.display_limit = PAGE_SIZE;
self.list_scroll = Default::default();
self.synchronize_rows(ctx);
}
AIActivityAction::ShowMore => {
self.display_limit += PAGE_SIZE;
self.synchronize_rows(ctx);
}
}
}
}
+63
View File
@@ -821,6 +821,18 @@ fn log_tool_event(
); );
} }
/// Stream the app's projected tool arguments into the inspector's bounded
/// writer instead of allocating an unbounded Debug string first.
#[cfg(not(target_family = "wasm"))]
struct ActivityToolArguments<'a>(&'a AIAgentActionType);
#[cfg(not(target_family = "wasm"))]
impl serde::Serialize for ActivityToolArguments<'_> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(&format_args!("{:#?}", self.0))
}
}
pub struct BlocklistAIActionModel { pub struct BlocklistAIActionModel {
executor: ModelHandle<BlocklistAIActionExecutor>, executor: ModelHandle<BlocklistAIActionExecutor>,
active_session: ModelHandle<ActiveSession>, active_session: ModelHandle<ActiveSession>,
@@ -2029,6 +2041,22 @@ impl BlocklistAIActionModel {
action.id, action.id,
std::mem::discriminant(&action.action) std::mem::discriminant(&action.action)
); );
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
crate::ai::activity::record_serializable(
"tool_call",
format!("Tool requested: {}", action_tool_name(action)),
serde_json::json!({
"conversation_id": conversation_id.to_string(),
"action_id": action.id.to_string(),
"task_id": action.task_id.to_string(),
"tool_name": action_tool_name(action),
"payload_kind": "projected_tool_arguments",
"requires_result": action.requires_result,
}),
&ActivityToolArguments(&action.action),
);
}
} }
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
log_tool_event( log_tool_event(
@@ -2616,6 +2644,41 @@ impl BlocklistAIActionModel {
{ {
tool_result.content = reason; tool_result.content = reason;
} }
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
#[derive(serde::Serialize)]
struct ResultPayload<'a> {
content: &'a str,
status: &'a ToolResultStatus,
image_count: usize,
image_bytes_omitted: usize,
}
crate::ai::activity::record_serializable(
"tool_result",
format!(
"Tool result: {}",
action_result_type_name(&action_result.result)
),
serde_json::json!({
"conversation_id": conversation_id.to_string(),
"action_id": action_result.id.to_string(),
"task_id": action_result.task_id.to_string(),
"result_type": action_result_type_name(&action_result.result),
"permission_denied": permission_denied,
"payload_kind": "provider_neutral_tool_result",
}),
&ResultPayload {
content: &tool_result.content,
status: &tool_result.status,
image_count: tool_result.images.len(),
image_bytes_omitted: tool_result
.images
.iter()
.map(|image| image.data.len())
.sum(),
},
);
}
if execution_ref.is_none() { if execution_ref.is_none() {
self.finished_tool_results self.finished_tool_results
.entry(conversation_id) .entry(conversation_id)
@@ -692,7 +692,18 @@ impl BlocklistAIActionModel {
request.max_output_tokens = Some(u64::from(REVIEW_MAX_OUTPUT_TOKENS)); request.max_output_tokens = Some(u64::from(REVIEW_MAX_OUTPUT_TOKENS));
let started = Instant::now(); let started = Instant::now();
ctx.spawn( ctx.spawn(
assess(provider_config, request, cancel, control), assess(
provider_config,
request,
cancel,
control,
#[cfg(not(target_family = "wasm"))]
serde_json::json!({
"conversation_id": conversation_id.to_string(),
"action_id": action.id.to_string(),
"request_id": token.to_string(),
}),
),
move |me, result, ctx| me.finish_action_review(key, token, started, result, ctx), move |me, result, ctx| me.finish_action_review(key, token, started, result, ctx),
); );
None None
@@ -1010,18 +1021,41 @@ async fn assess(
request: TurnRequest, request: TurnRequest,
sender: TurnCommandSender, sender: TurnCommandSender,
control: TurnControl, control: TurnControl,
#[cfg(not(target_family = "wasm"))] activity_identity: serde_json::Value,
) -> anyhow::Result<Assessment> { ) -> anyhow::Result<Assessment> {
anyhow::ensure!( anyhow::ensure!(
serde_json::to_vec(&request)?.len() <= MAX_REVIEW_INPUT_BYTES, serde_json::to_vec(&request)?.len() <= MAX_REVIEW_INPUT_BYTES,
"action is too large for a reliable bounded assessment" "action is too large for a reliable bounded assessment"
); );
#[cfg(not(target_family = "wasm"))]
let started = Instant::now();
#[cfg(not(target_family = "wasm"))]
let activity_model_id = request.model.as_str().to_string();
#[cfg(not(target_family = "wasm"))]
let activity_context = || {
let mut context = activity_identity.clone();
context["model_id"] = serde_json::json!(activity_model_id);
context["elapsed_ms"] = serde_json::json!(started.elapsed().as_millis());
context
};
let review = async { let review = async {
let model_id = request.model.as_str().to_string(); let model_id = request.model.as_str().to_string();
let runtime = provider_runtime_for_request(provider_config, &request).await?; let runtime = provider_runtime_for_request(provider_config, &request).await?;
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
crate::ai::activity::record_serializable(
"risk_review_request",
"Permission assessment request",
activity_context(),
&request,
);
}
let mut stream = runtime.start_turn(request, control).await?; let mut stream = runtime.start_turn(request, control).await?;
let mut text = String::new(); let mut text = String::new();
let mut reasoning_bytes = 0usize; let mut reasoning_bytes = 0usize;
let mut output_tokens = None; let mut output_tokens = None;
#[cfg(not(target_family = "wasm"))]
let mut response_usage = None;
while let Some(event) = stream.next().await { while let Some(event) = stream.next().await {
match event? { match event? {
AgentEvent::TextDelta { text: delta } => { AgentEvent::TextDelta { text: delta } => {
@@ -1031,12 +1065,25 @@ async fn assess(
); );
text.push_str(&delta); text.push_str(&delta);
} }
AgentEvent::TurnStopped {
reason: StopReason::Completed,
} => return Assessment::parse(&text).map_err(|error| anyhow::anyhow!(
"{error} (model={model_id}, decision_bytes={}, reasoning_bytes={reasoning_bytes}, output_tokens={output_tokens:?})", text.len()
)),
AgentEvent::TurnStopped { reason } => { AgentEvent::TurnStopped { reason } => {
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
crate::ai::activity::record_serializable(
"risk_review_response",
"Permission assessment response",
activity_context(),
&serde_json::json!({
"text": text,
"usage": response_usage,
"stop_reason": reason,
}),
);
}
if reason == StopReason::Completed {
return Assessment::parse(&text).map_err(|error| anyhow::anyhow!(
"{error} (model={model_id}, decision_bytes={}, reasoning_bytes={reasoning_bytes}, output_tokens={output_tokens:?})", text.len()
));
}
anyhow::bail!("permission reviewer stopped with {reason:?} (model={model_id}, decision_bytes={}, reasoning_bytes={reasoning_bytes}, output_tokens={output_tokens:?})", text.len()) anyhow::bail!("permission reviewer stopped with {reason:?} (model={model_id}, decision_bytes={}, reasoning_bytes={reasoning_bytes}, output_tokens={output_tokens:?})", text.len())
} }
AgentEvent::Tool { .. } => { AgentEvent::Tool { .. } => {
@@ -1050,6 +1097,10 @@ async fn assess(
} }
AgentEvent::UsageUpdated { usage } => { AgentEvent::UsageUpdated { usage } => {
output_tokens = Some(usage.output_tokens); output_tokens = Some(usage.output_tokens);
#[cfg(not(target_family = "wasm"))]
{
response_usage = Some(usage);
}
} }
AgentEvent::TurnStarted { .. } AgentEvent::TurnStarted { .. }
| AgentEvent::KeepAlive | AgentEvent::KeepAlive
@@ -1066,6 +1117,18 @@ async fn assess(
let timeout = FutureExt::fuse(Timer::after(REVIEW_TIMEOUT)); let timeout = FutureExt::fuse(Timer::after(REVIEW_TIMEOUT));
futures::pin_mut!(review, timeout); futures::pin_mut!(review, timeout);
let result = futures::select_biased! { result = review => result, _ = timeout => Err(anyhow::anyhow!("assessment timed out")) }; let result = futures::select_biased! { result = review => result, _ = timeout => Err(anyhow::anyhow!("assessment timed out")) };
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
if let Err(error) = &result {
let mut context = activity_context();
context["error"] = serde_json::json!(crate::ai::remote_logging::sanitize_error(error));
crate::ai::activity::record(
"risk_review_error",
"Permission assessment failed",
context,
);
}
}
let _ = sender.try_send(TurnCommand::Cancel); let _ = sender.try_send(TurnCommand::Cancel);
result result
} }
+187 -2
View File
@@ -37,6 +37,7 @@ use persistence::model::ProviderConversationContext;
use session_sharing_protocol::common::ParticipantId; use session_sharing_protocol::common::ParticipantId;
use settings::Setting; use settings::Setting;
pub use slash_command::*; pub use slash_command::*;
use uuid::Uuid;
use warp_multi_agent_api::{message, Task, ToolType}; use warp_multi_agent_api::{message, Task, ToolType};
use warpui::r#async::{SpawnedFutureHandle, Timer}; use warpui::r#async::{SpawnedFutureHandle, Timer};
use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};
@@ -53,6 +54,8 @@ use super::orchestration_events::{OrchestrationEventService, OrchestrationEventS
use super::orchestration_topology::descendant_conversation_ids_in_spawn_order; use super::orchestration_topology::descendant_conversation_ids_in_spawn_order;
use super::queued_query::{QueuedQueryId, QueuedQueryModel}; use super::queued_query::{QueuedQueryId, QueuedQueryModel};
use super::{BlocklistAIInputModel, ResponseStreamId}; use super::{BlocklistAIInputModel, ResponseStreamId};
#[cfg(not(target_family = "wasm"))]
use crate::ai::activity;
use crate::ai::agent::api::{self, ServerConversationToken}; use crate::ai::agent::api::{self, ServerConversationToken};
use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus}; use crate::ai::agent::conversation::{AIConversation, AIConversationId, ConversationStatus};
use crate::ai::agent::task::TaskId; use crate::ai::agent::task::TaskId;
@@ -416,6 +419,49 @@ fn progressive_summary_messages(summary: String) -> Vec<ConversationMessage> {
async fn collect_progressive_summary( async fn collect_progressive_summary(
provider_config: ProviderConfig, provider_config: ProviderConfig,
request: TurnRequest, request: TurnRequest,
mut activity_context: serde_json::Value,
) -> anyhow::Result<(String, Usage)> {
let started_at = Instant::now();
activity_context["model_id"] = serde_json::json!(request.model.as_str());
activity_context["request_id"] = serde_json::json!(Uuid::new_v4().to_string());
activity_context["snapshot_kind"] = serde_json::json!("provider_neutral");
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record_serializable(
"compaction_request",
"Compaction model request",
activity_context.clone(),
&request,
);
}
let result = collect_progressive_summary_inner(provider_config, request).await;
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity_context["elapsed_ms"] = serde_json::json!(started_at.elapsed().as_millis() as u64);
match &result {
Ok((summary, usage)) => activity::record_serializable(
"compaction_response",
"Compaction model response",
activity_context,
&(summary, usage),
),
Err(error) => {
activity_context["error"] =
serde_json::json!(remote_logging::sanitize_error(error));
activity::record(
"compaction_error",
"Compaction request failed",
activity_context,
);
}
}
}
result
}
async fn collect_progressive_summary_inner(
provider_config: ProviderConfig,
request: TurnRequest,
) -> anyhow::Result<(String, Usage)> { ) -> anyhow::Result<(String, Usage)> {
let (command_sender, control) = turn_control(); let (command_sender, control) = turn_control();
let startup = async move { let startup = async move {
@@ -5482,6 +5528,17 @@ impl BlocklistAIController {
)); ));
}; };
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
let title = conversation
.title()
.unwrap_or_else(|| "Untitled session".to_string());
let label = conversation
.agent_name()
.map_or_else(|| title.clone(), |name| format!("{name}: {title}"));
activity::register_session(conversation.id().to_string(), label);
}
let active_tasks = conversation.compute_active_tasks(); let active_tasks = conversation.compute_active_tasks();
( (
@@ -7483,6 +7540,7 @@ impl BlocklistAIController {
}), }),
}, },
); );
let summary_activity_identity = identity.clone();
ctx.spawn( ctx.spawn(
async move { async move {
let mut errors = Vec::new(); let mut errors = Vec::new();
@@ -7493,7 +7551,17 @@ impl BlocklistAIController {
); );
request.system_prompt = Some(PROGRESSIVE_SUMMARY_PROMPT.to_string()); request.system_prompt = Some(PROGRESSIVE_SUMMARY_PROMPT.to_string());
request.max_output_tokens = Some(u64::from(PROGRESSIVE_SUMMARY_OUTPUT_TOKENS)); request.max_output_tokens = Some(u64::from(PROGRESSIVE_SUMMARY_OUTPUT_TOKENS));
match collect_progressive_summary(candidate.provider_config, request).await { match collect_progressive_summary(
candidate.provider_config,
request,
serde_json::json!({
"conversation_id": conversation_id,
"provider_run_id": summary_activity_identity.run_id.as_str(),
"stream_id": summary_activity_identity.stream_id.as_str(),
}),
)
.await
{
Ok((summary, usage)) => { Ok((summary, usage)) => {
return (plan, Ok((summary, usage, candidate.request_model_id))); return (plan, Ok((summary, usage, candidate.request_model_id)));
} }
@@ -7764,6 +7832,31 @@ impl BlocklistAIController {
if let Some((usage, summarizer_model_id)) = recorded_usage { if let Some((usage, summarizer_model_id)) = recorded_usage {
self.record_progressive_summary_usage(conversation_id, usage, summarizer_model_id, ctx); self.record_progressive_summary_usage(conversation_id, usage, summarizer_model_id, ctx);
} }
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record(
if summary_applied {
"compaction_applied"
} else {
"compaction_discarded"
},
if summary_applied {
"Compaction applied at model boundary"
} else {
"Compaction was not applied"
},
serde_json::json!({
"conversation_id": conversation_id,
"applied": summary_applied,
"error": failure.as_deref().map(remote_logging::sanitize_error),
"summarized_message_count": plan.split_point,
"trigger_context_tokens": plan.trigger_context_tokens,
"context_tokens_after": failure_context_tokens,
"context_usage_before": plan.context_usage,
"waiting_for_context": waiting_for_context,
}),
);
}
if waiting_for_context && !summary_applied { if waiting_for_context && !summary_applied {
self.fail_active_provider_run( self.fail_active_provider_run(
conversation_id, conversation_id,
@@ -7931,6 +8024,20 @@ impl BlocklistAIController {
conversation_id: AIConversationId, conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
if let Some(conversation) =
BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id)
{
let title = conversation
.title()
.unwrap_or_else(|| "Untitled session".to_string());
let label = conversation
.agent_name()
.map_or_else(|| title.clone(), |name| format!("{name}: {title}"));
activity::register_session(conversation_id.to_string(), label);
}
}
// A follow-up owns a new generation. Its history must never be blocked // A follow-up owns a new generation. Its history must never be blocked
// by, or replaced with, a detached summary from the previous generation. // by, or replaced with, a detached summary from the previous generation.
// Cancellation also discards summaries before they can fail recovery. // Cancellation also discards summaries before they can fail recovery.
@@ -7952,6 +8059,18 @@ impl BlocklistAIController {
.remove(&conversation_id) .remove(&conversation_id)
.is_some() .is_some()
{ {
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record(
"compaction_discarded",
"Compaction generation ended",
serde_json::json!({
"conversation_id": conversation_id,
"stale_generation": stale_summary,
"cancelled": cancelling,
}),
);
}
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| { BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| {
if let Some(conversation) = history.conversation_mut(&conversation_id) { if let Some(conversation) = history.conversation_mut(&conversation_id) {
conversation.set_has_pending_progressive_summary(false); conversation.set_has_pending_progressive_summary(false);
@@ -7987,6 +8106,8 @@ impl BlocklistAIController {
let Some(mut run) = slot.run.take() else { let Some(mut run) = slot.run.take() else {
return; return;
}; };
#[cfg(not(target_family = "wasm"))]
run.coordinator.set_activity_session_id(conversation_id);
let checkpoint_template = match ActiveProviderRunCheckpoint::from_active_run(&run) { let checkpoint_template = match ActiveProviderRunCheckpoint::from_active_run(&run) {
Ok(checkpoint) => checkpoint, Ok(checkpoint) => checkpoint,
Err(error) => { Err(error) => {
@@ -9529,6 +9650,17 @@ impl BlocklistAIController {
if BlocklistAIHistoryModel::as_ref(ctx).conversation_status(&conversation_id) if BlocklistAIHistoryModel::as_ref(ctx).conversation_status(&conversation_id)
!= Some(&ConversationStatus::Success) != Some(&ConversationStatus::Success)
{ {
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record(
"compaction_discarded",
"Compaction discarded for unfinished session",
serde_json::json!({
"conversation_id": conversation_id,
"reason": "conversation did not complete successfully",
}),
);
}
return; return;
} }
@@ -9554,6 +9686,13 @@ impl BlocklistAIController {
"[progressive-summary] Discarding stale idle summary for conversation {:?}; the snapshotted prefix no longer matches", "[progressive-summary] Discarding stale idle summary for conversation {:?}; the snapshotted prefix no longer matches",
conversation_id, conversation_id,
); );
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record("compaction_discarded", "Compaction prefix no longer matches", serde_json::json!({
"conversation_id": conversation_id,
"reason": "snapshotted prefix no longer matches conversation history",
}));
}
return; return;
} }
if !summary_reduces_context { if !summary_reduces_context {
@@ -9561,6 +9700,13 @@ impl BlocklistAIController {
"[progressive-summary] Idle summary did not reduce context for conversation {:?}", "[progressive-summary] Idle summary did not reduce context for conversation {:?}",
conversation_id, conversation_id,
); );
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record("compaction_discarded", "Compaction did not reduce context", serde_json::json!({
"conversation_id": conversation_id,
"reason": "summary did not reduce context",
}));
}
return; return;
} }
@@ -9587,6 +9733,16 @@ impl BlocklistAIController {
conversation_id, conversation_id,
}); });
conversation.write_updated_conversation_state(ctx); conversation.write_updated_conversation_state(ctx);
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record("compaction_applied", "Compaction applied after session completed", serde_json::json!({
"conversation_id": conversation_id,
"context_tokens_after": new_context_tokens,
"context_limit": plan.active_context_limit,
"retained_message_count": retained_count,
"summarized_message_count": plan.split_point,
}));
}
log::info!( log::info!(
"[progressive-summary] Applied background summary after active run completion {:?}: ~{} tokens ({:.1}% of {} context), {} tail messages preserved", "[progressive-summary] Applied background summary after active run completion {:?}: ~{} tokens ({:.1}% of {} context), {} tail messages preserved",
conversation_id, conversation_id,
@@ -11361,6 +11517,21 @@ impl BlocklistAIController {
candidate_names, candidate_names,
); );
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record(
"compaction_started",
"Background compaction started",
serde_json::json!({
"conversation_id": conversation_id,
"summarized_message_count": messages_count,
"retained_message_count": retained_messages_count,
"context_limit": active_context_limit,
"persistent_context_tokens": persistent_context_tokens,
"trigger_usage": PROGRESSIVE_SUMMARY_TRIGGER_USAGE,
}),
);
}
let summarize_messages = vec![ConversationMessage { let summarize_messages = vec![ConversationMessage {
role: MessageRole::User, role: MessageRole::User,
content: MessageContent::Text(summarize_content), content: MessageContent::Text(summarize_content),
@@ -11376,7 +11547,11 @@ impl BlocklistAIController {
); );
request.system_prompt = Some(summarize_prompt.to_string()); request.system_prompt = Some(summarize_prompt.to_string());
request.max_output_tokens = Some(u64::from(PROGRESSIVE_SUMMARY_OUTPUT_TOKENS)); request.max_output_tokens = Some(u64::from(PROGRESSIVE_SUMMARY_OUTPUT_TOKENS));
match collect_progressive_summary(candidate.provider_config, request).await { match collect_progressive_summary(
candidate.provider_config,
request,
serde_json::json!({ "conversation_id": conversation_id }),
).await {
Ok((summary, usage)) => { Ok((summary, usage)) => {
return Ok((summary, usage, candidate.request_model_id)); return Ok((summary, usage, candidate.request_model_id));
} }
@@ -11449,6 +11624,16 @@ impl BlocklistAIController {
conversation.set_context_window_usage(new_usage.clamp(0.0, 1.0)); conversation.set_context_window_usage(new_usage.clamp(0.0, 1.0));
conversation.set_current_context_tokens(new_context_tokens); conversation.set_current_context_tokens(new_context_tokens);
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record("compaction_applied", "Compaction applied to session history", serde_json::json!({
"conversation_id": conversation_id,
"context_tokens_after": new_context_tokens,
"context_limit": active_context_limit,
"summarized_message_count": drain_count,
"retained_message_count": conversation.bedrock_message_history().len(),
}));
}
log::info!( log::info!(
"[progressive-summary] Post-summary: ~{} tokens ({:.1}% of {} context), {} messages retained", "[progressive-summary] Post-summary: ~{} tokens ({:.1}% of {} context), {} messages retained",
new_context_tokens, new_context_tokens,
@@ -430,11 +430,19 @@ impl ResponseStream {
&self, &self,
request_id: Uuid, request_id: Uuid,
payload_kind: &str, payload_kind: &str,
raw_payload: impl AsRef<str>, raw_payload: &impl std::fmt::Debug,
ctx: &mut ModelContext<Self>, ctx: &mut ModelContext<Self>,
) { ) {
let Some(raw_payload) = remote_logging::raw_model_payload_context(ctx, |max_chars| { let Some(raw_payload) = remote_logging::raw_model_payload_context(ctx, |max_chars| {
remote_logging::tail_limited_payload_context(raw_payload.as_ref(), max_chars) struct DebugPayload<'a, T>(&'a T);
impl<T: std::fmt::Debug> serde::Serialize for DebugPayload<'_, T> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(&format_args!("{:#?}", self.0))
}
}
remote_logging::prefix_bounded_payload_context(max_chars, |writer| {
serde_json::to_writer_pretty(writer, &DebugPayload(raw_payload))
})
}) else { }) else {
return; return;
}; };
@@ -956,7 +964,7 @@ impl ResponseStream {
self.log_raw_model_response( self.log_raw_model_response(
request_id, request_id,
event_type_name, event_type_name,
format!("{response_event:#?}"), response_event,
ctx, ctx,
); );
if let Some(event_type) = &response_event.r#type { if let Some(event_type) = &response_event.r#type {
+77 -2
View File
@@ -118,7 +118,17 @@ impl CrosscheckReviewer {
// Spawn the reviewer request asynchronously // Spawn the reviewer request asynchronously
let conversation_id_for_task = conversation_id; let conversation_id_for_task = conversation_id;
ctx.spawn( ctx.spawn(
Self::invoke_reviewer(agent_output, model_id, provider_config), Self::invoke_reviewer(
agent_output,
model_id,
provider_config,
#[cfg(not(target_family = "wasm"))]
serde_json::json!({
"conversation_id": conversation_id,
"iteration": current_iteration,
"max_iterations": max,
}),
),
move |me, reviewer_response, ctx| { move |me, reviewer_response, ctx| {
me.handle_reviewer_response( me.handle_reviewer_response(
conversation_id_for_task, conversation_id_for_task,
@@ -206,6 +216,70 @@ impl CrosscheckReviewer {
agent_output: String, agent_output: String,
model_id: String, model_id: String,
provider_config: ProviderConfig, provider_config: ProviderConfig,
#[cfg(not(target_family = "wasm"))] mut activity_context: serde_json::Value,
) -> Result<String, String> {
#[cfg(not(target_family = "wasm"))]
let started_at = instant::Instant::now();
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
let effective_model = match &provider_config {
ProviderConfig::OpenAI(config) => config.model.as_deref().unwrap_or(&model_id),
ProviderConfig::Bedrock(_) | ProviderConfig::None => &model_id,
};
activity_context["model_id"] = serde_json::json!(effective_model);
activity_context["request_id"] = serde_json::json!(uuid::Uuid::new_v4().to_string());
activity_context["snapshot_kind"] = serde_json::json!("reviewer_input");
#[derive(serde::Serialize)]
struct ReviewerInput<'a> {
system_prompt: &'a str,
user_prompt_prefix: &'a str,
agent_output: &'a str,
max_output_tokens: u32,
}
crate::ai::activity::record_serializable(
"crosscheck_model_request",
"Crosscheck reviewer request",
activity_context.clone(),
&ReviewerInput {
system_prompt: prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT,
user_prompt_prefix: "Please review the following agent output:\n\n---\n\n",
agent_output: &agent_output,
max_output_tokens: REVIEWER_MAX_OUTPUT_TOKENS,
},
);
}
let response = Self::invoke_reviewer_inner(agent_output, model_id, provider_config).await;
#[cfg(not(target_family = "wasm"))]
if crate::ai::activity::is_recording() {
activity_context["elapsed_ms"] =
serde_json::json!(started_at.elapsed().as_millis() as u64);
// These compatibility helpers return text only, with no usage report.
activity_context["usage_reported"] = serde_json::json!(false);
match &response {
Ok(text) => crate::ai::activity::record_serializable(
"crosscheck_model_response",
"Crosscheck reviewer response",
activity_context,
text,
),
Err(error) => {
activity_context["error"] =
serde_json::json!(crate::ai::remote_logging::sanitize_error(error));
crate::ai::activity::record(
"crosscheck_model_error",
"Crosscheck reviewer failed",
activity_context,
);
}
}
}
response
}
async fn invoke_reviewer_inner(
agent_output: String,
model_id: String,
provider_config: ProviderConfig,
) -> Result<String, String> { ) -> Result<String, String> {
match provider_config { match provider_config {
ProviderConfig::OpenAI(config) ProviderConfig::OpenAI(config)
@@ -217,7 +291,8 @@ impl CrosscheckReviewer {
reasoning_effort: config.reasoning_effort, reasoning_effort: config.reasoning_effort,
max_output_tokens: Some(u64::from(REVIEWER_MAX_OUTPUT_TOKENS)), max_output_tokens: Some(u64::from(REVIEWER_MAX_OUTPUT_TOKENS)),
}, },
).with_credential_resolver(crate::ai::chatgpt_auth::credential_resolver()); )
.with_credential_resolver(crate::ai::chatgpt_auth::credential_resolver());
runtime runtime
.complete_text( .complete_text(
prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT.to_string(), prompt::CROSSCHECK_REVIEWER_SYSTEM_PROMPT.to_string(),
+2
View File
@@ -4,6 +4,8 @@
//! The side panel Warp AI implementation lives in `super::ai_assistant`. //! The side panel Warp AI implementation lives in `super::ai_assistant`.
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
pub(crate) mod acp; pub(crate) mod acp;
#[cfg(not(target_family = "wasm"))]
pub(crate) mod activity;
pub(crate) mod active_agent_views_model; pub(crate) mod active_agent_views_model;
pub(crate) mod agent; pub(crate) mod agent;
pub(crate) mod agent_conversations_model; pub(crate) mod agent_conversations_model;
+31 -6
View File
@@ -43,7 +43,7 @@ pub(crate) enum RemoteLogLevel {
} }
impl RemoteLogLevel { impl RemoteLogLevel {
fn as_str(self) -> &'static str { pub(crate) fn as_str(self) -> &'static str {
match self { match self {
Self::Info => "info", Self::Info => "info",
Self::Warn => "warn", Self::Warn => "warn",
@@ -99,6 +99,10 @@ pub(crate) fn log_model_event<M>(ctx: &mut ModelContext<M>, record: RemoteLogRec
where where
M: Entity, M: Entity,
{ {
super::activity::record_log(&record);
if is_raw_payload(&record) && !remote_payloads_enabled(AISettings::as_ref(ctx)) {
return;
}
let Some(config) = RemoteLogConfig::from_settings(AISettings::as_ref(ctx)) else { let Some(config) = RemoteLogConfig::from_settings(AISettings::as_ref(ctx)) else {
return; return;
}; };
@@ -126,6 +130,10 @@ where
/// Views must log without updating AISettings: its singleton cannot be read /// Views must log without updating AISettings: its singleton cannot be read
/// while it is temporarily borrowed by a model update. /// while it is temporarily borrowed by a model update.
pub(crate) fn log_view_event<V: View>(ctx: &mut ViewContext<V>, record: RemoteLogRecord) { pub(crate) fn log_view_event<V: View>(ctx: &mut ViewContext<V>, record: RemoteLogRecord) {
super::activity::record_log(&record);
if is_raw_payload(&record) && !remote_payloads_enabled(AISettings::as_ref(ctx)) {
return;
}
let Some(config) = RemoteLogConfig::from_settings(AISettings::as_ref(ctx)) else { let Some(config) = RemoteLogConfig::from_settings(AISettings::as_ref(ctx)) else {
return; return;
}; };
@@ -219,17 +227,34 @@ where
M: Entity, M: Entity,
{ {
let settings = AISettings::as_ref(ctx); let settings = AISettings::as_ref(ctx);
if !*settings.remote_logging_enabled.value() let remote_enabled = remote_payloads_enabled(settings);
|| !*settings.remote_logging_log_model_payloads.value() let local_enabled = super::activity::is_recording();
|| settings.remote_logging_api_key.value().trim().is_empty() if !remote_enabled && !local_enabled {
{
return None; return None;
} }
let max_chars = (*settings.remote_logging_model_payload_max_chars.value()).max(1); let remote_max = (*settings.remote_logging_model_payload_max_chars.value()).max(1);
let max_chars = if remote_enabled {
remote_max
} else {
super::activity::MAX_PAYLOAD_CHARS
};
Some(build_payload(max_chars)) Some(build_payload(max_chars))
} }
fn remote_payloads_enabled(settings: &AISettings) -> bool {
*settings.remote_logging_enabled.value()
&& *settings.remote_logging_log_model_payloads.value()
&& !settings.remote_logging_api_key.value().trim().is_empty()
}
fn is_raw_payload(record: &RemoteLogRecord) -> bool {
matches!(
record.context.get("event").and_then(Value::as_str),
Some("raw_model_request" | "raw_model_response")
)
}
fn enrich_context(mut context: Value) -> Value { fn enrich_context(mut context: Value) -> Value {
let Value::Object(ref mut map) = context else { let Value::Object(ref mut map) = context else {
return json!({ return json!({
+70 -2
View File
@@ -12,12 +12,15 @@ use galaxy_agent_core::{
ModelFailureDisposition, PendingToolBatch, ProviderModelCall, ProviderRequestProfile, ModelFailureDisposition, PendingToolBatch, ProviderModelCall, ProviderRequestProfile,
ProviderRun, ProviderRunFailureKind, ProviderRunId, ProviderRunLimits, ProviderRunOutcome, ProviderRun, ProviderRunFailureKind, ProviderRunId, ProviderRunLimits, ProviderRunOutcome,
ProviderRunPhase, ProviderRunProtocolError, ProviderRunState, ProviderRunStep, RunEpoch, ProviderRunPhase, ProviderRunProtocolError, ProviderRunState, ProviderRunStep, RunEpoch,
RuntimeKind, StopReason, TaskListSnapshot, ToolEvent, TurnCommand, TurnControl, TurnRequest, Usage, RuntimeKind, StopReason, TaskListSnapshot, ToolEvent, TurnCommand, TurnControl, TurnRequest,
Usage,
}; };
use instant::Instant; use instant::Instant;
use warpui::r#async::Timer; use warpui::r#async::Timer;
use super::task_progress::{add_task_list_context, initial_task_list, task_list_from_plan_batch}; use super::task_progress::{add_task_list_context, initial_task_list, task_list_from_plan_batch};
#[cfg(not(target_family = "wasm"))]
use crate::ai::activity;
use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent::conversation::AIConversationId;
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
use crate::ai::model_output_logging; use crate::ai::model_output_logging;
@@ -285,6 +288,8 @@ pub(crate) struct ProviderRunCoordinator {
model_event_idle_timeout: Duration, model_event_idle_timeout: Duration,
context_compaction_requested: bool, context_compaction_requested: bool,
max_context_tokens: Option<u32>, max_context_tokens: Option<u32>,
#[cfg(not(target_family = "wasm"))]
activity_session_id: Option<String>,
} }
impl ProviderRunCoordinator { impl ProviderRunCoordinator {
@@ -327,6 +332,8 @@ impl ProviderRunCoordinator {
model_event_idle_timeout: PROVIDER_MODEL_EVENT_IDLE_TIMEOUT, model_event_idle_timeout: PROVIDER_MODEL_EVENT_IDLE_TIMEOUT,
context_compaction_requested: false, context_compaction_requested: false,
max_context_tokens: None, max_context_tokens: None,
#[cfg(not(target_family = "wasm"))]
activity_session_id: None,
}) })
} }
@@ -336,6 +343,30 @@ impl ProviderRunCoordinator {
self.model_event_idle_timeout = event_idle; self.model_event_idle_timeout = event_idle;
} }
/// Local inspection identity only; never changes provider requests or checkpoints.
#[cfg(not(target_family = "wasm"))]
pub(crate) fn set_activity_session_id(&mut self, conversation_id: AIConversationId) {
self.activity_session_id = Some(conversation_id.to_string());
}
#[cfg(not(target_family = "wasm"))]
fn activity_context(
&self,
call: &ProviderModelCall,
profile: &ProviderRunProfile,
) -> serde_json::Value {
serde_json::json!({
"conversation_id": self.activity_session_id,
"provider_run_id": call.work_id.run_id.as_str(),
"provider_epoch": call.work_id.epoch.get(),
"profile": call.profile.as_str(),
"runtime_id": profile.runtime.descriptor().id,
"model_id": profile.request.model.as_str(),
"retry_attempt": call.retry_attempt,
"snapshot_kind": "provider_neutral",
})
}
pub(crate) fn run(&self) -> &ProviderRun { pub(crate) fn run(&self) -> &ProviderRun {
&self.run &self.run
} }
@@ -730,6 +761,15 @@ impl ProviderRunCoordinator {
profile.max_context_tokens.or(self.max_context_tokens), profile.max_context_tokens.or(self.max_context_tokens),
self.run.task_list(), self.run.task_list(),
); );
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
activity::record_serializable(
"model_request",
"Model request",
self.activity_context(&call, &profile),
&request,
);
}
let started_at = Instant::now(); let started_at = Instant::now();
let (attempt_sender, attempt_control) = turn_control(); let (attempt_sender, attempt_control) = turn_control();
let start_future = profile.runtime.start_turn(request, attempt_control).fuse(); let start_future = profile.runtime.start_turn(request, attempt_control).fuse();
@@ -779,6 +819,8 @@ impl ProviderRunCoordinator {
}; };
futures::pin_mut!(stream); futures::pin_mut!(stream);
let mut buffer = ModelTurnBuffer::default(); let mut buffer = ModelTurnBuffer::default();
#[cfg(not(target_family = "wasm"))]
let mut usage_reported = false;
loop { loop {
let next_event = stream.next().fuse(); let next_event = stream.next().fuse();
@@ -1014,6 +1056,10 @@ impl ProviderRunCoordinator {
return Ok(()); return Ok(());
} }
buffer.usage.clone_from(&usage); buffer.usage.clone_from(&usage);
#[cfg(not(target_family = "wasm"))]
{
usage_reported = true;
}
if !self if !self
.project_or_fail_acknowledged( .project_or_fail_acknowledged(
ProviderRunProjection::ModelEvent { ProviderRunProjection::ModelEvent {
@@ -1056,6 +1102,19 @@ impl ProviderRunCoordinator {
{ {
return Ok(()); return Ok(());
} }
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
let mut context = self.activity_context(&call, &profile);
context["elapsed_ms"] = serde_json::json!(elapsed_millis(started_at));
context["stop_reason"] = serde_json::json!(&reason);
context["usage_reported"] = serde_json::json!(usage_reported);
activity::record_serializable(
"model_response",
"Model response",
context,
&buffer,
);
}
if reason == StopReason::Cancelled { if reason == StopReason::Cancelled {
self.run.cancel("provider model call was cancelled")?; self.run.cancel("provider model call was cancelled")?;
return Ok(()); return Ok(());
@@ -1159,6 +1218,15 @@ impl ProviderRunCoordinator {
"retry_attempt": call.retry_attempt, "retry_attempt": call.retry_attempt,
"payload": &error, "payload": &error,
})); }));
#[cfg(not(target_family = "wasm"))]
if activity::is_recording() {
let mut context = self.activity_context(call, profile);
context["elapsed_ms"] = serde_json::json!(elapsed_millis(started_at));
context["error_kind"] = serde_json::json!(&error.kind);
context["error"] =
serde_json::json!(crate::ai::remote_logging::sanitize_error(&error.message));
activity::record("model_error", "Model request failed", context);
}
let error_message = error.message.clone(); let error_message = error.message.clone();
if error.kind == AgentErrorKind::ContextWindowExceeded { if error.kind == AgentErrorKind::ContextWindowExceeded {
self.run.prepare_context_compaction(&call.work_id)?; self.run.prepare_context_compaction(&call.work_id)?;
@@ -1243,7 +1311,7 @@ impl ProviderRunCoordinator {
} }
} }
#[derive(Default)] #[derive(Default, serde::Serialize)]
struct ModelTurnBuffer { struct ModelTurnBuffer {
started: bool, started: bool,
text: String, text: String,
@@ -104,6 +104,15 @@ pub const BACKGROUND_JOBS: StaticCommand = StaticCommand {
argument: None, argument: None,
}; };
pub const AI_ACTIVITY: StaticCommand = StaticCommand {
name: "/ai-activity",
description: "Inspect model requests, responses, compaction, and tools across sessions",
icon_path: "bundled/svg/dataflow.svg",
availability: Availability::empty(),
auto_enter_ai_mode: false,
argument: None,
};
pub static ADD_PROMPT: LazyLock<StaticCommand> = LazyLock::new(|| StaticCommand { pub static ADD_PROMPT: LazyLock<StaticCommand> = LazyLock::new(|| StaticCommand {
name: "/add-prompt", name: "/add-prompt",
description: "Add new Agent prompt", description: "Add new Agent prompt",
@@ -713,6 +722,7 @@ fn all_commands() -> Vec<StaticCommand> {
if !cfg!(target_family = "wasm") { if !cfg!(target_family = "wasm") {
commands.extend([EDIT.clone(), EXPORT_TO_FILE.clone()]); commands.extend([EDIT.clone(), EXPORT_TO_FILE.clone()]);
commands.push(AI_ACTIVITY);
} }
if cfg!(all(feature = "local_fs", not(target_family = "wasm"))) { if cfg!(all(feature = "local_fs", not(target_family = "wasm"))) {
commands.push(BACKGROUND_JOBS); commands.push(BACKGROUND_JOBS);
+7
View File
@@ -1861,6 +1861,13 @@ pub fn init(app: &mut AppContext) {
) )
.with_enabled(|| ContextFlag::NetworkLogConsole.is_enabled())]); .with_enabled(|| ContextFlag::NetworkLogConsole.is_enabled())]);
#[cfg(not(target_family = "wasm"))]
app.register_editable_bindings([EditableBinding::new(
"workspace:open_ai_activity",
"Show AI Activity",
WorkspaceAction::OpenAIActivity,
)]);
app.register_editable_bindings([EditableBinding::new( app.register_editable_bindings([EditableBinding::new(
"input:clear_screen", "input:clear_screen",
"Clear screen", "Clear screen",
@@ -209,7 +209,8 @@ impl SlashCommandDataSource {
/// Slash commands that are available in CLI agent rich input mode. /// Slash commands that are available in CLI agent rich input mode.
/// Add command names here to make them accessible when composing prompts /// Add command names here to make them accessible when composing prompts
/// for a running CLI agent (Claude Code, Codex, etc.). /// for a running CLI agent (Claude Code, Codex, etc.).
const CLI_AGENT_INPUT_ALLOWED_COMMANDS: &[&str] = &["/prompts", "/skills", "/background-jobs"]; const CLI_AGENT_INPUT_ALLOWED_COMMANDS: &[&str] =
&["/prompts", "/skills", "/background-jobs", "/ai-activity"];
fn is_cloud_mode(&self, ctx: &AppContext) -> bool { fn is_cloud_mode(&self, ctx: &AppContext) -> bool {
self.is_cloud_mode_v2 self.is_cloud_mode_v2
@@ -782,6 +782,9 @@ impl Input {
_background_jobs if command.name == commands::BACKGROUND_JOBS.name => { _background_jobs if command.name == commands::BACKGROUND_JOBS.name => {
ctx.dispatch_typed_action(&TerminalAction::OpenBackgroundJobs { job_id: None }); ctx.dispatch_typed_action(&TerminalAction::OpenBackgroundJobs { job_id: None });
} }
_ai_activity if command.name == commands::AI_ACTIVITY.name => {
ctx.dispatch_typed_action(&WorkspaceAction::OpenAIActivity);
}
_ if command.name == commands::HOST.name => { _ if command.name == commands::HOST.name => {
if !self.is_cloud_mode_input_v2_composing(ctx) { if !self.is_cloud_mode_input_v2_composing(ctx) {
return false; return false;
+4 -1
View File
@@ -875,6 +875,8 @@ pub enum WorkspaceAction {
/// Opens (or focuses) the in-app network log pane as a right-split of the /// Opens (or focuses) the in-app network log pane as a right-split of the
/// active pane group. Gated on `ContextFlag::NetworkLogConsole`. /// active pane group. Gated on `ContextFlag::NetworkLogConsole`.
OpenNetworkLogPane, OpenNetworkLogPane,
/// Opens the app-wide inspector for model, compaction, and tool activity.
OpenAIActivity,
} }
impl From<&WorkspaceAction> for LoginGatedFeature { impl From<&WorkspaceAction> for LoginGatedFeature {
@@ -1195,7 +1197,8 @@ impl WorkspaceAction {
| ShowHandoffEnvironmentCreationModal | ShowHandoffEnvironmentCreationModal
| ShowCloudModeV2EnvironmentCreationModal | ShowCloudModeV2EnvironmentCreationModal
| OpenCreateAuthSecretModal { .. } | OpenCreateAuthSecretModal { .. }
| OpenNetworkLogPane => false, | OpenNetworkLogPane
| OpenAIActivity => false,
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
ShowHoaOnboardingFlow => false, ShowHoaOnboardingFlow => false,
#[cfg(target_family = "wasm")] #[cfg(target_family = "wasm")]
+59
View File
@@ -155,6 +155,8 @@ use super::util::{
}; };
use super::{util, ActiveSession, TabBarDropTargetData, TabBarLocation, WorkspaceRegistry}; use super::{util, ActiveSession, TabBarDropTargetData, TabBarLocation, WorkspaceRegistry};
use crate::ai::active_agent_views_model::ActiveAgentViewsModel; use crate::ai::active_agent_views_model::ActiveAgentViewsModel;
#[cfg(not(target_family = "wasm"))]
use crate::ai::activity::view::AIActivityView;
use crate::ai::agent::api::ServerConversationToken; use crate::ai::agent::api::ServerConversationToken;
#[cfg(not(target_family = "wasm"))] #[cfg(not(target_family = "wasm"))]
use crate::ai::agent::conversation::AIAgentHarness; use crate::ai::agent::conversation::AIAgentHarness;
@@ -1060,6 +1062,8 @@ pub struct Workspace {
new_worktree_modal: ModalViewState<Modal<NewWorktreeModal>>, new_worktree_modal: ModalViewState<Modal<NewWorktreeModal>>,
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))] #[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
background_jobs_modal: ModalViewState<Modal<BackgroundJobsView>>, background_jobs_modal: ModalViewState<Modal<BackgroundJobsView>>,
#[cfg(not(target_family = "wasm"))]
ai_activity_modal: ModalViewState<Modal<AIActivityView>>,
close_session_confirmation_dialog: ViewHandle<CloseSessionConfirmationDialog>, close_session_confirmation_dialog: ViewHandle<CloseSessionConfirmationDialog>,
rewind_confirmation_dialog: ViewHandle<RewindConfirmationDialog>, rewind_confirmation_dialog: ViewHandle<RewindConfirmationDialog>,
delete_conversation_confirmation_dialog: ViewHandle<DeleteConversationConfirmationDialog>, delete_conversation_confirmation_dialog: ViewHandle<DeleteConversationConfirmationDialog>,
@@ -2181,6 +2185,43 @@ impl Workspace {
ModalViewState::new(modal) ModalViewState::new(modal)
} }
#[cfg(not(target_family = "wasm"))]
fn build_ai_activity_modal(
ctx: &mut ViewContext<Self>,
) -> ModalViewState<Modal<AIActivityView>> {
let body = ctx.add_typed_action_view(AIActivityView::new);
let modal = ctx.add_typed_action_view(|ctx| {
Modal::new(Some("AI Activity".to_owned()), body, ctx)
.with_modal_style(UiComponentStyles {
width: Some(1120.),
height: Some(760.),
..Default::default()
})
.with_header_style(UiComponentStyles {
height: Some(58.),
..Default::default()
})
.with_body_style(UiComponentStyles {
padding: Some(Coords::uniform(12.)),
height: Some(680.),
..Default::default()
})
.with_max_height_percentage(0.9)
.with_dismiss_on_click()
});
ctx.subscribe_to_view(&modal, |me, _, event, ctx| {
if matches!(event, ModalEvent::Close) {
me.ai_activity_modal.close();
me.ai_activity_modal.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |view, ctx| view.on_close(ctx));
});
me.focus_active_tab(ctx);
ctx.notify();
}
});
ModalViewState::new(modal)
}
fn build_remove_tab_config_confirmation_dialog( fn build_remove_tab_config_confirmation_dialog(
ctx: &mut ViewContext<Self>, ctx: &mut ViewContext<Self>,
) -> ViewHandle<RemoveTabConfigConfirmationDialog> { ) -> ViewHandle<RemoveTabConfigConfirmationDialog> {
@@ -3026,6 +3067,8 @@ impl Workspace {
let new_worktree_modal = Self::build_new_worktree_modal(ctx); let new_worktree_modal = Self::build_new_worktree_modal(ctx);
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))] #[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
let background_jobs_modal = Self::build_background_jobs_modal(ctx); let background_jobs_modal = Self::build_background_jobs_modal(ctx);
#[cfg(not(target_family = "wasm"))]
let ai_activity_modal = Self::build_ai_activity_modal(ctx);
let session_config_modal = Self::build_session_config_modal(ctx); let session_config_modal = Self::build_session_config_modal(ctx);
@@ -3427,6 +3470,8 @@ impl Workspace {
new_worktree_modal, new_worktree_modal,
#[cfg(all(feature = "local_fs", not(target_family = "wasm")))] #[cfg(all(feature = "local_fs", not(target_family = "wasm")))]
background_jobs_modal, background_jobs_modal,
#[cfg(not(target_family = "wasm"))]
ai_activity_modal,
close_session_confirmation_dialog, close_session_confirmation_dialog,
rewind_confirmation_dialog, rewind_confirmation_dialog,
delete_conversation_confirmation_dialog, delete_conversation_confirmation_dialog,
@@ -24012,6 +24057,16 @@ impl TypedActionView for Workspace {
OpenNetworkLogPane => { OpenNetworkLogPane => {
self.open_network_log_pane(ctx); self.open_network_log_pane(ctx);
} }
OpenAIActivity => {
#[cfg(not(target_family = "wasm"))]
{
self.ai_activity_modal.view.update(ctx, |modal, ctx| {
modal.body().update(ctx, |view, ctx| view.on_open(ctx));
});
self.ai_activity_modal.open();
ctx.notify();
}
}
FixSettingsWithOz { error_description } => { FixSettingsWithOz { error_description } => {
use crate::ai::skills::SkillManager; use crate::ai::skills::SkillManager;
let modify_settings_skill = SkillManager::as_ref(ctx) let modify_settings_skill = SkillManager::as_ref(ctx)
@@ -26869,6 +26924,10 @@ impl View for Workspace {
if self.background_jobs_modal.is_open() { if self.background_jobs_modal.is_open() {
stack.add_child(self.background_jobs_modal.render()); stack.add_child(self.background_jobs_modal.render());
} }
#[cfg(not(target_family = "wasm"))]
if self.ai_activity_modal.is_open() {
stack.add_child(self.ai_activity_modal.render());
}
if self.workflow_modal.as_ref(app).is_open() { if self.workflow_modal.as_ref(app).is_open() {
stack.add_child(ChildView::new(&self.workflow_modal).finish()); stack.add_child(ChildView::new(&self.workflow_modal).finish());