blob: b3b6d502f5ca6d10b82574a0854533b110f0ad21 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
use crate::support::context as fixture;
use maka_agent_engine::RunError;
use maka_agent_facts::context::ModelPurpose;
use maka_agent_facts::context::ModelRequestContext;
use maka_agent_facts::event::Fact;
use maka_event_log::EventLog;
use maka_model::ModelError;
use serde_json::json;
use std::sync::Arc;
use tokio::{
io::AsyncWriteExt,
net::{TcpListener, TcpStream},
};
use tokio_util::sync::CancellationToken;
async fn overflow(socket: &mut TcpStream, observed: bool) {
let error = json!({"error":{"message":"request exceeds context","type":"invalid_request_error","code":"context_length_exceeded"}});
let (status, media, body) = if observed {
let chunk = json!({"id":"partial","object":"chat.completion.chunk","created":1,"model":"test","choices":[{"index":0,"delta":{"content":"already-observed"},"finish_reason":null}]});
(
"200 OK",
"text/event-stream",
format!("data: {chunk}\n\ndata: {error}\n\n"),
)
} else {
("400 Bad Request", "application/json", error.to_string())
};
socket.write_all(format!("HTTP/1.1 {status}\r\nContent-Type: {media}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()).as_bytes()).await.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn overflow_recovers_once_only_without_observed_output() {
for observed in [false, true] {
let recover = !observed;
let directory = tempfile::tempdir().unwrap();
let log = Arc::new(
EventLog::open(&directory.path().join("events.sqlite"))
.await
.unwrap(),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}/v1", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (mut old, _) = listener.accept().await.unwrap();
fixture::read_request(&mut old).await;
fixture::respond(&mut old, "old-answer", "stop").await;
let (mut rejected, _) = listener.accept().await.unwrap();
let rejected_request = fixture::read_request(&mut rejected).await;
assert_eq!(rejected_request["max_tokens"], 128_000);
overflow(&mut rejected, observed).await;
if recover {
let (mut summary, _) = listener.accept().await.unwrap();
let request = fixture::read_request(&mut summary).await;
assert!(
request["messages"].to_string().contains(
maka_agent_facts::context::default_compaction_prompt(
maka_agent_facts::language::Language::En
)
.lines()
.next()
.unwrap()
)
);
fixture::respond(&mut summary, fixture::SUMMARY, "stop").await;
let (mut next, _) = listener.accept().await.unwrap();
let request = fixture::read_request(&mut next).await;
assert!(request["messages"].to_string().contains("summary-marker"));
assert_eq!(request["max_tokens"], 128_000);
fixture::respond(&mut next, "done", "stop").await;
}
});
let worker = fixture::engine(log.clone());
worker
.run(
fixture::input(&base, "old", false),
CancellationToken::new(),
)
.await
.unwrap();
let mut next = fixture::input(&base, "overflow", false);
next.main_output_limit = Some(128_000);
let result = worker.run(next, CancellationToken::new()).await;
if recover {
result.unwrap();
} else {
assert!(
matches!(result,Err(RunError::Model(ModelError::ContextOverflow {observed_output})) if observed_output==observed)
);
}
worker.drain().await;
server.await.unwrap();
let prefix = log.prefix(100, 256 * 1024).await.unwrap();
let events: Vec<_> = prefix
.events
.iter()
.filter(|event| event.event.invocation.invocation_id == "invocation-overflow")
.collect();
assert_eq!(
events
.iter()
.filter(|event| matches!(
event.event.fact,
Fact::ModelRequested {
purpose: ModelPurpose::Main,
..
}
))
.count(),
if recover { 2 } else { 1 }
);
assert_eq!(
events
.iter()
.filter(|event| matches!(
event.event.fact,
Fact::ModelRequested {
purpose: ModelPurpose::Summary,
..
}
))
.count(),
usize::from(recover)
);
assert_eq!(
events
.iter()
.filter(|event| matches!(event.event.fact, Fact::ModelInterrupted { .. }))
.count(),
1
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn proactive_failure_spends_the_same_budget_as_overflow_recovery() {
let directory = tempfile::tempdir().unwrap();
let log = Arc::new(
EventLog::open(&directory.path().join("events.sqlite"))
.await
.unwrap(),
);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let base = format!("http://{}/v1", listener.local_addr().unwrap());
let server = tokio::spawn(async move {
let (mut old, _) = listener.accept().await.unwrap();
fixture::read_request(&mut old).await;
fixture::respond(&mut old, "old-answer", "stop").await;
let mut main = None;
let mut summaries = 0;
while summaries < 1 || main.is_none() {
let (mut socket, _) = listener.accept().await.unwrap();
let request = fixture::read_request(&mut socket).await;
if request["messages"].to_string().contains(
maka_agent_facts::context::default_compaction_prompt(
maka_agent_facts::language::Language::En,
)
.lines()
.next()
.unwrap(),
) {
summaries += 1;
fixture::respond(&mut socket, "incomplete", "length").await;
} else {
assert!(main.is_none(), "only one main request before overflow");
assert!(request["messages"].to_string().contains("old-answer"));
assert!(!request["messages"].to_string().contains("malformed"));
main = Some(socket);
}
}
let mut main = main.unwrap();
overflow(&mut main, false).await;
});
let worker = fixture::engine(log.clone());
let mut old = fixture::input(&base, "old", false);
old.context = Some(ModelRequestContext {
provider_id: "openai".into(),
context_window: Some(170),
declared_window: None,
model_context_window: None,
});
worker.run(old, CancellationToken::new()).await.unwrap();
let mut next = fixture::input(&base, "latched", false);
next.context = Some(ModelRequestContext {
provider_id: "openai".into(),
context_window: Some(170),
declared_window: Some(70),
model_context_window: None,
});
assert!(matches!(
worker.run(next, CancellationToken::new()).await,
Err(RunError::Model(ModelError::ContextOverflow {
observed_output: false
}))
));
worker.drain().await;
server.await.unwrap();
let prefix = log.prefix(100, 256 * 1024).await.unwrap();
assert!(
!prefix
.events
.iter()
.any(|event| matches!(event.event.fact, Fact::ContextCheckpointRecorded { .. }))
);
let events: Vec<_> = prefix
.events
.iter()
.filter(|event| event.event.invocation.invocation_id == "invocation-latched")
.collect();
assert_eq!(
events
.iter()
.filter(|event| matches!(
event.event.fact,
Fact::ModelRequested {
purpose: ModelPurpose::Summary,
..
}
))
.count(),
1
);
assert_eq!(
events
.iter()
.filter(|event| matches!(
event.event.fact,
Fact::ModelRequested {
purpose: ModelPurpose::Main,
..
}
))
.count(),
1
);
}