Harden provider diagnostics and tool failure handling
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -16,6 +16,7 @@ use crate::ai::agent::{
|
||||
RunAgentsRequest, SearchCodebaseRequest, ShellCommandDelay,
|
||||
};
|
||||
use crate::ai::document::ai_document_model::AIDocumentId;
|
||||
use crate::ai::grep_query::prepare_grep_queries;
|
||||
|
||||
const MAX_BATCH_READ_ITEMS: usize = 6;
|
||||
const MAX_BATCH_SEARCH_ITEMS: usize = 3;
|
||||
@@ -83,7 +84,10 @@ pub(crate) fn action_from_tool_call(
|
||||
title: Some(required_string(input, "summary")?),
|
||||
},
|
||||
"grep" => AIAgentActionType::Grep {
|
||||
queries: limited_required_strings(input, "queries", MAX_BATCH_SEARCH_ITEMS)?,
|
||||
queries: prepare_grep_queries(
|
||||
limited_required_strings(input, "queries", MAX_BATCH_SEARCH_ITEMS)?,
|
||||
optional_boolean(input, "fixed_strings")?.unwrap_or(false),
|
||||
),
|
||||
path: optional_string(input, "path")?.unwrap_or_default(),
|
||||
},
|
||||
"file_glob" => AIAgentActionType::FileGlob {
|
||||
|
||||
@@ -651,3 +651,74 @@ fn mcp_title_metadata_does_not_change_tool_arguments() {
|
||||
if input == serde_json::json!({"message": "hello", "_galaxy_tool_title": "a real server argument"}))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn grep_literal_mode_escapes_queries_without_changing_scope() {
|
||||
let action = action_from_tool_call(
|
||||
"task",
|
||||
&call(
|
||||
"grep",
|
||||
serde_json::json!({
|
||||
"queries": ["variantResolutions: []", "indexAsset(context"],
|
||||
"fixed_strings": true,
|
||||
"path": "/project/src",
|
||||
}),
|
||||
),
|
||||
&SkillPathOrigin::Local,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let AIAgentActionType::Grep { queries, path } = action.action else {
|
||||
panic!("expected grep action");
|
||||
};
|
||||
assert_eq!(
|
||||
queries,
|
||||
vec![r"variantResolutions: \[\]", r"indexAsset\(context"]
|
||||
);
|
||||
assert_eq!(path, "/project/src");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_default_and_explicit_regex_modes_preserve_patterns() {
|
||||
for fixed_strings in [None, Some(false)] {
|
||||
let mut input = serde_json::json!({"queries": ["[unclosed", "foo|bar"]});
|
||||
if let Some(value) = fixed_strings {
|
||||
input["fixed_strings"] = value.into();
|
||||
}
|
||||
let action = action_from_tool_call(
|
||||
"task",
|
||||
&call("grep", input),
|
||||
&SkillPathOrigin::Local,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let AIAgentActionType::Grep { queries, .. } = action.action else {
|
||||
panic!("expected grep action");
|
||||
};
|
||||
assert_eq!(queries, vec!["[unclosed", "foo|bar"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grep_literal_mode_still_rejects_invalid_booleans_and_excessive_batches() {
|
||||
for (input, expected) in [
|
||||
(
|
||||
serde_json::json!({"queries": ["needle"], "fixed_strings": "true"}),
|
||||
"fixed_strings",
|
||||
),
|
||||
(
|
||||
serde_json::json!({"queries": ["a", "b", "c", "d"], "fixed_strings": true}),
|
||||
"queries",
|
||||
),
|
||||
] {
|
||||
let error = action_from_tool_call(
|
||||
"task",
|
||||
&call("grep", input),
|
||||
&SkillPathOrigin::Local,
|
||||
&HashMap::new(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.contains(expected), "unexpected error: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user