Synced from monorepo
Synced from monorepo Changes: - Workspace server: surface preview-proxy metrics through the hub metric pump - Shell: reclaim a session’s retained state in one entry - Shell: reclaim a session’s resident state in one entry - Pager: withhold key event types from Alacritty builds that double keys - Tools: cancel a session’s subagents when it closes - Pager: keep the whole plan in scrollback and separate reasoning from output in minimal mode - Pager: probe terminal version over DA2 and include it with feedback - SuperGrok Plus: identity, CLI, and analytics tier surfaces - Shell: inherit the session process scope into subagents - Pager: build @-file-search matcher lazily on first use - Tools: fix description and output contradictions in tool definitions - Workspace: degrade @-file-search instead of aborting on thread exhaustion - Tools: reap a session’s LSP servers when it closes - Tools: fix contradictions and defects in tool descriptions, schemas, and harness pools - MCP: reap stdio MCP children on session close - Shell: reuse spawn-time skill discovery for session telemetry - Tools: stop leaking shell-wrapper positional params into sourced scripts (fixes activate_conda under persistent/static shell) - Shell: self-heal corrupt session-search SQLite cache - Workspace: cap workspace-server tokio workers on many-core hosts - Shell: reap a session’s child processes when it closes - Crash handler: capture SIGABRT so panic-aborts leave crash reports - CLI chat proxy: team-scoped Grok Code managed-config admin routes - MCP: add CLI enable/disable for MCP servers - Shell: cap tokio worker threads for startup thread demand - Workspace: harden git_commit and add git_sync_base operation - Circuit breaker: add feature-gated gRPC retry policy Source-Revision: 2a818575225183d8ca915f5632a09b8067b5156a
This commit is contained in:
parent
02d9359435
commit
5da6962e4a
192 changed files with 10337 additions and 3421 deletions
|
|
@ -7,9 +7,12 @@ version = "0.1.0"
|
|||
[features]
|
||||
# Exposes force_half_open() and other test hooks for cross-crate tests.
|
||||
test-hooks = []
|
||||
# Adds `GrpcRetryPolicy` for classifying `tonic::Code` (pulls in tonic).
|
||||
grpc = ["dep:tonic"]
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
tonic = { workspace = true, optional = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
|
|
|||
116
crates/common/xai-circuit-breaker/src/grpc.rs
Normal file
116
crates/common/xai-circuit-breaker/src/grpc.rs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
//! [`GrpcRetryPolicy`] — classifies a `tonic::Code` into a [`Disposition`], the
|
||||
//! gRPC analogue of [`crate::RetryPolicy`]. Behind the `grpc` feature.
|
||||
|
||||
use crate::retry_policy::Disposition;
|
||||
use tonic::Code;
|
||||
|
||||
/// Maps a gRPC [`Code`] to a [`Disposition`].
|
||||
pub struct GrpcRetryPolicy {
|
||||
retryable: &'static [Code],
|
||||
}
|
||||
|
||||
impl GrpcRetryPolicy {
|
||||
/// Retry only transient connection errors (`Unavailable`, `Unknown`);
|
||||
/// excluding `Internal`/`DeadlineExceeded` avoids amplifying a sick peer.
|
||||
pub const DEFAULT: Self = Self::new(&[Code::Unavailable, Code::Unknown]);
|
||||
|
||||
/// Permissive preset: also retry `Internal` and `DeadlineExceeded`.
|
||||
pub const PERMISSIVE: Self = Self::new(&[
|
||||
Code::Unavailable,
|
||||
Code::Unknown,
|
||||
Code::Internal,
|
||||
Code::DeadlineExceeded,
|
||||
]);
|
||||
|
||||
/// Construct from an explicit retryable-code set.
|
||||
pub const fn new(retryable: &'static [Code]) -> Self {
|
||||
Self { retryable }
|
||||
}
|
||||
|
||||
/// Classify `code`. Returns `None` for `Code::Ok` (success, not an error).
|
||||
pub fn classify(&self, code: Code) -> Option<Disposition> {
|
||||
match code {
|
||||
Code::Ok => None,
|
||||
c if self.is_retryable(c) => Some(Disposition::Retryable),
|
||||
_ => Some(Disposition::Terminal),
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` iff `code` is in the retryable set.
|
||||
pub fn is_retryable(&self, code: Code) -> bool {
|
||||
self.retryable.contains(&code)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GrpcRetryPolicy {
|
||||
fn default() -> Self {
|
||||
Self::DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_retries_transient_codes() {
|
||||
for c in [Code::Unavailable, Code::Unknown] {
|
||||
assert!(GrpcRetryPolicy::DEFAULT.is_retryable(c));
|
||||
assert_eq!(
|
||||
GrpcRetryPolicy::DEFAULT.classify(c),
|
||||
Some(Disposition::Retryable)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_excludes_internal_and_deadline_exceeded() {
|
||||
for c in [Code::Internal, Code::DeadlineExceeded] {
|
||||
assert!(!GrpcRetryPolicy::DEFAULT.is_retryable(c));
|
||||
assert_eq!(
|
||||
GrpcRetryPolicy::DEFAULT.classify(c),
|
||||
Some(Disposition::Terminal)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_terminal_for_permanent_codes() {
|
||||
for c in [
|
||||
Code::NotFound,
|
||||
Code::PermissionDenied,
|
||||
Code::InvalidArgument,
|
||||
Code::AlreadyExists,
|
||||
Code::Unauthenticated,
|
||||
] {
|
||||
assert_eq!(
|
||||
GrpcRetryPolicy::DEFAULT.classify(c),
|
||||
Some(Disposition::Terminal)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ok_classifies_as_none() {
|
||||
assert_eq!(GrpcRetryPolicy::DEFAULT.classify(Code::Ok), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permissive_also_retries_internal_and_deadline() {
|
||||
for c in [
|
||||
Code::Unavailable,
|
||||
Code::Unknown,
|
||||
Code::Internal,
|
||||
Code::DeadlineExceeded,
|
||||
] {
|
||||
assert!(GrpcRetryPolicy::PERMISSIVE.is_retryable(c));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_set_is_respected() {
|
||||
let policy = GrpcRetryPolicy::new(&[Code::ResourceExhausted]);
|
||||
assert!(policy.is_retryable(Code::ResourceExhausted));
|
||||
assert!(!policy.is_retryable(Code::Unavailable));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +1,20 @@
|
|||
//! Shared HTTP circuit breaker.
|
||||
//! Shared circuit breaker.
|
||||
//!
|
||||
//! Sliding-window-with-min-samples algorithm: the breaker trips when
|
||||
//! `sample_count >= min_samples AND error_rate >= error_rate_threshold`
|
||||
//! over the live window. Server- and client-side consumers run the same
|
||||
//! state machine and pick a preset via [`BreakerConfig::server`] or
|
||||
//! [`BreakerConfig::client`].
|
||||
//!
|
||||
//! The breaker is protocol-agnostic (it operates on [`Outcome`]); classification
|
||||
//! helpers exist for HTTP ([`RetryPolicy`]) and gRPC ([`GrpcRetryPolicy`], `grpc`
|
||||
//! feature).
|
||||
|
||||
mod breaker;
|
||||
mod clock;
|
||||
mod config;
|
||||
#[cfg(feature = "grpc")]
|
||||
mod grpc;
|
||||
mod observer;
|
||||
mod registry;
|
||||
mod retry_policy;
|
||||
|
|
@ -20,6 +26,8 @@ pub use breaker::CircuitBreaker;
|
|||
pub use clock::MockClock;
|
||||
pub use clock::{Clock, SystemClock};
|
||||
pub use config::{BreakerConfig, default_failure_codes, parse_failure_codes};
|
||||
#[cfg(feature = "grpc")]
|
||||
pub use grpc::GrpcRetryPolicy;
|
||||
pub use observer::{NoopObserver, Observer};
|
||||
pub use registry::CircuitBreakerRegistry;
|
||||
pub use retry_policy::{Disposition, RetryPolicy};
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ edition.workspace = true
|
|||
description = "SDK for the xAI Computer Hub: connection pool, transparent reconnect, tool harness, and tool-server runtime."
|
||||
|
||||
[features]
|
||||
metrics = ["dep:prometheus"]
|
||||
metrics = ["dep:prometheus", "dep:prometheus-parse"]
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true, features = ["rt", "sync", "time", "macros"] }
|
||||
|
|
@ -25,6 +25,7 @@ tracing-subscriber = { workspace = true }
|
|||
url = { workspace = true }
|
||||
http = { workspace = true }
|
||||
prometheus = { workspace = true, optional = true }
|
||||
prometheus-parse = { version = "0.2.5", optional = true }
|
||||
reqwest = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -125,6 +125,138 @@ fn convert_families(families: &[MetricFamily]) -> Vec<Metric> {
|
|||
out
|
||||
}
|
||||
|
||||
/// Text-format exposition → OTLP, same shapes as [`convert_families`].
|
||||
fn convert_text_exposition(text: &str, prefix: &str) -> Vec<Metric> {
|
||||
let Ok(scrape) =
|
||||
prometheus_parse::Scrape::parse(text.lines().map(|l| std::io::Result::Ok(l.to_owned())))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let now = now_unix_nanos();
|
||||
let cumulative = AggregationTemporality::Cumulative as i32;
|
||||
|
||||
let histogram_names: std::collections::HashSet<&str> = scrape
|
||||
.samples
|
||||
.iter()
|
||||
.filter(|s| matches!(s.value, prometheus_parse::Value::Histogram(_)))
|
||||
.map(|s| s.metric.as_str())
|
||||
.collect();
|
||||
let histogram_sums: std::collections::HashMap<(String, String), f64> = scrape
|
||||
.samples
|
||||
.iter()
|
||||
.filter_map(|s| {
|
||||
let base = s.metric.strip_suffix("_sum")?;
|
||||
if !histogram_names.contains(base) {
|
||||
return None;
|
||||
}
|
||||
let prometheus_parse::Value::Untyped(v) = s.value else {
|
||||
return None;
|
||||
};
|
||||
Some(((base.to_owned(), s.labels.to_string()), v))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let parsed_labels_to_kv = |labels: &prometheus_parse::Labels| -> Vec<KeyValue> {
|
||||
let mut kv: Vec<KeyValue> = labels
|
||||
.iter()
|
||||
.map(|(k, v)| string_kv(k, v.clone()))
|
||||
.collect();
|
||||
kv.sort_by(|a, b| a.key.cmp(&b.key));
|
||||
kv
|
||||
};
|
||||
|
||||
let mut out = Vec::new();
|
||||
for sample in &scrape.samples {
|
||||
if !sample.metric.starts_with(prefix) {
|
||||
continue;
|
||||
}
|
||||
let data = match &sample.value {
|
||||
prometheus_parse::Value::Counter(v) => metric::Data::Sum(Sum {
|
||||
data_points: vec![NumberDataPoint {
|
||||
attributes: parsed_labels_to_kv(&sample.labels),
|
||||
time_unix_nano: now,
|
||||
value: Some(number_data_point::Value::AsDouble(*v)),
|
||||
..Default::default()
|
||||
}],
|
||||
aggregation_temporality: cumulative,
|
||||
is_monotonic: true,
|
||||
}),
|
||||
prometheus_parse::Value::Gauge(v) => metric::Data::Gauge(Gauge {
|
||||
data_points: vec![NumberDataPoint {
|
||||
attributes: parsed_labels_to_kv(&sample.labels),
|
||||
time_unix_nano: now,
|
||||
value: Some(number_data_point::Value::AsDouble(*v)),
|
||||
..Default::default()
|
||||
}],
|
||||
}),
|
||||
prometheus_parse::Value::Histogram(counts) => {
|
||||
let mut counts = counts.clone();
|
||||
counts.sort_by(|a, b| a.less_than.total_cmp(&b.less_than));
|
||||
let mut bucket_counts = Vec::with_capacity(counts.len());
|
||||
let mut explicit_bounds = Vec::with_capacity(counts.len().saturating_sub(1));
|
||||
let mut prev = 0f64;
|
||||
let mut total = 0u64;
|
||||
for bucket in &counts {
|
||||
bucket_counts.push((bucket.count - prev).max(0.0) as u64);
|
||||
if bucket.less_than.is_finite() {
|
||||
explicit_bounds.push(bucket.less_than);
|
||||
} else {
|
||||
total = bucket.count.max(0.0) as u64;
|
||||
}
|
||||
prev = bucket.count;
|
||||
}
|
||||
let sum = histogram_sums
|
||||
.get(&(sample.metric.clone(), sample.labels.to_string()))
|
||||
.copied();
|
||||
metric::Data::Histogram(Histogram {
|
||||
data_points: vec![HistogramDataPoint {
|
||||
attributes: parsed_labels_to_kv(&sample.labels),
|
||||
time_unix_nano: now,
|
||||
count: total,
|
||||
sum,
|
||||
bucket_counts,
|
||||
explicit_bounds,
|
||||
..Default::default()
|
||||
}],
|
||||
aggregation_temporality: cumulative,
|
||||
})
|
||||
}
|
||||
// Untyped = histogram `_sum`/`_count` twins, joined above.
|
||||
prometheus_parse::Value::Summary(_) | prometheus_parse::Value::Untyped(_) => continue,
|
||||
};
|
||||
out.push(Metric {
|
||||
name: sample.metric.clone(),
|
||||
data: Some(data),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Donates metrics from outside the default Prometheus registry.
|
||||
pub struct DonatedMetricsSink {
|
||||
exporter: Arc<MetricExporter>,
|
||||
}
|
||||
|
||||
impl DonatedMetricsSink {
|
||||
/// Returns the number of OTLP metrics queued (malformed input → 0).
|
||||
pub fn export_text_exposition(&self, text: &str, prefix: &str) -> usize {
|
||||
let metrics = convert_text_exposition(text, prefix);
|
||||
let exported = metrics.len();
|
||||
if exported > 0 {
|
||||
self.exporter.export(metrics);
|
||||
}
|
||||
exported
|
||||
}
|
||||
}
|
||||
|
||||
/// `Some` while the donation pump is active; re-fetch per use (cheap).
|
||||
pub fn active_metrics_sink() -> Option<DonatedMetricsSink> {
|
||||
ACTIVE_METRIC_EXPORTER
|
||||
.load_full()
|
||||
.map(|exporter| DonatedMetricsSink { exporter })
|
||||
}
|
||||
|
||||
/// Encodes batches of OTLP metrics onto the pump channel. Chunks at
|
||||
/// [`MAX_METRICS_PER_DONATION`], drops payloads over
|
||||
/// [`MAX_DONATION_BYTES`], and never blocks.
|
||||
|
|
@ -299,6 +431,92 @@ mod tests {
|
|||
.collect()
|
||||
}
|
||||
|
||||
const PROXY_TEXT: &str = "\
|
||||
# HELP preview_proxy_requests_total Proxied preview requests.\n\
|
||||
# TYPE preview_proxy_requests_total counter\n\
|
||||
preview_proxy_requests_total{visibility=\"public\",outcome=\"ok\"} 41\n\
|
||||
preview_proxy_requests_total{visibility=\"private\",outcome=\"upstream_error\"} 2\n\
|
||||
# HELP preview_proxy_active_ws_connections Live proxied WebSocket connections.\n\
|
||||
# TYPE preview_proxy_active_ws_connections gauge\n\
|
||||
preview_proxy_active_ws_connections 3\n\
|
||||
# HELP preview_proxy_request_duration_seconds Preview request latency.\n\
|
||||
# TYPE preview_proxy_request_duration_seconds histogram\n\
|
||||
preview_proxy_request_duration_seconds_bucket{le=\"0.5\"} 4\n\
|
||||
preview_proxy_request_duration_seconds_bucket{le=\"1\"} 6\n\
|
||||
preview_proxy_request_duration_seconds_bucket{le=\"+Inf\"} 7\n\
|
||||
preview_proxy_request_duration_seconds_sum 5.25\n\
|
||||
preview_proxy_request_duration_seconds_count 7\n\
|
||||
# TYPE other_family_total counter\n\
|
||||
other_family_total 9\n";
|
||||
|
||||
#[test]
|
||||
fn text_exposition_converts_and_filters_by_prefix() {
|
||||
let metrics = convert_text_exposition(PROXY_TEXT, "preview_proxy_");
|
||||
let by_name: std::collections::HashMap<&str, &Metric> =
|
||||
metrics.iter().map(|m| (m.name.as_str(), m)).collect();
|
||||
|
||||
assert!(!by_name.contains_key("other_family_total"));
|
||||
assert!(!by_name.contains_key("preview_proxy_request_duration_seconds_sum"));
|
||||
assert!(!by_name.contains_key("preview_proxy_request_duration_seconds_count"));
|
||||
|
||||
let counters: Vec<&Metric> = metrics
|
||||
.iter()
|
||||
.filter(|m| m.name == "preview_proxy_requests_total")
|
||||
.collect();
|
||||
assert_eq!(2, counters.len());
|
||||
let ok_point = counters
|
||||
.iter()
|
||||
.find_map(|m| match m.data.as_ref() {
|
||||
Some(metric::Data::Sum(sum)) => {
|
||||
assert!(sum.is_monotonic);
|
||||
sum.data_points.iter().find(|p| {
|
||||
label_map(&p.attributes).get("outcome").map(String::as_str) == Some("ok")
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("ok counter point");
|
||||
assert_eq!(
|
||||
Some(number_data_point::Value::AsDouble(41.0)),
|
||||
ok_point.value
|
||||
);
|
||||
assert_eq!(
|
||||
Some("public"),
|
||||
label_map(&ok_point.attributes)
|
||||
.get("visibility")
|
||||
.map(String::as_str)
|
||||
);
|
||||
|
||||
let Some(metric::Data::Gauge(gauge)) =
|
||||
by_name["preview_proxy_active_ws_connections"].data.as_ref()
|
||||
else {
|
||||
panic!("gauge expected");
|
||||
};
|
||||
assert_eq!(
|
||||
Some(number_data_point::Value::AsDouble(3.0)),
|
||||
gauge.data_points[0].value
|
||||
);
|
||||
|
||||
let Some(metric::Data::Histogram(hist)) = by_name["preview_proxy_request_duration_seconds"]
|
||||
.data
|
||||
.as_ref()
|
||||
else {
|
||||
panic!("histogram expected");
|
||||
};
|
||||
let point = &hist.data_points[0];
|
||||
assert_eq!(vec![0.5, 1.0], point.explicit_bounds);
|
||||
assert_eq!(vec![4, 2, 1], point.bucket_counts);
|
||||
assert_eq!(7, point.count);
|
||||
assert_eq!(Some(5.25), point.sum);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_exposition_tolerates_garbage_and_empty_input() {
|
||||
assert!(convert_text_exposition("", "preview_proxy_").is_empty());
|
||||
assert!(convert_text_exposition("not a metric line at all", "preview_proxy_").is_empty());
|
||||
assert!(convert_text_exposition(PROXY_TEXT, "no_such_prefix_").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_counter_gauge_histogram_with_labels() {
|
||||
let registry = Registry::new();
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
//! Lenient deserializers for tool-argument booleans: a boolean may arrive as a
|
||||
//! JSON string (`"true"`) or number (`1`) when a client doesn't coerce args
|
||||
//! against the tool schema. Accepted forms (strings case-insensitive, trimmed;
|
||||
//! `null` is `false`):
|
||||
//! Lenient deserializers for tool arguments whose wire shape models get
|
||||
//! wrong in predictable ways.
|
||||
//!
|
||||
//! Booleans may arrive as a JSON string (`"true"`) or number (`1`) when a
|
||||
//! client doesn't coerce args against the tool schema. Accepted forms
|
||||
//! (strings case-insensitive, trimmed; `null` is `false`):
|
||||
//!
|
||||
//! | Truthy | Falsy |
|
||||
//! |---------------------------------------|------------------------------------------------|
|
||||
//! | `true`, `"true"`, `"yes"`, `"1"`, `1` | `false`, `"false"`, `"no"`, `"0"`, `0`, `null` |
|
||||
//!
|
||||
//! String lists (e.g. `task_ids`) may arrive as a bare string or number
|
||||
//! instead of an array; see [`lenient_string_list_from_json`].
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
|
|
@ -71,6 +76,43 @@ where
|
|||
.ok_or_else(|| serde::de::Error::custom(invalid_bool_message(&value)))
|
||||
}
|
||||
|
||||
/// Parse a JSON value into a list of strings, tolerating the shapes models
|
||||
/// actually send:
|
||||
///
|
||||
/// - array of strings/numbers → each element as a string (`228` → `"228"`),
|
||||
/// - bare string or number → one-element list,
|
||||
/// - `null` → empty list.
|
||||
///
|
||||
/// Booleans, objects, and nested arrays are rejected (`None`).
|
||||
pub fn lenient_string_list_from_json(value: &serde_json::Value) -> Option<Vec<String>> {
|
||||
fn item_to_string(v: &serde_json::Value) -> Option<String> {
|
||||
match v {
|
||||
serde_json::Value::String(s) => Some(s.clone()),
|
||||
serde_json::Value::Number(n) => Some(n.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
match value {
|
||||
serde_json::Value::Array(items) => items.iter().map(item_to_string).collect(),
|
||||
serde_json::Value::Null => Some(Vec::new()),
|
||||
other => item_to_string(other).map(|s| vec![s]),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deserialize a `Vec<String>` per [`lenient_string_list_from_json`]; pair
|
||||
/// with `#[serde(default)]` so an absent key yields an empty list.
|
||||
pub fn deserialize_lenient_string_list<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let value = serde_json::Value::deserialize(deserializer)?;
|
||||
lenient_string_list_from_json(&value).ok_or_else(|| {
|
||||
serde::de::Error::custom(format!(
|
||||
"expected a list of string ids (or a single string), got {value}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -192,4 +234,50 @@ mod tests {
|
|||
assert_eq!(deser_opt_bool(r#"{"value":0}"#).unwrap(), Some(false));
|
||||
assert!(deser_opt_bool(r#"{"value":"nope"}"#).is_err());
|
||||
}
|
||||
|
||||
// ── lenient string lists ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn string_list_accepts_arrays_strings_and_numbers() {
|
||||
assert_eq!(
|
||||
lenient_string_list_from_json(&json!(["a", "b"])),
|
||||
Some(vec!["a".to_string(), "b".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
lenient_string_list_from_json(&json!("abc")),
|
||||
Some(vec!["abc".to_string()])
|
||||
);
|
||||
// A bare OS-PID-style number becomes a one-element string list so the
|
||||
// tool can answer with a clean "Task 228 not found" instead of a
|
||||
// deserialize error.
|
||||
assert_eq!(
|
||||
lenient_string_list_from_json(&json!(228)),
|
||||
Some(vec!["228".to_string()])
|
||||
);
|
||||
assert_eq!(
|
||||
lenient_string_list_from_json(&json!([1, "b"])),
|
||||
Some(vec!["1".to_string(), "b".to_string()])
|
||||
);
|
||||
assert_eq!(lenient_string_list_from_json(&json!(null)), Some(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_list_rejects_non_id_shapes() {
|
||||
for v in [json!(true), json!({}), json!([["nested"]]), json!([true])] {
|
||||
assert_eq!(lenient_string_list_from_json(&v), None, "should reject {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_string_list_reports_readable_error() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Wrapper {
|
||||
#[serde(default, deserialize_with = "deserialize_lenient_string_list")]
|
||||
value: Vec<String>,
|
||||
}
|
||||
let err = serde_json::from_str::<Wrapper>(r#"{"value":{}}"#).unwrap_err();
|
||||
assert!(err.to_string().contains("expected a list of string ids"));
|
||||
let ok: Wrapper = serde_json::from_str(r#"{}"#).unwrap();
|
||||
assert!(ok.value.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -321,10 +321,22 @@ pub const MAX_MULTI_WAIT_IDS: usize = 20;
|
|||
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema)]
|
||||
pub struct TaskOutputToolInput {
|
||||
/// Task IDs to query. Pass one or more; a single task is a one-element list.
|
||||
///
|
||||
/// Lenient on the wire (invisible to the advertised schema — schemars
|
||||
/// ignores serde aliases and custom deserializers): also accepts the
|
||||
/// singular `task_id` key and a bare string/number instead of an array.
|
||||
/// Models frequently mirror `kill_task`'s singular `task_id` here (in
|
||||
/// soak rollouts 3 of 4 organic calls did) and previously hard-failed
|
||||
/// with "Provide a non-empty task_ids list", after which they abandoned
|
||||
/// the background-task workflow for shell polling.
|
||||
#[schemars(
|
||||
description = "Task IDs to get output from. Pass one or more; for a single task use a one-element array. With a positive timeout_ms, multiple ids wait until all complete. Omit timeout_ms or pass 0 for a non-blocking snapshot."
|
||||
)]
|
||||
#[serde(default)]
|
||||
#[serde(
|
||||
default,
|
||||
alias = "task_id",
|
||||
deserialize_with = "crate::serde_lenient::deserialize_lenient_string_list"
|
||||
)]
|
||||
pub task_ids: Vec<String>,
|
||||
|
||||
/// When set and positive, wait up to this many milliseconds; omit or `0` polls.
|
||||
|
|
@ -775,7 +787,7 @@ Workspace boundary:
|
|||
pub const GENERAL_PURPOSE_SUBAGENT: BuiltinSubagent = BuiltinSubagent {
|
||||
name: "general-purpose",
|
||||
description: "General purpose agent for multi-step tasks.",
|
||||
tools_template: "Has access to all tools: \
|
||||
tools_template: "Has access to: \
|
||||
${{ tools.by_kind.execute }}, ${{ tools.by_kind.read }}, ${{ tools.by_kind.edit }}, \
|
||||
${{ tools.by_kind.list }}, ${{ tools.by_kind.search }}, ${{ tools.by_kind.web_search }}, \
|
||||
and ${{ tools.by_kind.plan }}.",
|
||||
|
|
@ -796,10 +808,10 @@ pub const EXPLORE_SUBAGENT: BuiltinSubagent = BuiltinSubagent {
|
|||
pub const PLAN_SUBAGENT: BuiltinSubagent = BuiltinSubagent {
|
||||
name: "plan",
|
||||
description: "Software architect for planning implementation strategies.",
|
||||
tools_template: "Read-only \u{2014} has access to all tools except file editing \
|
||||
(${{ tools.by_kind.edit }} is not available): \
|
||||
tools_template: "Read-only \u{2014} has access to: \
|
||||
${{ tools.by_kind.read }}, ${{ tools.by_kind.list }}, ${{ tools.by_kind.search }}, \
|
||||
${{ tools.by_kind.web_search }}, and ${{ tools.by_kind.plan }}.",
|
||||
${{ tools.by_kind.web_search }}, and ${{ tools.by_kind.plan }}. \
|
||||
File editing and command execution are not available.",
|
||||
prompt_template: PLAN_PROMPT,
|
||||
};
|
||||
|
||||
|
|
@ -997,14 +1009,14 @@ pub fn build_task_output_description(naming: &TaskOutputToolNaming) -> String {
|
|||
|
||||
let target_suffix = lifecycle_target_suffix(monitor_present, subagent_present);
|
||||
|
||||
let mut sources: Vec<String> = Vec::new();
|
||||
if let Some(p) = bash_background_param {
|
||||
sources.push(format!("{p}=true commands"));
|
||||
}
|
||||
if let Some(p) = subagent_background_param {
|
||||
sources.push(format!("{p}=true subagents"));
|
||||
}
|
||||
let sources = sources.join(" or ");
|
||||
let sources = match (bash_background_param, subagent_background_param) {
|
||||
// Both params share one client-facing name: don't repeat it.
|
||||
(Some(b), Some(s)) if b == s => format!("{b}=true commands or subagents"),
|
||||
(Some(b), Some(s)) => format!("{b}=true commands or {s}=true subagents"),
|
||||
(Some(b), None) => format!("{b}=true commands"),
|
||||
(None, Some(s)) => format!("{s}=true subagents"),
|
||||
(None, None) => "background tasks".to_string(),
|
||||
};
|
||||
|
||||
let monitor_note = monitor_task_id_note(monitor_tool, task_id_param);
|
||||
let read_note = match read_tool {
|
||||
|
|
@ -1040,14 +1052,14 @@ pub fn build_wait_tasks_description(naming: &WaitTasksToolNaming) -> String {
|
|||
subagent_background_param,
|
||||
} = *naming;
|
||||
|
||||
let mut sources: Vec<String> = Vec::new();
|
||||
if let Some(p) = bash_background_param {
|
||||
sources.push(format!("{p}=true"));
|
||||
}
|
||||
if let Some(p) = subagent_background_param {
|
||||
sources.push(format!("{p}=true"));
|
||||
}
|
||||
let sources = sources.join(" or ");
|
||||
let sources = match (bash_background_param, subagent_background_param) {
|
||||
// Both params share one client-facing name: don't repeat it.
|
||||
(Some(b), Some(s)) if b == s => format!("{b}=true commands or subagents"),
|
||||
(Some(b), Some(s)) => format!("{b}=true commands or {s}=true subagents"),
|
||||
(Some(b), None) => format!("{b}=true commands"),
|
||||
(None, Some(s)) => format!("{s}=true subagents"),
|
||||
(None, None) => "background tasks".to_string(),
|
||||
};
|
||||
|
||||
format!(
|
||||
"Wait for multiple background tasks or subagents to complete.\n\n\
|
||||
|
|
@ -1157,6 +1169,54 @@ mod tests {
|
|||
assert!(value.get("model").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_output_input_accepts_singular_task_id_alias() {
|
||||
// Canonical plural form (unchanged).
|
||||
let input: TaskOutputToolInput =
|
||||
serde_json::from_str(r#"{"task_ids": ["a", "b"]}"#).unwrap();
|
||||
assert_eq!(input.resolved_task_ids(), vec!["a", "b"]);
|
||||
|
||||
// Singular key with a bare string — the shape models organically send
|
||||
// (mirroring kill_task's singular task_id).
|
||||
let input: TaskOutputToolInput =
|
||||
serde_json::from_str(r#"{"task_id": "abc-123", "timeout_ms": 0}"#).unwrap();
|
||||
assert_eq!(input.resolved_task_ids(), vec!["abc-123"]);
|
||||
assert_eq!(input.timeout_ms, Some(0));
|
||||
|
||||
// Singular key with an array also works.
|
||||
let input: TaskOutputToolInput =
|
||||
serde_json::from_str(r#"{"task_id": ["x", "y"]}"#).unwrap();
|
||||
assert_eq!(input.resolved_task_ids(), vec!["x", "y"]);
|
||||
|
||||
// Plural key with a bare string.
|
||||
let input: TaskOutputToolInput = serde_json::from_str(r#"{"task_ids": "solo"}"#).unwrap();
|
||||
assert_eq!(input.resolved_task_ids(), vec!["solo"]);
|
||||
|
||||
// Bare number (observed: an OS PID) becomes a string id, so the tool
|
||||
// answers "Task 228 not found" instead of a deserialize error.
|
||||
let input: TaskOutputToolInput = serde_json::from_str(r#"{"task_id": 228}"#).unwrap();
|
||||
assert_eq!(input.resolved_task_ids(), vec!["228"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_output_input_schema_does_not_advertise_the_alias() {
|
||||
// The leniency is wire-only: the advertised schema must keep exactly
|
||||
// the canonical properties (task_ids, timeout_ms) so tool-definition
|
||||
// dumps and param randomization are unaffected.
|
||||
let schema = serde_json::to_value(schemars::schema_for!(TaskOutputToolInput)).unwrap();
|
||||
let props = schema["properties"].as_object().unwrap();
|
||||
assert!(props.contains_key("task_ids"));
|
||||
assert!(props.contains_key("timeout_ms"));
|
||||
assert!(
|
||||
!props.contains_key("task_id"),
|
||||
"singular alias must not leak into the schema: {props:?}"
|
||||
);
|
||||
assert_eq!(props.len(), 2);
|
||||
// And task_ids stays a plain string array.
|
||||
assert_eq!(props["task_ids"]["type"], "array");
|
||||
assert_eq!(props["task_ids"]["items"]["type"], "string");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_optional_arg_moves_when_no_trim() {
|
||||
assert_eq!(
|
||||
|
|
@ -1338,12 +1398,12 @@ mod tests {
|
|||
// Bare-kind naming reproduces the placeholder kinds verbatim.
|
||||
assert_eq!(
|
||||
GENERAL_PURPOSE_SUBAGENT.render_tools(&plain_tool_naming()),
|
||||
"Has access to all tools: execute, read, edit, list, search, web_search, and plan."
|
||||
"Has access to: execute, read, edit, list, search, web_search, and plan."
|
||||
);
|
||||
assert_eq!(
|
||||
PLAN_SUBAGENT.render_tools(&plain_tool_naming()),
|
||||
"Read-only \u{2014} has access to all tools except file editing (edit is not available): \
|
||||
read, list, search, web_search, and plan."
|
||||
"Read-only \u{2014} has access to: read, list, search, web_search, and plan. \
|
||||
File editing and command execution are not available."
|
||||
);
|
||||
|
||||
// Real tool names are substituted per kind.
|
||||
|
|
@ -1512,7 +1572,7 @@ mod tests {
|
|||
desc,
|
||||
"Get output and status from a background task, monitor, or subagent.\n\n\
|
||||
Usage notes:\n\
|
||||
- Pass task_ids with one or more ids from background=true commands or background=true subagents (a monitor's task_id is returned by monitor); for a single task use a one-element array. Multiple ids with a positive timeout_ms wait until all complete\n\
|
||||
- Pass task_ids with one or more ids from background=true commands or subagents (a monitor's task_id is returned by monitor); for a single task use a one-element array. Multiple ids with a positive timeout_ms wait until all complete\n\
|
||||
- Omit timeout_ms or pass 0 for a non-blocking status snapshot; set a positive timeout_ms to wait up to that many milliseconds, capped at ~10 min\n\
|
||||
- Returns current output, status, and exit code if completed\n\
|
||||
- If output is large, use read_file on the output_file path"
|
||||
|
|
@ -1553,7 +1613,7 @@ mod tests {
|
|||
"Wait for multiple background tasks or subagents to complete.\n\n\
|
||||
Prefer get_command_or_subagent_output with task_ids and a positive timeout_ms. This tool is kept for compatibility.\n\n\
|
||||
Usage notes:\n\
|
||||
- task_ids: list of task IDs from background=true or background=true\n\
|
||||
- task_ids: list of task IDs from background=true commands or subagents\n\
|
||||
- mode: 'wait_all' or 'wait_any'\n\
|
||||
- timeout_ms: optional max wait, default 30s, capped at ~10 min"
|
||||
);
|
||||
|
|
@ -1566,7 +1626,9 @@ mod tests {
|
|||
bash_background_param: None,
|
||||
subagent_background_param: Some("run_in_background"),
|
||||
});
|
||||
assert!(desc.contains("- task_ids: list of task IDs from run_in_background=true\n"));
|
||||
assert!(
|
||||
desc.contains("- task_ids: list of task IDs from run_in_background=true subagents\n")
|
||||
);
|
||||
assert!(desc.contains("Prefer get_task_output with task_ids"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue