Skip to main content

agent_commander/
result_metadata.rs

1//! Normalized result metadata for tool-specific agent output.
2
3use serde::{Deserialize, Serialize};
4use serde_json::{json, Map, Value};
5
6/// Pricing details derived from a tool's public pricing estimate.
7#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
8#[serde(rename_all = "camelCase")]
9pub struct PricingInfo {
10    pub total_cost_usd: f64,
11    pub source: String,
12}
13
14/// Stable metadata that summarizes a completed agent run across tools.
15#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
16#[serde(rename_all = "camelCase")]
17pub struct ResultMetadata {
18    pub tool: String,
19    pub exit_code: i32,
20    pub success: bool,
21    pub session_id: Option<String>,
22    pub limit_reached: bool,
23    pub limit_reset_time: Option<String>,
24    pub limit_timezone: Option<String>,
25    pub anthropic_total_cost_usd: Option<f64>,
26    pub public_pricing_estimate: Option<f64>,
27    pub pricing_info: Option<PricingInfo>,
28    pub result_summary: Option<String>,
29    pub result_model_usage: Option<Value>,
30    pub stream_token_usage: Option<Value>,
31    pub sub_agent_calls: Option<Vec<Value>>,
32    pub error_during_execution: bool,
33    pub error_type: Option<String>,
34    pub error_message: Option<String>,
35}
36
37/// Inputs used to build normalized result metadata.
38#[derive(Debug, Clone)]
39pub struct BuildMetadataOptions<'a> {
40    pub tool: &'a str,
41    pub exit_code: i32,
42    pub plain_output: &'a str,
43    pub parsed_output: Option<&'a [Value]>,
44    pub session_id: Option<String>,
45    pub usage: Option<Value>,
46}
47
48#[derive(Debug, Clone, Default)]
49struct UsageLimit {
50    reached: bool,
51    reset_time: Option<String>,
52    timezone: Option<String>,
53}
54
55#[derive(Debug, Clone, Default)]
56struct ExecutionError {
57    has_error: bool,
58    error_type: Option<String>,
59    message: Option<String>,
60}
61
62fn parse_json_messages(output: &str) -> Vec<Value> {
63    if output.trim().is_empty() {
64        return Vec::new();
65    }
66
67    if let Ok(parsed) = serde_json::from_str::<Value>(output) {
68        return match parsed {
69            Value::Array(messages) => messages,
70            message => vec![message],
71        };
72    }
73
74    output
75        .lines()
76        .filter_map(|line| {
77            let trimmed = line.trim();
78            if trimmed.is_empty() || (!trimmed.starts_with('{') && !trimmed.starts_with('[')) {
79                return None;
80            }
81
82            serde_json::from_str::<Value>(trimmed).ok()
83        })
84        .flat_map(|value| match value {
85            Value::Array(values) => values,
86            message => vec![message],
87        })
88        .collect()
89}
90
91fn limit_reached(output: &str) -> bool {
92    let lower = output.to_ascii_lowercase();
93    [
94        "usage limit reached",
95        "usage limit exceeded",
96        "rate limit",
97        "rate_limit",
98        "limit reached",
99        "billing hard limit",
100        "please try again at",
101        "available again at",
102        "session limit reached",
103        "weekly limit reached",
104        "daily limit reached",
105        "monthly limit reached",
106        "freeusagelimiterror",
107        "resets ",
108    ]
109    .iter()
110    .any(|pattern| lower.contains(pattern))
111}
112
113fn clean_reset_time(value: &str) -> Option<String> {
114    let cleaned = value
115        .trim()
116        .trim_matches(|c: char| c == ')' || c == ',' || c == ';')
117        .trim();
118    if cleaned.is_empty() || cleaned.len() > 120 {
119        return None;
120    }
121
122    Some(cleaned.to_string())
123}
124
125fn extract_reset_time(output: &str) -> Option<String> {
126    let lower = output.to_ascii_lowercase();
127    for marker in [
128        "limit resets at",
129        "reset time:",
130        "resets at",
131        "reset at",
132        "try again at",
133        "available again at",
134        "available at",
135        "resets",
136        "reset",
137    ] {
138        if let Some(index) = lower.find(marker) {
139            let tail = &output[index + marker.len()..];
140            let candidate = tail.split(['\n', '.']).next().unwrap_or_default().trim();
141            if let Some(cleaned) = clean_reset_time(candidate) {
142                return Some(cleaned);
143            }
144        }
145    }
146
147    None
148}
149
150fn looks_like_timezone(value: &str) -> bool {
151    let value = value.trim();
152    if value.starts_with("UTC") {
153        return true;
154    }
155    if value.contains('/') {
156        return value
157            .chars()
158            .all(|c| c.is_ascii_alphabetic() || c == '/' || c == '_' || c == '-');
159    }
160    value.len() >= 2 && value.len() <= 5 && value.chars().all(|c| c.is_ascii_uppercase())
161}
162
163fn extract_timezone(output: &str, reset_time: Option<&str>) -> Option<String> {
164    for part in output.split('(').skip(1) {
165        if let Some(candidate) = part.split(')').next() {
166            if looks_like_timezone(candidate) {
167                return Some(candidate.trim().to_string());
168            }
169        }
170    }
171
172    if let Some(reset_time) = reset_time {
173        for token in reset_time.split_whitespace() {
174            let candidate = token.trim_matches(|c: char| c == '(' || c == ')' || c == ',');
175            if looks_like_timezone(candidate) {
176                return Some(candidate.to_string());
177            }
178        }
179    }
180
181    None
182}
183
184fn detect_usage_limit(output: &str) -> UsageLimit {
185    if !limit_reached(output) {
186        return UsageLimit::default();
187    }
188
189    let reset_time = extract_reset_time(output);
190    let timezone = extract_timezone(output, reset_time.as_deref());
191
192    UsageLimit {
193        reached: true,
194        reset_time,
195        timezone,
196    }
197}
198
199fn text_from_value(value: Option<&Value>) -> Option<String> {
200    match value {
201        Some(Value::String(text)) => {
202            let trimmed = text.trim();
203            if trimmed.is_empty() {
204                None
205            } else {
206                Some(trimmed.to_string())
207            }
208        }
209        Some(Value::Array(values)) => {
210            let text = values
211                .iter()
212                .filter_map(|item| text_from_value(Some(item)))
213                .collect::<Vec<_>>()
214                .join("\n");
215            if text.trim().is_empty() {
216                None
217            } else {
218                Some(text)
219            }
220        }
221        Some(Value::Object(map)) => {
222            for key in ["text", "content", "result", "summary", "message"] {
223                if let Some(text) = text_from_value(map.get(key)) {
224                    return Some(text);
225                }
226            }
227            None
228        }
229        _ => None,
230    }
231}
232
233fn tail_chars(value: &str, max_chars: usize) -> String {
234    let chars = value.chars().collect::<Vec<_>>();
235    let start = chars.len().saturating_sub(max_chars);
236    chars[start..].iter().collect()
237}
238
239fn extract_result_summary(messages: &[Value], plain_output: &str) -> Option<String> {
240    let keys = [
241        "result",
242        "summary",
243        "result_summary",
244        "resultSummary",
245        "final_answer",
246        "finalAnswer",
247        "text",
248        "content",
249        "message",
250    ];
251
252    for message in messages.iter().rev() {
253        if !message.is_object() {
254            continue;
255        }
256
257        for key in keys {
258            if let Some(text) = text_from_value(message.get(key)) {
259                return Some(text);
260            }
261        }
262
263        let nested_text = text_from_value(message.pointer("/item/content"))
264            .or_else(|| text_from_value(message.pointer("/item/text")))
265            .or_else(|| text_from_value(message.pointer("/delta/text")));
266        if nested_text.is_some() {
267            return nested_text;
268        }
269    }
270
271    let trimmed = plain_output.trim();
272    if trimmed.is_empty() {
273        None
274    } else {
275        Some(tail_chars(trimmed, 4000))
276    }
277}
278
279fn first_number(messages: &[Value], keys: &[&str]) -> Option<f64> {
280    for message in messages.iter().rev() {
281        for key in keys {
282            if let Some(value) = message.get(*key).and_then(Value::as_f64) {
283                return Some(value);
284            }
285        }
286    }
287
288    None
289}
290
291fn extract_result_model_usage(messages: &[Value]) -> Option<Value> {
292    for message in messages.iter().rev() {
293        for key in [
294            "resultModelUsage",
295            "result_model_usage",
296            "modelUsage",
297            "model_usage",
298            "usage_by_model",
299        ] {
300            if let Some(value) = message.get(key) {
301                if value.is_object() {
302                    return Some(value.clone());
303                }
304            }
305        }
306    }
307
308    let mut usage_by_model = Map::new();
309    for message in messages {
310        let model = message
311            .get("model")
312            .or_else(|| message.pointer("/message/model"))
313            .or_else(|| message.pointer("/part/model"))
314            .and_then(Value::as_str);
315        let usage = message
316            .get("usage")
317            .or_else(|| message.pointer("/message/usage"))
318            .or_else(|| message.pointer("/part/tokens"));
319
320        if let (Some(model), Some(usage)) = (model, usage) {
321            let entry = usage_by_model
322                .entry(model.to_string())
323                .or_insert_with(|| Value::Array(Vec::new()));
324            if let Value::Array(values) = entry {
325                values.push(usage.clone());
326            }
327        }
328    }
329
330    if usage_by_model.is_empty() {
331        None
332    } else {
333        Some(Value::Object(usage_by_model))
334    }
335}
336
337fn extract_sub_agent_calls(messages: &[Value]) -> Option<Vec<Value>> {
338    let mut calls = Vec::new();
339
340    for message in messages {
341        for key in [
342            "subAgentCalls",
343            "sub_agent_calls",
344            "subAgents",
345            "sub_agents",
346        ] {
347            if let Some(Value::Array(values)) = message.get(key) {
348                calls.extend(values.iter().cloned());
349            }
350        }
351
352        let message_type = message
353            .get("type")
354            .or_else(|| message.pointer("/item/type"))
355            .or_else(|| message.get("item_type"))
356            .and_then(Value::as_str);
357        if let Some(message_type) = message_type {
358            let lower = message_type.to_ascii_lowercase();
359            if lower.contains("sub_agent")
360                || lower.contains("sub-agent")
361                || lower.contains("subagent")
362                || lower.contains("collab")
363            {
364                calls.push(json!({
365                    "type": message_type,
366                    "id": message
367                        .get("id")
368                        .or_else(|| message.get("call_id"))
369                        .or_else(|| message.pointer("/item/id"))
370                        .cloned()
371                        .unwrap_or(Value::Null),
372                    "name": message
373                        .get("name")
374                        .or_else(|| message.get("tool"))
375                        .or_else(|| message.pointer("/item/name"))
376                        .cloned()
377                        .unwrap_or(Value::Null),
378                    "status": message
379                        .get("status")
380                        .or_else(|| message.get("state"))
381                        .cloned()
382                        .unwrap_or(Value::Null),
383                    "summary": extract_result_summary(std::slice::from_ref(message), ""),
384                }));
385            }
386        }
387    }
388
389    if calls.is_empty() {
390        None
391    } else {
392        Some(calls)
393    }
394}
395
396fn extract_error_from_messages(messages: &[Value]) -> ExecutionError {
397    for message in messages.iter().rev() {
398        let message_type = message
399            .get("type")
400            .or_else(|| message.get("subtype"))
401            .or_else(|| message.pointer("/item/type"))
402            .and_then(Value::as_str);
403        let is_error = message.get("is_error").and_then(Value::as_bool) == Some(true)
404            || message.get("error").is_some()
405            || matches!(message_type, Some("error" | "step_error"));
406
407        if !is_error {
408            continue;
409        }
410
411        let error = message.get("error");
412        let error_type = error
413            .and_then(|value| value.get("type").or_else(|| value.get("code")))
414            .and_then(Value::as_str)
415            .or_else(|| message.get("errorType").and_then(Value::as_str))
416            .or_else(|| message.get("error_type").and_then(Value::as_str))
417            .or(message_type)
418            .unwrap_or("execution_error")
419            .to_string();
420        let error_message = error
421            .and_then(Value::as_str)
422            .map(ToString::to_string)
423            .or_else(|| {
424                error
425                    .and_then(|value| value.get("message").or_else(|| value.get("details")))
426                    .and_then(Value::as_str)
427                    .map(ToString::to_string)
428            })
429            .or_else(|| text_from_value(message.get("message")))
430            .or_else(|| text_from_value(message.get("result")))
431            .unwrap_or_else(|| "Execution failed".to_string());
432
433        return ExecutionError {
434            has_error: true,
435            error_type: Some(error_type),
436            message: Some(error_message),
437        };
438    }
439
440    ExecutionError::default()
441}
442
443fn detect_execution_error(
444    tool: &str,
445    exit_code: i32,
446    plain_output: &str,
447    messages: &[Value],
448) -> ExecutionError {
449    if tool == "agent" {
450        let detected = crate::tools::agent::detect_errors(plain_output);
451        if detected.has_error {
452            return ExecutionError {
453                has_error: true,
454                error_type: detected
455                    .error_type
456                    .or_else(|| Some("execution_error".to_string())),
457                message: detected
458                    .message
459                    .or_else(|| Some("Execution failed".to_string())),
460            };
461        }
462    }
463
464    let message_error = extract_error_from_messages(messages);
465    if message_error.has_error {
466        return message_error;
467    }
468
469    if exit_code != 0 {
470        let last_line = plain_output
471            .trim()
472            .lines()
473            .rev()
474            .find(|line| !line.trim().is_empty())
475            .map(str::trim)
476            .filter(|line| !line.is_empty())
477            .map(ToString::to_string);
478        return ExecutionError {
479            has_error: true,
480            error_type: Some("exit_code".to_string()),
481            message: last_line.or_else(|| Some(format!("Process exited with code {}", exit_code))),
482        };
483    }
484
485    ExecutionError::default()
486}
487
488fn extract_session_id(explicit_session_id: Option<String>, messages: &[Value]) -> Option<String> {
489    if explicit_session_id.is_some() {
490        return explicit_session_id;
491    }
492
493    for message in messages {
494        for key in [
495            "session_id",
496            "sessionId",
497            "thread_id",
498            "threadId",
499            "conversation_id",
500            "conversationId",
501        ] {
502            if let Some(session_id) = message.get(key).and_then(Value::as_str) {
503                return Some(session_id.to_string());
504            }
505        }
506    }
507
508    None
509}
510
511fn public_pricing_estimate(tool: &str, usage: Option<&Value>) -> Option<f64> {
512    if tool != "agent" && tool != "opencode" {
513        return None;
514    }
515
516    usage
517        .and_then(|value| value.get("totalCost").or_else(|| value.get("totalCostUSD")))
518        .and_then(Value::as_f64)
519}
520
521/// Build stable, caller-facing metadata from tool-specific agent output.
522pub fn build_normalized_result_metadata(options: BuildMetadataOptions<'_>) -> ResultMetadata {
523    let messages = options.parsed_output.map_or_else(
524        || parse_json_messages(options.plain_output),
525        <[Value]>::to_vec,
526    );
527    let usage_limit = detect_usage_limit(options.plain_output);
528    let execution_error = detect_execution_error(
529        options.tool,
530        options.exit_code,
531        options.plain_output,
532        &messages,
533    );
534    let session_id = extract_session_id(options.session_id, &messages);
535    let public_pricing_estimate = public_pricing_estimate(options.tool, options.usage.as_ref());
536    let pricing_info = public_pricing_estimate.map(|total_cost_usd| PricingInfo {
537        total_cost_usd,
538        source: format!("{}-stream-usage", options.tool),
539    });
540
541    ResultMetadata {
542        tool: options.tool.to_string(),
543        exit_code: options.exit_code,
544        success: options.exit_code == 0 && !usage_limit.reached && !execution_error.has_error,
545        session_id,
546        limit_reached: usage_limit.reached,
547        limit_reset_time: usage_limit.reset_time,
548        limit_timezone: usage_limit.timezone,
549        anthropic_total_cost_usd: first_number(
550            &messages,
551            &["total_cost_usd", "totalCostUsd", "anthropicTotalCostUSD"],
552        ),
553        public_pricing_estimate,
554        pricing_info,
555        result_summary: extract_result_summary(&messages, options.plain_output),
556        result_model_usage: extract_result_model_usage(&messages),
557        stream_token_usage: options.usage,
558        sub_agent_calls: extract_sub_agent_calls(&messages),
559        error_during_execution: execution_error.has_error,
560        error_type: execution_error.error_type,
561        error_message: execution_error.message,
562    }
563}
564
565#[cfg(test)]
566mod tests {
567    use super::{build_normalized_result_metadata, BuildMetadataOptions};
568    use serde_json::json;
569
570    #[test]
571    fn normalizes_codex_thread_id_and_usage() {
572        let usage = json!({
573            "inputTokens": 12,
574            "outputTokens": 4,
575        });
576        let metadata = build_normalized_result_metadata(BuildMetadataOptions {
577            tool: "codex",
578            exit_code: 0,
579            plain_output:
580                "{\"type\":\"session\",\"thread_id\":\"thread-123\"}\n{\"type\":\"message\",\"content\":\"Done.\"}",
581            parsed_output: None,
582            session_id: None,
583            usage: Some(usage.clone()),
584        });
585
586        assert_eq!(metadata.tool, "codex");
587        assert!(metadata.success);
588        assert_eq!(metadata.session_id, Some("thread-123".to_string()));
589        assert_eq!(metadata.result_summary, Some("Done.".to_string()));
590        assert_eq!(metadata.stream_token_usage, Some(usage));
591    }
592
593    #[test]
594    fn exposes_agent_pricing_and_sub_agent_calls() {
595        let messages = vec![
596            json!({
597                "type": "collab_tool_call",
598                "id": "call-1",
599                "name": "worker",
600                "status": "completed",
601                "summary": "Worker finished."
602            }),
603            json!({
604                "type": "message",
605                "text": "Final summary."
606            }),
607        ];
608        let usage = json!({
609            "inputTokens": 100,
610            "outputTokens": 40,
611            "totalCost": 0.004,
612            "stepCount": 1
613        });
614        let metadata = build_normalized_result_metadata(BuildMetadataOptions {
615            tool: "agent",
616            exit_code: 0,
617            plain_output: "",
618            parsed_output: Some(&messages),
619            session_id: None,
620            usage: Some(usage.clone()),
621        });
622
623        assert_eq!(metadata.tool, "agent");
624        assert!(metadata.success);
625        assert_eq!(metadata.public_pricing_estimate, Some(0.004));
626        assert_eq!(
627            metadata
628                .pricing_info
629                .as_ref()
630                .map(|pricing| pricing.source.as_str()),
631            Some("agent-stream-usage")
632        );
633        assert_eq!(metadata.result_summary, Some("Final summary.".to_string()));
634        let sub_agent_calls = metadata.sub_agent_calls.unwrap();
635        assert_eq!(sub_agent_calls.len(), 1);
636        assert_eq!(sub_agent_calls[0].get("id"), Some(&json!("call-1")));
637    }
638}