use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ToolCallDialect {
FunctionTag,
RootTag,
BareJson,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecoveredIdentity {
pub locator: String,
pub cid: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum ParseSignal {
NoParseableToolCall { round: usize },
RecoveredToolCall {
round: usize,
dialect: ToolCallDialect,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
calls: Vec<RecoveredIdentity>,
},
}
pub fn round_parse_signal(
round: usize,
content_nonempty: bool,
native_calls: bool,
recovered: Option<ToolCallDialect>,
identities: Vec<RecoveredIdentity>,
) -> Option<ParseSignal> {
if let Some(dialect) = recovered {
return Some(ParseSignal::RecoveredToolCall {
round,
dialect,
calls: identities,
});
}
if !native_calls && content_nonempty {
return Some(ParseSignal::NoParseableToolCall { round });
}
None
}
#[must_use]
pub fn reasoning_overflow_signature(
finish_reason: Option<&str>,
content_empty: bool,
reasoning_nonempty: bool,
has_calls: bool,
) -> bool {
finish_reason == Some("length") && content_empty && reasoning_nonempty && !has_calls
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum BehaviorSignal {
Verification {
round: usize,
decision: String,
repairs_used: usize,
allowance: usize,
report: super::self_verify::VerificationReport,
},
ContextExceeded {
round: usize,
attempt: u32,
estimated_tokens: usize,
projected_tokens: Option<usize>,
},
ChatCompletionFinish {
round: usize,
finish_reason: Option<String>,
},
ReasoningOverflow {
round: usize,
reasoning_overflow_detected: bool,
continuation_attempted: bool,
continuation_succeeded: bool,
finish_reason: String,
reasoning_tokens_estimate: usize,
},
CognitionDropRetry {
round: usize,
from: String,
to: String,
},
}
impl BehaviorSignal {
pub(crate) fn mark_continuation_succeeded(&mut self) {
if let Self::ReasoningOverflow {
continuation_succeeded,
..
} = self
{
*continuation_succeeded = true;
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct SolveObservation {
pub served_model: Option<String>,
pub parse_signals: Vec<ParseSignal>,
pub behavior_signals: Vec<BehaviorSignal>,
pub output_allowance: Option<OutputAllowance>,
pub responses_capability: Option<crate::model_card::ResponsesCapability>,
pub reasoning_effort: Option<crate::model_card::ReasoningEffort>,
pub harness_reply: bool,
}
pub(crate) fn observe_harness_reply(obs: &mut Option<&mut SolveObservation>) {
if let Some(obs) = obs.as_deref_mut() {
obs.harness_reply = true;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct OutputAllowance {
pub tokens: u32,
pub enforced: Enforcement,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum Enforcement {
Server,
Local,
}
pub(crate) fn observe_output_allowance(
obs: &mut Option<&mut SolveObservation>,
tokens: Option<u32>,
sent: bool,
) {
if let Some(obs) = obs.as_deref_mut() {
obs.output_allowance = tokens.map(|tokens| OutputAllowance {
tokens,
enforced: if sent {
Enforcement::Server
} else {
Enforcement::Local
},
});
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorClass {
Model,
ContextExceeded,
Transport,
Timeout,
Harness,
}
pub fn classify_reqwest(e: &reqwest::Error) -> ErrorClass {
if e.is_connect() {
return ErrorClass::Transport;
}
if e.is_timeout() {
return ErrorClass::Timeout;
}
if e.is_status() || e.is_decode() || e.is_body() {
return ErrorClass::Model;
}
if e.is_builder() {
return ErrorClass::Harness;
}
ErrorClass::Transport
}
#[derive(Debug)]
pub struct DispatchError {
pub class: ErrorClass,
msg: String,
usage: Option<crate::TokenUsage>,
}
impl DispatchError {
pub fn new(class: ErrorClass, msg: impl Into<String>) -> Self {
Self {
class,
msg: msg.into(),
usage: None,
}
}
pub fn from_reqwest(prefix: &str, e: reqwest::Error) -> Self {
Self {
class: classify_reqwest(&e),
msg: format!("{prefix}: {e}"),
usage: None,
}
}
pub fn response_read(prefix: &str, e: reqwest::Error) -> Self {
Self {
class: if e.is_timeout() {
ErrorClass::Timeout
} else {
ErrorClass::Transport
},
msg: format!("{prefix}: {e}"),
usage: None,
}
}
pub fn context_exceeded(message: impl Into<String>) -> Self {
Self {
class: ErrorClass::ContextExceeded,
msg: message.into(),
usage: None,
}
}
pub fn http_status(msg: String) -> Self {
Self {
class: if super::cw_overflow::is_context_overflow(&msg) {
ErrorClass::ContextExceeded
} else {
ErrorClass::Model
},
msg,
usage: None,
}
}
pub(crate) fn with_usage(mut self, usage: Option<crate::TokenUsage>) -> Self {
self.usage = usage;
self
}
}
pub(crate) fn reported_usage(e: &anyhow::Error) -> Option<crate::TokenUsage> {
e.downcast_ref::<DispatchError>()?.usage
}
impl std::fmt::Display for DispatchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.msg)
}
}
impl std::error::Error for DispatchError {}
pub fn error_class(e: &anyhow::Error) -> Option<ErrorClass> {
e.downcast_ref::<DispatchError>()
.map(|d| d.class)
.or_else(|| {
e.chain()
.find_map(|c| c.downcast_ref::<reqwest::Error>().map(classify_reqwest))
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn request_deadline_after_connect_classifies_timeout() {
let server = MockServer::start().await;
Mock::given(wiremock::matchers::method("GET"))
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_millis(250)))
.mount(&server)
.await;
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(20))
.build()
.unwrap();
let err = client.get(server.uri()).send().await.unwrap_err();
assert_eq!(classify_reqwest(&err), ErrorClass::Timeout);
}
#[tokio::test]
async fn connection_refused_classifies_transport() {
let port = {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
l.local_addr().unwrap().port()
};
let err = reqwest::Client::new()
.get(format!("http://127.0.0.1:{port}/"))
.send()
.await
.unwrap_err();
assert_eq!(classify_reqwest(&err), ErrorClass::Transport);
}
#[test]
fn http_status_classifies_model_error() {
let e = DispatchError::http_status("Ollama 500 Internal Server Error: boom".into());
assert_eq!(e.class, ErrorClass::Model);
assert_eq!(e.to_string(), "Ollama 500 Internal Server Error: boom");
}
#[test]
fn a_dispatch_error_attached_as_context_keeps_its_class() {
let rejected = |message: &str| {
anyhow::anyhow!("streamed tool call has no ID")
.context(DispatchError::http_status(message.to_string()))
.context("round 3")
};
assert_eq!(
error_class(&rejected("streamed tool call has no ID")),
Some(ErrorClass::Model)
);
assert_eq!(
error_class(&rejected(
r#"OpenAI stream error: {"message":"Context size has been exceeded."}"#
)),
Some(ErrorClass::ContextExceeded)
);
}
#[tokio::test]
async fn builder_error_classifies_harness() {
let err = reqwest::Client::new().get("").send().await.unwrap_err();
assert!(err.is_builder());
assert_eq!(classify_reqwest(&err), ErrorClass::Harness);
let chained = anyhow::Error::from(err).context("decoding response body");
assert_eq!(error_class(&chained), Some(ErrorClass::Harness));
}
#[test]
fn error_class_walks_the_anyhow_chain() {
let wrapped = anyhow::Error::new(DispatchError::http_status("Ollama 404: nope".into()))
.context("round 3 dispatch");
assert_eq!(error_class(&wrapped), Some(ErrorClass::Model));
assert_eq!(error_class(&anyhow::anyhow!("spawn failed")), None);
}
#[tokio::test]
async fn from_reqwest_preserves_the_historical_message() {
let err = reqwest::Client::new().get("").send().await.unwrap_err();
let expect = format!("request failed: {err}");
let d = DispatchError::from_reqwest("request failed", err);
assert_eq!(d.to_string(), expect);
assert!(d.to_string().contains("request failed"));
}
#[test]
fn content_without_any_call_signals_no_parseable_tool_call() {
assert_eq!(
round_parse_signal(2, true, false, None, vec![]),
Some(ParseSignal::NoParseableToolCall { round: 2 })
);
}
#[test]
fn recovery_hit_signals_recovered_tool_call_with_its_dialect() {
assert_eq!(
round_parse_signal(1, true, false, Some(ToolCallDialect::FunctionTag), vec![]),
Some(ParseSignal::RecoveredToolCall {
round: 1,
dialect: ToolCallDialect::FunctionTag,
calls: vec![],
})
);
}
#[test]
fn healthy_native_call_and_empty_content_signal_nothing() {
assert_eq!(round_parse_signal(0, true, true, None, vec![]), None);
assert_eq!(round_parse_signal(0, false, false, None, vec![]), None);
}
#[test]
fn reasoning_overflow_requires_the_exact_structural_signature() {
assert!(reasoning_overflow_signature(
Some("length"),
true,
true,
false
));
for (finish_reason, content_empty, reasoning_nonempty, has_calls) in [
(Some("stop"), true, true, false),
(Some("length"), false, true, false),
(Some("length"), true, false, false),
(Some("length"), true, true, true),
(None, true, true, false),
] {
assert!(
!reasoning_overflow_signature(
finish_reason,
content_empty,
reasoning_nonempty,
has_calls
),
"non-matching signature must not trigger: {finish_reason:?}"
);
}
}
#[test]
fn reasoning_overflow_signal_serializes_recovery_outcome() {
let signal = BehaviorSignal::ReasoningOverflow {
round: 2,
reasoning_overflow_detected: true,
continuation_attempted: true,
continuation_succeeded: false,
finish_reason: "length".into(),
reasoning_tokens_estimate: 37,
};
assert_eq!(
serde_json::to_value(signal).unwrap(),
serde_json::json!({
"kind": "reasoning_overflow",
"round": 2,
"reasoning_overflow_detected": true,
"continuation_attempted": true,
"continuation_succeeded": false,
"finish_reason": "length",
"reasoning_tokens_estimate": 37,
})
);
}
#[test]
fn chat_completion_finish_signal_records_each_finish_reason() {
assert_eq!(
serde_json::to_value(BehaviorSignal::ChatCompletionFinish {
round: 4,
finish_reason: Some("tool_calls".into()),
})
.unwrap(),
serde_json::json!({
"kind": "chat_completion_finish",
"round": 4,
"finish_reason": "tool_calls",
})
);
}
#[test]
fn parse_signals_serialize_as_adr_event_lines() {
let no_parse = serde_json::to_value(ParseSignal::NoParseableToolCall { round: 4 }).unwrap();
assert_eq!(
no_parse,
serde_json::json!({"kind": "no_parseable_tool_call", "round": 4})
);
let recovered = serde_json::to_value(ParseSignal::RecoveredToolCall {
round: 1,
dialect: ToolCallDialect::BareJson,
calls: vec![],
})
.unwrap();
assert_eq!(
recovered,
serde_json::json!({"kind": "recovered_tool_call", "round": 1, "dialect": "bare_json"})
);
assert!(no_parse.get("contract_version").is_none());
}
}