Harden provider diagnostics and tool failure handling

This commit is contained in:
Ryan Ward
2026-11-06 19:49:39 +00:00
parent 4b968f34f8
commit 38f1dcdef6
35 changed files with 1558 additions and 79 deletions
+2
View File
@@ -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
);
}
@@ -18,6 +18,8 @@ use galaxy_agent_core::{
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;
@@ -290,6 +292,8 @@ pub(crate) struct ProviderRunCoordinator {
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 {
@@ -334,6 +338,8 @@ impl ProviderRunCoordinator {
max_context_tokens: None,
#[cfg(not(target_family = "wasm"))]
activity_session_id: None,
#[cfg(not(target_family = "wasm"))]
model_failure_diagnostics: ModelFailureDiagnostics::default(),
})
}
@@ -349,6 +355,11 @@ impl ProviderRunCoordinator {
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,
@@ -1206,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",
+5 -1
View File
@@ -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 {
+71
View File
@@ -651,3 +651,74 @@ fn mcp_title_metadata_does_not_change_tool_arguments() {
if input == serde_json::json!({"message": "hello", "_galaxy_tool_title": "a real server argument"}))
);
}
#[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}");
}
}