blob: 0a942e97aa0c786fc5272258f902cc79291c243a [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::invocation;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use maka_agent_engine::{Engine, RunError, RunInput};
use maka_agent_facts::event::Fact;
use maka_agent_facts::event::Invocation;
use maka_agent_facts::event::InvocationOutcome;
use maka_agent_facts::event::ToolOutcome;
use maka_agent_facts::tool_call::ToolCallIdentity;
use maka_agent_facts::tool_call::ToolOrigin;
use maka_agent_facts::tool_call::ToolRejection;
use maka_agent_facts::tools::ToolExecutor;
use maka_agent_facts::tools::ToolFuture;
use maka_code_runtime::CellLimits;
use maka_event_log::EventLog;
use maka_model::{ModelExecutor, ProviderConfig, ProviderKind};
use maka_tools::CodeExecutor;
use maka_tools::{
ToolCatalog, ToolDefinition, ToolHandler, ToolMode, ToolNesting, ToolRegistration,
ToolSemantics,
};
use serde_json::{Value, json};
use tokio::io::AsyncWriteExt;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::Notify;
use tokio_util::sync::CancellationToken;
#[derive(Clone, Default)]
struct Effect {
entered: Arc<Notify>,
cancelled: Arc<Notify>,
release: Arc<Notify>,
count: Arc<AtomicUsize>,
}
impl ToolExecutor for Effect {
fn names(&self) -> Vec<String> {
vec!["echo".into()]
}
fn invoke(&self, _name: String, input: Value, cancellation: CancellationToken) -> ToolFuture {
let effect = self.clone();
Box::pin(async move {
effect.count.fetch_add(1, Ordering::SeqCst);
effect.entered.notify_one();
cancellation.cancelled().await;
effect.cancelled.notify_one();
// This dispatched native effect must finish even after cancellation.
effect.release.notified().await;
Ok(input)
})
}
}
fn input(base: &str, suffix: &str, effect: Arc<Effect>) -> RunInput {
RunInput {
model_source: None,
model_revision: None,
provider_id: "fixture".into(),
main_output_limit: None,
context: None,
invocation: Invocation {
session_id: "session".into(),
turn_id: format!("turn-{suffix}"),
run_id: format!("run-{suffix}"),
invocation_id: format!("invocation-{suffix}"),
},
request_fingerprint: None,
provider: ProviderConfig {
adapter: None,
capabilities: Default::default(),
kind: ProviderKind::OpenaiChatCompletions,
model: "test".into(),
base_url: base.into(),
auth: maka_model::ProviderAuth::ApiKey("test-key".into()),
headers: BTreeMap::new(),
network: Default::default(),
body_overlay: None,
},
provider_options: json!({}),
supports_vision: false,
configuration: invocation::configuration(ToolMode::Direct),
work: maka_agent_engine::RunWork::Message {
allow_prior_unknown: false,
source_messages: Vec::new(),
message: format!("question {suffix}").into(),
tools: ToolCatalog::new([ToolRegistration {
definition: ToolDefinition {
freeform: None,
output_schema: None,
provider: None,
name: "echo".into(),
description: "echo".into(),
input_schema: json!({"type":"object"}),
},
nesting: ToolNesting::Nestable,
semantics: ToolSemantics::Parallel,
handler: ToolHandler::Immediate(effect),
}])
.unwrap(),
},
}
}
use crate::support::http;
use http::read_request;
async fn respond(socket: &mut TcpStream, tool: bool) {
let delta = if tool {
json!({"tool_calls":[
{"index":0,"id":"call-1","type":"function","function":{"name":"echo","arguments":"{\"value\":42}"}},
{"index":1,"id":"call-2","type":"function","function":{"name":"echo","arguments":"{\"value\":43}"}}
]})
} else {
json!({"content":"done"})
};
let first = json!({"id":"reply","object":"chat.completion.chunk","created":1,"model":"test","choices":[{"index":0,"delta":delta,"finish_reason":null}]});
let last = json!({"id":"reply","object":"chat.completion.chunk","created":1,"model":"test","choices":[{"index":0,"delta":{},"finish_reason":if tool {"tool_calls"} else {"stop"}}],"usage":{"prompt_tokens":4,"completion_tokens":3,"total_tokens":7}});
let body = format!("data: {first}\n\ndata: {last}\n\ndata: [DONE]\n\n");
socket.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\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 aborted_caller_cancels_worker_but_retains_admission_until_effect_is_durable() {
tokio::time::timeout(Duration::from_secs(30), async {
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 requests = Vec::new();
for index in 0..3 {
let (mut socket, _) = listener.accept().await.unwrap();
requests.push(read_request(&mut socket).await);
respond(&mut socket, index == 0).await;
}
requests
});
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("events.sqlite");
let effect = Arc::new(Effect::default());
{
let log = Arc::new(EventLog::open(&path).await.unwrap());
let engine = Engine::new(
log.clone(),
ModelExecutor::new(Duration::from_secs(10)).unwrap(),
CodeExecutor::new(CellLimits::default()).unwrap(),
);
let caller_token = CancellationToken::new();
let running_engine = engine.clone();
let running_input = input(&base, "aborted", effect.clone());
let running_token = caller_token.clone();
let caller =
tokio::spawn(async move { running_engine.run(running_input, running_token).await });
effect.entered.notified().await;
caller.abort();
assert!(caller.await.unwrap_err().is_cancelled());
effect.cancelled.notified().await;
assert!(
!caller_token.is_cancelled(),
"dropping a run must only cancel its child token"
);
let held = log.prefix(100, 128 * 1024).await.unwrap();
assert!(matches!(
held.events.last().unwrap().event.fact,
Fact::ToolDispatched { .. }
));
assert_eq!(held.project_invocation("invocation-aborted").terminal, None);
assert!(matches!(
engine
.run(input(&base, "blocked", effect.clone()), CancellationToken::new())
.await,
Err(RunError::Busy)
));
assert_eq!(
log.prefix(100, 128 * 1024).await.unwrap().high_water,
held.high_water,
"rejected admission and caller abort must not seal a live effect"
);
effect.release.notify_one();
// The detached worker has no join handle exposed by Engine. Retry
// admission with cooperative yields until its drain has completed.
loop {
match engine
.run(input(&base, "next", effect.clone()), CancellationToken::new())
.await
{
Err(RunError::Busy) => tokio::task::yield_now().await,
result => {
result.unwrap();
break;
}
}
}
let settled = log.prefix(100, 128 * 1024).await.unwrap();
let aborted: Vec<_> = settled
.events
.iter()
.filter(|stored| stored.event.invocation.invocation_id == "invocation-aborted")
.collect();
let boundaries: Vec<_> = aborted
.iter()
.copied()
.filter(|stored| !matches!(stored.event.fact, Fact::ModelObserved { .. }))
.collect();
for observation in aborted
.iter()
.filter(|stored| matches!(stored.event.fact, Fact::ModelObserved { .. }))
{
assert!(
boundaries[1].sequence < observation.sequence
&& observation.sequence < boundaries[2].sequence
);
}
let aborted = boundaries;
assert_eq!(
aborted
.iter()
.map(|stored| stored.event.fact.kind())
.collect::<Vec<_>>().join(","),
"invocation_opened,model_requested,model_completed,tool_dispatched,tool_settled,tool_rejected,invocation_ended"
);
let Fact::ToolDispatched { operation_id, call, .. } = &aborted[3].event.fact else {
unreachable!()
};
let ToolOrigin::Provider { step_id } = &call.origin else {
panic!("direct tool calls must preserve their provider step")
};
assert!(matches!(&aborted[4].event.fact,
Fact::ToolSettled { operation_id: settled, outcome: ToolOutcome::Succeeded { .. } } if settled == operation_id));
assert_eq!(log.resolve_tool_result(&aborted[4].event.invocation.session_id, &aborted[4].event.id).await.unwrap().into_json(), json!({"value":42}));
assert_eq!(aborted[5].event.fact, Fact::ToolRejected {
operation_id: format!("{step_id}:call-2"),
call: ToolCallIdentity::provider(step_id.clone(), "call-2".into()),
name: "echo".into(),
input: json!({"value":43}).into(),
reason: ToolRejection::Cancelled,
});
assert_eq!(
aborted[6].event.fact,
Fact::InvocationEnded {
outcome: InvocationOutcome::Cancelled {
source: "runtime_cancellation".into()
}
}
);
let next_opening = settled
.events
.iter()
.find(|stored| stored.event.invocation.invocation_id == "invocation-next")
.unwrap();
assert!(aborted[6].sequence < next_opening.sequence);
engine.drain().await;
drop(engine);
Arc::try_unwrap(log).ok().unwrap().close().await.unwrap();
}
{
// Reopen to verify durable, usable history without effect replay.
let log = Arc::new(EventLog::open(&path).await.unwrap());
let engine = Engine::new(
log.clone(),
ModelExecutor::new(Duration::from_secs(10)).unwrap(),
CodeExecutor::new(CellLimits::default()).unwrap(),
);
engine
.run(input(&base, "reopened", effect.clone()), CancellationToken::new())
.await
.unwrap();
}
assert_eq!(
effect.count.load(Ordering::SeqCst),
1,
"committed effect must never be replayed"
);
let requests = server.await.unwrap();
for (request, suffix) in requests[1..].iter().zip(["next", "reopened"]) {
let messages = request["messages"].as_array().unwrap();
assert_eq!(
messages.last().unwrap(),
&json!({"role":"user","content":format!("question {suffix}")})
);
let results: Vec<_> = messages
.iter()
.filter(|message| message["role"] == "tool")
.collect();
assert_eq!(results.len(), 2);
assert_eq!(results[0]["tool_call_id"], "call-1");
assert_eq!(results[1]["tool_call_id"], "call-2");
assert!(results[1]["content"].as_str().unwrap().contains(&ToolRejection::Cancelled.to_string()));
assert_eq!(
serde_json::from_str::<Value>(results[0]["content"].as_str().unwrap()).unwrap(),
json!({"value":42})
);
}
})
.await
.expect("caller cancellation and effect draining must make bounded progress");
}