blob: 6337876f45eabc80c88e9f9501625b4f063b5ebf [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::preflight as support;
use maka_agent_facts::event::Fact;
use maka_agent_facts::event::ToolOutcome;
use maka_agent_facts::model::ToolInput;
use maka_agent_facts::tool_call::ToolRejection;
use maka_agent_facts::tools::ToolError;
use maka_code_runtime::CellLimits;
use maka_event_log::EventLog;
use maka_tools::CodeExecutor;
use maka_tools::{RunTools, ToolMode};
use serde_json::json;
use std::sync::{Arc, atomic::Ordering};
use support::*;
use tokio_util::sync::CancellationToken;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn provider_preflight_rejects_before_t1_and_exclusivity_follows_call_order() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("events.sqlite");
let log = Arc::new(EventLog::open(&path).await.unwrap());
let effect = Arc::new(Effect::default());
let cells = CodeExecutor::new(CellLimits::default()).unwrap();
let cases = [
(
"finish",
ToolMode::CodeMode,
vec![
(call("finish", "direct", json!({"n": 1})), None),
(
call("after", "exec", json!({"code": "text(4)"})),
Some(ToolRejection::ExclusiveConflict),
),
],
),
(
"direct",
ToolMode::Direct,
vec![
(
call("exec", "exec", json!({"code":"text(1)"})),
Some(ToolRejection::Unavailable),
),
(call("ok", "echo", json!({"n":1})), None),
],
),
(
"parallel-first",
ToolMode::Direct,
vec![
(
call("hidden", "withheld", json!({"n":1})),
Some(ToolRejection::Unavailable),
),
(
call("bad", "echo", json!({"n":"1"})),
Some(ToolRejection::InvalidInput {
message: "arguments do not match the declared schema".into(),
}),
),
(call("ok", "echo", json!({"n":2})), None),
(
call("exclusive", "direct", json!({"n":1})),
Some(ToolRejection::ExclusiveConflict),
),
],
),
(
"exclusive-first",
ToolMode::CodeMode,
vec![
(call("exec", "exec", json!({"code":"text(3)"})), None),
(
call("echo", "echo", json!({"n":1})),
Some(ToolRejection::Unavailable),
),
(
call("again", "exec", json!({"code":"text(4)"})),
Some(ToolRejection::ExclusiveConflict),
),
],
),
(
"malformed-direct",
ToolMode::Direct,
vec![
(
call("malformed", "echo", ToolInput::from_json("{\"n\":".into())),
Some(ToolRejection::InvalidInput {
message: "arguments are not valid JSON".into(),
}),
),
(call("corrected", "echo", json!({"n":7})), None),
],
),
(
"malformed-exclusive",
ToolMode::CodeMode,
vec![
(
call("exec", "exec", ToolInput::from_json("{\"code\":".into())),
Some(ToolRejection::InvalidInput {
message: "arguments are not valid JSON".into(),
}),
),
(
call("again", "exec", json!({"code":"text(4)"})),
Some(ToolRejection::ExclusiveConflict),
),
],
),
(
"invalid-exclusive",
ToolMode::CodeMode,
vec![
(
call("exec", "exec", json!({"code":1})),
Some(ToolRejection::InvalidInput {
message: "invalid type: integer `1`, expected a string".into(),
}),
),
(
call("again", "exec", json!({"code":"text(4)"})),
Some(ToolRejection::ExclusiveConflict),
),
],
),
(
"cancelled",
ToolMode::CodeMode,
vec![
(
call(
"malformed",
"exec",
ToolInput::from_json("{\"code\":".into()),
),
Some(ToolRejection::Cancelled),
),
(
call("echo", "echo", json!({"n":1})),
Some(ToolRejection::Cancelled),
),
(
call("exec", "exec", json!({"code":"text(4)"})),
Some(ToolRejection::Cancelled),
),
],
),
];
for (id, mode, expectations) in cases {
let invocation = invocation(id);
accepted(
&log,
&invocation,
&expectations
.iter()
.map(|(c, _)| c.clone())
.collect::<Vec<_>>(),
)
.await;
let run = RunTools::new(
maka_agent_facts::language::Language::En,
log.clone(),
invocation.clone(),
catalog(effect.clone()),
mode,
cells.clone(),
Default::default(),
);
let request = run
.capture(".", tokio_util::sync::CancellationToken::new())
.await
.unwrap();
let names: Vec<_> = request.definitions().into_iter().map(|d| d.name).collect();
assert_eq!(
names,
if mode == ToolMode::Direct {
vec!["direct", "echo"]
} else {
vec!["exec", "wait", "direct"]
}
);
let token = CancellationToken::new();
if id == "cancelled" {
token.cancel();
}
let step_id = &invocation.invocation_id;
let mut step = request.into_step(step_id);
for (call, reason) in expectations {
let result = step.invoke(&call, token.clone()).await;
let prefix = log.prefix(200, 1024 * 1024).await.unwrap();
let facts: Vec<_> = prefix
.events
.iter()
.filter(|event| event.event.invocation == invocation)
.map(|event| &event.event.fact)
.collect();
if let Some(reason) = reason {
assert!(
matches!(result, Err(ToolError::Failed(_))),
"{id}: {result:?}"
);
assert!(matches!(facts.last().unwrap(),
Fact::ToolRejected { operation_id, call: identity, input, reason: actual, .. }
if input == &call.input && operation_id == &format!("{step_id}:{}", call.id)
&& identity.tool_call_id == call.id && actual == &reason));
assert!(!facts.iter().any(|fact| matches!(fact,
Fact::ToolDispatched { operation_id, .. } | Fact::ToolSettled { operation_id, .. }
if operation_id == &format!("{step_id}:{}", call.id))));
} else {
let value = result.unwrap();
let expected = if call.name == "exec" {
json!({"ok":true,"value":null,"toolCalls":[]})
} else {
call.input.as_json().unwrap().clone()
};
if call.name == "exec" {
assert_eq!(value["state"], "completed");
assert_eq!(value["result"], expected);
} else {
assert_eq!(value, expected);
}
assert!(matches!(
facts.last().unwrap(),
Fact::ToolSettled {
outcome: ToolOutcome::Succeeded { .. },
..
}
));
let event = &prefix.events.last().unwrap().event;
assert_eq!(
log.resolve_tool_result(&invocation.session_id, &event.id)
.await
.unwrap()
.into_json(),
value
);
}
}
assert_eq!(step.finished(), id == "finish");
assert!(
log.invocation_recovery(&invocation, 32, 128 * 1024)
.await
.unwrap()
.uncertain_operations
.is_empty()
);
}
assert_eq!(effect.0.load(Ordering::SeqCst), 4);
let before = log.prefix(200, 1024 * 1024).await.unwrap();
close(log).await;
let reopened = EventLog::open(&path).await.unwrap();
assert_eq!(
serde_json::to_value(reopened.prefix(200, 1024 * 1024).await.unwrap()).unwrap(),
serde_json::to_value(before).unwrap()
);
reopened.close().await.unwrap();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn nested_preflight_is_effect_free_and_diagnostics_are_successful_parent_values() {
let directory = tempfile::tempdir().unwrap();
let log = Arc::new(
EventLog::open(&directory.path().join("events.sqlite"))
.await
.unwrap(),
);
let effect = Arc::new(Effect::default());
let cells = CodeExecutor::new(CellLimits::default()).unwrap();
for (id, code, kind, calls) in [
(
"direct-only",
"await tools.direct({n:1})",
"execution_error",
json!([]),
),
(
"invalid-input",
"await tools.echo({n:'1'})",
"tool_failure",
json!([{"index":1,"name":"echo"}]),
),
("parse", "return )", "parse_error", json!([])),
] {
let invocation = invocation(id);
let call = call("exec", "exec", json!({"code":code}));
accepted(&log, &invocation, std::slice::from_ref(&call)).await;
let run = RunTools::new(
maka_agent_facts::language::Language::En,
log.clone(),
invocation.clone(),
catalog(effect.clone()),
ToolMode::CodeMode,
cells.clone(),
Default::default(),
);
let value = run
.capture(".", tokio_util::sync::CancellationToken::new())
.await
.unwrap()
.into_step(&invocation.invocation_id)
.invoke(&call, CancellationToken::new())
.await
.unwrap();
assert_eq!(value["state"], "completed");
assert_eq!(value["result"]["ok"], false);
assert_eq!(value["result"]["error"]["kind"], kind);
assert_eq!(value["result"]["toolCalls"], calls);
assert!(value["result"].get("value").is_none());
let prefix = log.prefix(200, 1024 * 1024).await.unwrap();
let effects: Vec<_> = prefix
.events
.iter()
.filter(|event| event.event.invocation == invocation)
.filter_map(|event| match &event.event.fact {
Fact::ToolDispatched { operation_id, .. } => Some(operation_id.as_str()),
_ => None,
})
.collect();
assert_eq!(
effects,
[
format!("{}:exec", invocation.invocation_id),
value["cell_id"].as_str().unwrap().to_string()
],
"invalid child arguments must not claim T1"
);
assert!(matches!(
&prefix.events.last().unwrap().event.fact,
Fact::ToolSettled {
outcome: ToolOutcome::Succeeded { .. },
..
}
));
assert_eq!(
log.resolve_tool_result(
&invocation.session_id,
&prefix.events.last().unwrap().event.id
)
.await
.unwrap()
.into_json(),
value
);
}
assert_eq!(effect.0.load(Ordering::SeqCst), 0);
close(log).await;
}