blob: 5af11f2eefa6f58cbce33951fa36b3f5a1604655 [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 std::sync::{
Arc, Mutex,
atomic::{AtomicUsize, Ordering},
};
use maka_agent_facts::event::CommitError;
use maka_agent_facts::event::CommitFuture;
use maka_agent_facts::event::EventSink;
use maka_agent_facts::event::EventWrite;
use maka_agent_facts::event::Fact;
use maka_agent_facts::event::Invocation;
use maka_agent_facts::event::InvocationInput;
use maka_agent_facts::event::LogScope;
use maka_agent_facts::event::RuntimeEvent;
use maka_agent_facts::model::ModelFinishReason;
use maka_agent_facts::model::ModelPart;
use maka_agent_facts::model::ModelStep;
use maka_agent_facts::model::ModelToolCall;
use maka_agent_facts::tool_call::ToolOrigin;
use maka_agent_facts::tools::ToolError;
use maka_agent_facts::tools::ToolExecutor;
use maka_agent_facts::tools::ToolFuture;
use maka_code_runtime::CellLimits;
use maka_event_log::EventLog;
use maka_tools::CodeExecutor;
use maka_tools::{
RunTools, ToolCatalog, ToolDefinition, ToolHandler, ToolMode, ToolNesting, ToolRegistration,
ToolSemantics,
};
use serde_json::{Value, json};
use tokio_util::sync::CancellationToken;
const PARENT: &str = "step:parent";
#[derive(Clone, Copy)]
enum Boundary {
ChildDispatch,
ChildSettlement,
Notification,
}
struct FaultSink {
log: Arc<EventLog>,
boundary: Boundary,
attempts: Mutex<Vec<RuntimeEvent>>,
faults: AtomicUsize,
}
impl EventSink for FaultSink {
fn commit(self: Arc<Self>, write: EventWrite) -> CommitFuture {
Box::pin(async move {
let event = write.event();
self.attempts.lock().unwrap().push(event.clone());
let fail = match (&self.boundary, &event.fact) {
(Boundary::Notification, Fact::ToolNotified { .. }) => true,
(Boundary::ChildDispatch, Fact::ToolDispatched { call, .. }) => {
matches!(call.origin, ToolOrigin::CodeMode { .. })
}
(Boundary::ChildSettlement, Fact::ToolSettled { operation_id, .. }) => {
operation_id != PARENT
}
_ => false,
};
if fail {
self.faults.fetch_add(1, Ordering::SeqCst);
return Err(match self.boundary {
Boundary::ChildDispatch => CommitError::Rejected("injected child T1".into()),
Boundary::ChildSettlement => {
CommitError::OutcomeUnknown("injected child T2".into())
}
Boundary::Notification => {
CommitError::OutcomeUnknown("injected notification commit".into())
}
});
}
self.log.clone().commit(write).await
})
}
}
struct Effect {
calls: Arc<AtomicUsize>,
log: Arc<EventLog>,
}
impl ToolExecutor for Effect {
fn names(&self) -> Vec<String> {
vec!["effect".into()]
}
fn invoke(&self, _: String, input: Value, _: CancellationToken) -> ToolFuture {
let log = self.log.clone();
let calls = self.calls.clone();
Box::pin(async move {
// The real effect is admitted only after its own durable T1.
let prefix = log.prefix(32, 64 * 1024).await.unwrap();
assert!(matches!(prefix.events.last().unwrap().event.fact,
Fact::ToolDispatched { ref call, .. }
if matches!(call.origin, ToolOrigin::CodeMode { .. })));
calls.fetch_add(1, Ordering::SeqCst);
Ok(input)
})
}
}
fn catalog(log: Arc<EventLog>, calls: Arc<AtomicUsize>) -> ToolCatalog {
ToolCatalog::new([ToolRegistration {
definition: ToolDefinition {
freeform: None,
output_schema: None,
provider: None,
name: "effect".into(),
description: "count one effect".into(),
input_schema: json!({"type":"object"}),
},
handler: ToolHandler::Immediate(Arc::new(Effect { calls, log })),
nesting: ToolNesting::Nestable,
semantics: ToolSemantics::Parallel,
}])
.unwrap()
}
async fn seed(log: &EventLog, invocation: &Invocation, call: &ModelToolCall) {
let append = async |fact| {
log.append(&EventWrite::plain(RuntimeEvent::new(invocation.clone(), fact)).unwrap())
.await
.unwrap()
};
append(Fact::InvocationOpened {
configuration: None,
input: InvocationInput::Message {
source_messages: Vec::new(),
content: "execute the fixture".into(),
request_fingerprint: None,
},
})
.await;
let prefix = log
.scoped_prefix(
LogScope::Session {
id: invocation.session_id.clone(),
},
32,
64 * 1024,
)
.await
.unwrap();
append(Fact::ModelRequested {
context_breakdown: None,
item_acceptance: false,
effective_source_digest: None,
step_id: "step".into(),
model_id: "fixture".into(),
source_scope: prefix.scope,
source_high_water: prefix.high_water,
source_digest: prefix.digest,
input_digest: "fixture".into(),
route_identity: "fixture".into(),
checkpoint_event_id: None,
purpose: maka_agent_facts::context::ModelPurpose::Main,
context: None,
})
.await;
append(Fact::ModelCompleted {
step_id: "step".into(),
output: ModelStep {
parts: vec![ModelPart::ToolCall { call: call.clone() }],
finish_reason: ModelFinishReason::ToolCalls,
usage: Default::default(),
provider_options: None,
response_id: None,
model: None,
timestamp: None,
},
})
.await;
}
async fn check_boundary(boundary: Boundary) {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("events.sqlite");
let invocation = Invocation {
session_id: "session".into(),
turn_id: "turn".into(),
run_id: "run".into(),
invocation_id: "invocation".into(),
};
let call = ModelToolCall {
id: "parent".into(),
name: "exec".into(),
input: json!({"code": "try { await tools.effect({}); } catch (_) {} text('caught');"})
.into(),
provider_options: None,
provider_executed: false,
};
let effects = Arc::new(AtomicUsize::new(0));
let expected_effects = usize::from(matches!(boundary, Boundary::ChildSettlement));
let (digest, high_water, mut uncertain) = {
let log = Arc::new(EventLog::open(&path).await.unwrap());
seed(&log, &invocation, &call).await;
let sink = Arc::new(FaultSink {
log: log.clone(),
boundary,
attempts: Mutex::new(Vec::new()),
faults: AtomicUsize::new(0),
});
let run = RunTools::new(
maka_agent_facts::language::Language::En,
sink.clone(),
invocation.clone(),
catalog(log.clone(), effects.clone()),
ToolMode::CodeMode,
CodeExecutor::new(CellLimits::default()).unwrap(),
Default::default(),
);
let result = run
.capture(".", tokio_util::sync::CancellationToken::new())
.await
.unwrap()
.into_step("step")
.invoke(&call, CancellationToken::new())
.await;
assert!(matches!(
run.shutdown().await,
Err(ToolError::Persistence(_))
));
match boundary {
Boundary::Notification => unreachable!("separate notification case"),
Boundary::ChildDispatch => assert!(matches!(result, Err(ToolError::Persistence(_)))),
Boundary::ChildSettlement => {
assert!(matches!(result, Err(ToolError::Persistence(_))))
}
}
assert_eq!(sink.faults.load(Ordering::SeqCst), 1);
assert_eq!(effects.load(Ordering::SeqCst), expected_effects);
let child = {
let attempts = sink.attempts.lock().unwrap();
assert!(
!attempts.iter().any(|event| matches!(&event.fact,
Fact::ToolSettled { operation_id, .. } if operation_id == PARENT)),
"JS catch must not even attempt parent T2 after a fatal child commit error"
);
attempts
.iter()
.find_map(|event| match &event.fact {
Fact::ToolDispatched {
operation_id, call, ..
} if matches!(call.origin, ToolOrigin::CodeMode { .. }) => {
let ToolOrigin::CodeMode { parent_operation_id, .. } = &call.origin else { unreachable!() };
assert!(attempts.iter().any(|event| matches!(&event.fact,
Fact::ToolDispatched {operation_id,call,..} if operation_id==parent_operation_id
&& matches!(&call.origin,ToolOrigin::CodeCell {parent_operation_id,..} if parent_operation_id==PARENT))));
Some(operation_id.clone())
}
_ => None,
})
.expect("real JS invoked the child")
};
let prefix = log.prefix(32, 64 * 1024).await.unwrap();
assert_eq!(prefix.events.len(), 5 + expected_effects);
assert!(
!prefix
.events
.iter()
.any(|stored| matches!(stored.event.fact, Fact::ToolSettled { .. }))
);
let mut uncertain = vec![PARENT.to_string()];
uncertain.extend(
prefix
.events
.iter()
.filter_map(|event| match &event.event.fact {
Fact::ToolDispatched {
operation_id, call, ..
} if matches!(call.origin, ToolOrigin::CodeCell { .. }) => {
Some(operation_id.clone())
}
_ => None,
}),
);
if expected_effects == 1 {
uncertain.push(child);
}
drop(run);
drop(sink);
Arc::try_unwrap(log).ok().unwrap().close().await.unwrap();
(prefix.digest, prefix.high_water, uncertain)
};
uncertain.sort();
// Reopen the actual database with no fault injector. Recovery evidence must
// retain every unresolved dispatch, including the parent when child T1 failed.
let log = Arc::new(EventLog::open(&path).await.unwrap());
let recovery = log
.invocation_recovery(&invocation, 32, 64 * 1024)
.await
.unwrap();
let mut pending = recovery.uncertain_operations;
pending.sort();
assert_eq!(pending, uncertain);
assert!(recovery.undispatched_calls.is_empty());
assert_eq!(
log.unfinished_invocations(32).await.unwrap(),
std::slice::from_ref(&invocation)
);
let prefix = log.prefix(32, 64 * 1024).await.unwrap();
assert_eq!(prefix.digest, digest);
assert_eq!(prefix.high_water, high_water);
assert_eq!(prefix.project_invocation("invocation").terminal, None);
// Even explicitly retrying the same accepted provider call cannot replay
// the cell or its effect: storage refuses a second parent dispatch first.
let run = RunTools::new(
maka_agent_facts::language::Language::En,
log.clone(),
invocation,
catalog(log.clone(), effects.clone()),
ToolMode::CodeMode,
CodeExecutor::new(CellLimits::default()).unwrap(),
Default::default(),
);
assert!(matches!(
run.capture(".", tokio_util::sync::CancellationToken::new())
.await
.unwrap()
.into_step("step")
.invoke(&call, CancellationToken::new())
.await,
Err(ToolError::Persistence(_))
));
assert_eq!(effects.load(Ordering::SeqCst), expected_effects);
assert_eq!(log.prefix(32, 64 * 1024).await.unwrap().digest, digest);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn notification_commit_failure_cancels_the_cell_without_fabricating_success() {
tokio::time::timeout(std::time::Duration::from_secs(5), async {
let directory = tempfile::tempdir().unwrap();
let log = Arc::new(EventLog::open(&directory.path().join("events.sqlite")).await.unwrap());
let invocation = Invocation { session_id:"session".into(), turn_id:"turn".into(), run_id:"run".into(), invocation_id:"invocation".into() };
let call = ModelToolCall { id:"parent".into(), name:"exec".into(), input:json!({"code":"notify('progress'); await new Promise(resolve => setTimeout(resolve, 60000));"}).into(), provider_options:None, provider_executed:false };
seed(&log, &invocation, &call).await;
let sink = Arc::new(FaultSink { log:log.clone(), boundary:Boundary::Notification, attempts:Mutex::new(Vec::new()), faults:AtomicUsize::new(0) });
let run = RunTools::new(maka_agent_facts::language::Language::En, sink.clone(), invocation, ToolCatalog::default(), ToolMode::CodeMode, CodeExecutor::new(CellLimits::default()).unwrap(), Default::default());
let result = run.capture(".", CancellationToken::new()).await.unwrap().into_step("step").invoke(&call, CancellationToken::new()).await;
assert!(matches!(result, Err(ToolError::Persistence(_))));
assert!(matches!(run.shutdown().await, Err(ToolError::Persistence(_))));
assert_eq!(sink.faults.load(Ordering::SeqCst), 1);
assert!(!log.prefix(32, 64*1024).await.unwrap().events.iter().any(|event| matches!(event.event.fact, Fact::ToolSettled { .. } | Fact::ToolNotified { .. })));
drop(run); drop(sink); Arc::try_unwrap(log).ok().unwrap().close().await.unwrap();
}).await.expect("notification persistence failure must drain");
}
#[tokio::test]
async fn rejected_child_t1_runs_no_effect_and_js_catch_cannot_settle_parent() {
check_boundary(Boundary::ChildDispatch).await;
}
#[tokio::test]
async fn uncertain_child_t2_preserves_effect_and_js_catch_cannot_settle_parent() {
check_boundary(Boundary::ChildSettlement).await;
}