use super::*;
use crate::output::{ActivityMetadata, NormalizedUsageAggregate};
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct TaskTelemetry {
pub(crate) index: usize,
pub(crate) scoped_id: Option<String>,
pub(crate) intent: Option<String>,
pub(crate) profile: Option<String>,
pub(crate) agent: Option<String>,
pub(crate) provider: Option<String>,
pub(crate) model: Option<String>,
pub(crate) outcome: Option<String>,
pub(crate) usage: Option<NormalizedUsageAggregate>,
pub(crate) activity: Vec<String>,
pub(crate) activity_omitted: Option<u64>,
pub(crate) text_limited: bool,
pub(crate) changed_paths: Option<u64>,
pub(crate) calls_failed: Option<u64>,
pub(crate) calls_unknown: Option<u64>,
pub(crate) calls_changed: Option<u64>,
}
impl TaskTelemetry {
pub(crate) fn new(index: usize) -> Self {
Self {
index,
scoped_id: None,
intent: None,
profile: None,
agent: None,
provider: None,
model: None,
outcome: None,
usage: None,
activity: Vec::new(),
activity_omitted: None,
text_limited: false,
changed_paths: None,
calls_failed: None,
calls_unknown: None,
calls_changed: None,
}
}
pub(crate) fn set_intent(&mut self, intent: &str) {
self.intent = Some(self.select_text(intent));
}
fn select_text(&mut self, text: &str) -> String {
let safe = sanitized_single_line(text);
let mut end = safe.len().min(240);
while !safe.is_char_boundary(end) {
end -= 1;
}
self.text_limited |= end != safe.len();
safe[..end].to_owned()
}
pub(crate) fn metadata(&mut self, metadata: &ActivityMetadata) {
for (key, value) in &metadata.fields {
if matches!(
key.as_str(),
"intent" | "identity" | "agent" | "provider" | "model"
) {
let value = Some(self.select_text(value));
match key.as_str() {
"intent" => self.intent = value,
"identity" => self.profile = value,
"agent" => self.agent = value,
"provider" => self.provider = value,
"model" => self.model = value,
_ => unreachable!(),
}
}
}
if metadata
.fields
.iter()
.any(|(key, value)| key == "outcome" && value == "unknown")
{
self.outcome = Some("unknown".into());
}
}
pub(crate) fn activity(&mut self, text: &str) {
if self.activity.len() < 8 {
let text = self.select_text(text);
self.activity.push(text);
} else {
self.activity_omitted = self.activity_omitted.map(|count| count.saturating_add(1));
self.text_limited = true;
}
}
pub(crate) fn block(&self, persistence: &str) -> ToolBlock {
let mut rows = Vec::new();
let mut fact = |label: &str, value: Option<String>| {
rows.push(ResultRow {
label: label.into(),
value,
outcome: ResultOutcome::Unknown,
})
};
for (label, value) in [
("Task execution identity", &self.scoped_id),
("Task intent", &self.intent),
("Task profile", &self.profile),
("Task agent", &self.agent),
("Task provider", &self.provider),
("Task model", &self.model),
("Task outcome", &self.outcome),
] {
fact(label, value.clone());
}
fact("Task persistence", Some(persistence.into()));
fact("Task facts limited", Some(self.text_limited.to_string()));
fact(
"Task changed paths",
self.changed_paths.map(|n| n.to_string()),
);
fact(
"Task failed calls",
self.calls_failed.map(|n| n.to_string()),
);
fact(
"Task unknown calls",
self.calls_unknown.map(|n| n.to_string()),
);
fact(
"Task calls with effects",
self.calls_changed.map(|n| n.to_string()),
);
fact(
"Task activity count",
self.activity_omitted.map(|omitted| {
omitted
.saturating_add(self.activity.len() as u64)
.to_string()
}),
);
fact(
"Task activity omitted",
self.activity_omitted.map(|n| n.to_string()),
);
fact(
"Usage provenance",
self.usage.map(|_| "normalized_provider_usage".into()),
);
for (label, value) in [
(
"Usage effective input tokens",
self.usage.map(|u| u.whole_run.effective_input),
),
(
"Usage output tokens",
self.usage.map(|u| u.whole_run.output),
),
(
"Usage cache read tokens",
self.usage
.filter(|u| u.whole_run.cache_known)
.map(|u| u.whole_run.cache_read),
),
(
"Usage latest request sequence",
self.usage.and_then(|u| u.latest_request_sequence),
),
(
"Usage latest effective input tokens",
self.usage.and_then(|u| u.latest).map(|u| u.effective_input),
),
(
"Usage latest output tokens",
self.usage.and_then(|u| u.latest).map(|u| u.output),
),
(
"Usage latest cache read tokens",
self.usage
.and_then(|u| u.latest)
.filter(|u| u.cache_known)
.map(|u| u.cache_read),
),
] {
fact(label, value.map(|n| n.to_string()));
}
fact(
"Usage cache known",
self.usage.map(|u| u.whole_run.cache_known.to_string()),
);
fact(
"Usage latest final",
self.usage.map(|u| u.latest_final.to_string()),
);
ToolBlock::Results { rows }
}
fn valid(&self) -> bool {
self.scoped_id
.as_ref()
.is_none_or(|id| !id.is_empty() && id.len() <= 1024 && sanitize_display_text(id) == *id)
&& [
&self.intent,
&self.profile,
&self.agent,
&self.provider,
&self.model,
]
.into_iter()
.flatten()
.all(|text| text.len() <= 240 && sanitized_single_line(text) == *text)
&& self.outcome.as_deref().is_none_or(|v| {
matches!(
v,
"success" | "failure" | "cancelled" | "running" | "unknown"
)
})
&& self.activity.len() <= 8
&& self
.activity
.iter()
.all(|text| text.len() <= 240 && sanitized_single_line(text) == *text)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct TaskBatchTelemetry {
pub(crate) schema_version: u8,
pub(crate) tasks: Vec<TaskTelemetry>,
}
impl TaskBatchTelemetry {
pub(crate) fn parse(value: &Value) -> Option<Self> {
if serde_json::to_vec(value).ok()?.len() > 64 * 1024 {
return None;
}
let report: Self = serde_json::from_value(value.clone()).ok()?;
if report.schema_version != 1
|| report.tasks.is_empty()
|| report.tasks.len() > crate::subagents::MAX_SUBAGENT_TASKS
|| !report
.tasks
.iter()
.enumerate()
.all(|(index, task)| task.index == index && task.valid())
{
return None;
}
let mut identities = std::collections::HashSet::new();
if report
.tasks
.iter()
.filter_map(|task| task.scoped_id.as_ref())
.any(|id| !identities.insert(id))
{
return None;
}
if report
.tasks
.iter()
.zip(value["tasks"].as_array()?)
.any(|(task, source)| serde_json::to_value(task).ok().as_ref() != Some(source))
{
return None;
}
Some(report)
}
pub(crate) fn parse_for_tasks(value: &Value, params: &Value) -> Option<Self> {
let report = Self::parse(value)?;
let tasks = params["tasks"].as_array()?;
if tasks.len() != report.tasks.len()
|| !tasks.iter().zip(&report.tasks).all(|(source, task)| {
let Some(intent) = source["intent"].as_str() else {
return false;
};
let mut expected = TaskTelemetry::new(task.index);
expected.set_intent(intent);
task.intent == expected.intent
})
{
return None;
}
Some(report)
}
pub(crate) fn matches_parent(&self, parent: &crate::output::ActivityId) -> bool {
self.tasks.iter().all(|task| {
task.scoped_id.as_deref() == Some(parent.child(format!("g{}", task.index + 1)).as_str())
})
}
pub(crate) fn block(&self) -> ToolBlock {
let count = |outcome: &str| {
self.tasks
.iter()
.filter(|task| task.outcome.as_deref() == Some(outcome))
.count()
};
let rows = [
("Tasks total", self.tasks.len()),
("Tasks done", count("success")),
("Tasks failed", count("failure")),
("Tasks running", count("running")),
(
"Tasks unknown",
self.tasks
.iter()
.filter(|task| {
task.outcome.is_none() || task.outcome.as_deref() == Some("unknown")
})
.count(),
),
("Tasks cancelled", count("cancelled")),
]
.into_iter()
.map(|(label, count)| ResultRow {
label: label.into(),
value: Some(count.to_string()),
outcome: ResultOutcome::Unknown,
})
.collect();
ToolBlock::Results { rows }
}
}