newt-core 0.8.0

Newt-Agent core types, errors, and the NeMoCode-style tier router
use super::*;

struct OverflowRequests {
    requests: Arc<Mutex<Vec<serde_json::Value>>>,
    failures: usize,
    replay: DisplayReplay,
}

impl Respond for OverflowRequests {
    fn respond(&self, req: &Request) -> ResponseTemplate {
        if self.replay.take(req) {
            return sse_replay("recovered");
        }
        let mut requests = self.requests.lock().unwrap();
        requests.push(body_json(req));
        if requests.len() <= self.failures {
            ResponseTemplate::new(500).set_body_json(serde_json::json!({
                "error": {"message": "Context size has been exceeded."}
            }))
        } else {
            self.replay.arm(req);
            ResponseTemplate::new(200).set_body_json(serde_json::json!({
                "choices": [{"finish_reason": "stop", "message": {
                    "role": "assistant", "content": "recovered"
                }}]
            }))
        }
    }
}

fn history() -> Vec<MemMessage> {
    let mut messages = vec![MemMessage::system("base policy")];
    for i in 0..50 {
        messages.push(MemMessage::user(format!(
            "old step {i}: {}",
            "x".repeat(1_200)
        )));
        messages.push(MemMessage::assistant(format!(
            "old reply {i}: {}",
            "y".repeat(1_200)
        )));
    }
    messages.push(MemMessage::user("keep the exact operator prompt"));
    messages
}

type LoopResult = anyhow::Result<(String, bool, Option<crate::TokenUsage>, u32)>;

async fn run_overflow(
    failures: usize,
    messages: &[MemMessage],
    smart: bool,
) -> (LoopResult, Vec<serde_json::Value>, Vec<serde_json::Value>) {
    let server = MockServer::start().await;
    let requests = Arc::new(Mutex::new(Vec::new()));
    Mock::given(method("POST"))
        .and(path("/v1/chat/completions"))
        .respond_with(OverflowRequests {
            requests: requests.clone(),
            failures,
            replay: Default::default(),
        })
        .mount(&server)
        .await;
    let caveats = Caveats::top();
    let uri = server.uri();
    let mut observations = observability::SolveObservation::default();
    let harness = smart.then(|| {
        crate::agentic::smart_harness::SmartHarness::new(
            agent_harness::Session::new(crate::test_guard::unbudgeted_session_config()).unwrap(),
            Arc::new(|prompt| {
                let evidence: serde_json::Value =
                    serde_json::from_str(prompt.lines().last().unwrap()).unwrap();
                let reply = if let Some(candidates) = evidence["candidates"].as_array() {
                    serde_json::to_string(
                        &candidates
                            .iter()
                            .filter(|candidate| candidate["required"] == true)
                            .map(|candidate| candidate["cid"].clone())
                            .collect::<Vec<_>>(),
                    )
                    .unwrap()
                } else {
                    "\"answer\"".to_string()
                };
                Box::pin(async move { Ok((reply, None)) })
            }),
            Default::default(),
        )
        .unwrap()
    });
    let mut c = ctx(&uri, messages, &caveats);
    c.smart_harness = harness.as_ref();
    c.kind = BackendKind::Openai;
    c.task = "keep the exact operator prompt";
    c.action_nudges = false;
    c.num_ctx = Some(65_536);
    c.solve_obs = Some(&mut observations);
    let result = chat_complete(c, &mut NoMcp).await;
    if result.is_ok() {
        if let Some(harness) = &harness {
            let transmitted = server.received_requests().await.unwrap();
            assert_eq!(
                harness.replay_last_request().unwrap(),
                transmitted.last().unwrap().body
            );
        }
    }
    let requests = requests.lock().unwrap().clone();
    let events = observations
        .behavior_signals
        .iter()
        .map(|event| serde_json::to_value(event).unwrap())
        .filter(|event| event["kind"] == "context_exceeded")
        .collect();
    (result, requests, events)
}

#[tokio::test]
async fn context_exceeded_shrinks_before_second_attempt_and_records_recovery() {
    let (result, requests, events) = run_overflow(1, &history(), false).await;
    assert_eq!(
        crate::agentic::model_explanation(&(result.unwrap().0)),
        "recovered"
    );
    assert_eq!(requests.len(), 2);
    assert!(
        requests[1]["messages"].to_string().len() < requests[0]["messages"].to_string().len(),
        "context-exceeded requests must shrink before redispatch"
    );
    assert_eq!(
        events.len(),
        1,
        "overflow and its re-projection must appear in events"
    );
    assert!(events[0]["projected_tokens"].as_u64().is_some());
    assert!(requests[1]["messages"]
        .to_string()
        .contains("keep the exact operator prompt"));
}

#[tokio::test]
async fn context_exceeded_two_failed_shrinks_end_terminal() {
    let (result, requests, events) = run_overflow(usize::MAX, &history(), false).await;
    assert_eq!(
        observability::error_class(&result.unwrap_err()),
        Some(observability::ErrorClass::ContextExceeded)
    );
    assert_eq!(
        requests.len(),
        3,
        "initial dispatch plus two strictly smaller attempts"
    );
    assert!(requests
        .windows(2)
        .all(|pair| pair[1]["messages"].to_string().len() < pair[0]["messages"].to_string().len()));
    assert_eq!(
        events.len(),
        requests.len(),
        "every rejected attempt must be recorded"
    );
    assert_eq!(
        events.last().unwrap()["projected_tokens"],
        serde_json::Value::Null
    );
}

#[tokio::test]
async fn context_exceeded_never_resends_an_unchanged_request() {
    let (result, requests, events) = run_overflow(usize::MAX, &msgs(), false).await;
    assert_eq!(
        observability::error_class(&result.unwrap_err()),
        Some(observability::ErrorClass::ContextExceeded)
    );
    assert_eq!(
        requests.len(),
        1,
        "irreducible request must not be retried unchanged"
    );
    assert_eq!(events.len(), 1);
    assert_eq!(events[0]["projected_tokens"], serde_json::Value::Null);
}

#[tokio::test]
async fn smart_context_exceeded_reprojects_the_recorded_request_before_retry() {
    let (result, requests, events) = run_overflow(1, &history(), true).await;
    assert_eq!(
        crate::agentic::model_explanation(&(result.unwrap().0)),
        "recovered"
    );
    assert_eq!(requests.len(), 2);
    assert!(requests[1]["messages"].to_string().len() < requests[0]["messages"].to_string().len());
    assert_eq!(events.len(), 1);
    assert!(events[0]["projected_tokens"].as_u64().is_some());
}

#[tokio::test]
async fn cap_summary_context_exceeded_keeps_round_cap_and_records_the_rejection() {
    let server = MockServer::start().await;
    let requests = Arc::new(Mutex::new(Vec::new()));
    let captured = requests.clone();
    Mock::given(method("POST"))
        .and(path("/v1/chat/completions"))
        .respond_with(move |request: &Request| {
            let mut requests = captured.lock().unwrap();
            requests.push(body_json(request));
            if requests.len() == 1 {
                ResponseTemplate::new(200).set_body_json(serde_json::json!({
                    "choices":[{"finish_reason":"tool_calls","message":{
                        "role":"assistant","content":null,"tool_calls":[{
                            "id":"completed_call","type":"function","function":{
                                "name":"get_context_remaining","arguments":"{}"
                            }
                        }]
                    }}]
                }))
            } else {
                ResponseTemplate::new(500).set_body_json(serde_json::json!({
                    "error":{"message":"Context size has been exceeded."}
                }))
            }
        })
        .mount(&server)
        .await;
    let messages = msgs();
    let caveats = Caveats::top();
    let uri = server.uri();
    let mut observation = observability::SolveObservation::default();
    let mut state = CompressState::new();
    let mut reason = None;
    let mut context = ctx(&uri, &messages, &caveats);
    context.kind = BackendKind::Openai;
    context.max_tool_rounds = 1;
    context.action_nudges = false;
    context.solve_obs = Some(&mut observation);
    context.compress_state = Some(&mut state);
    context.end_reason = Some(&mut reason);
    let (reply, _, _, _) = chat_complete(context, &mut NoMcp).await.unwrap();
    assert!(reply.contains("tool-round limit (1"), "{reply}");
    assert_eq!(reason, Some(crate::TurnEndReason::RoundCap));
    let requests = requests.lock().unwrap();
    let summaries = &requests[1..];
    assert!(!summaries.is_empty());
    assert!(summaries.len() <= 3, "at most two smaller summary attempts");
    for summary in summaries {
        assert!(
            summary["tools"].is_null(),
            "the tool cap cannot be bypassed"
        );
        let messages = summary["messages"].as_array().unwrap();
        assert!(messages.iter().any(|message| {
            message["role"] == "tool" && message["tool_call_id"] == "completed_call"
        }));
    }
    assert!(
        summaries.windows(2).all(|pair| {
            pair[1]["messages"].to_string().len() < pair[0]["messages"].to_string().len()
        }),
        "a rejected summary must never be re-sent unchanged"
    );
    let rejections = observation
        .behavior_signals
        .iter()
        .filter(|signal| {
            matches!(
                signal,
                observability::BehaviorSignal::ContextExceeded { .. }
            )
        })
        .collect::<Vec<_>>();
    assert_eq!(
        rejections.len(),
        summaries.len(),
        "a rejected cap summary must not disappear into the fallback"
    );
    assert!(matches!(
        rejections.last().unwrap(),
        observability::BehaviorSignal::ContextExceeded {
            projected_tokens: None,
            ..
        }
    ));
    assert!(state.calibration.ratio(None) >= 1.5);
}

#[path = "http_context_exceeded_rounds.rs"]
mod rounds;