blob: ba33178f1a26d835da17eedf1436948f2f4ef36b [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::CellDiagnostic;
use maka_agent_facts::tool_output::ImageOutput;
use maka_agent_facts::tools::ToolError;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
collections::{BTreeMap, VecDeque},
sync::{Arc, Mutex},
};
use tokio::sync::Notify;
/// Only JSON crosses cell boundaries; neither interpreter values nor authority are stored.
#[derive(Clone, Default)]
pub struct CellStore(Arc<Mutex<BTreeMap<String, Value>>>);
/// Synchronous notification admission shared by all cells of a Run. Closing
/// and enqueueing use the same lock; publication remains the Host's job.
#[derive(Clone, Default)]
pub struct NotificationGate(Arc<Mutex<bool>>);
impl NotificationGate {
pub fn close(&self) {
*self.0.lock().unwrap() = true;
}
pub fn close_if_empty<'a>(&self, mut contexts: impl Iterator<Item = &'a CellContext>) -> bool {
let mut closed = self.0.lock().unwrap();
if contexts.any(CellContext::has_notifications) {
return false;
}
*closed = true;
true
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum CellOutput {
Text {
text: String,
},
Image {
image: ImageOutput,
},
Media {
content: maka_agent_facts::capability::ContentBlock,
#[serde(default, skip_serializing_if = "Option::is_none")]
detail: Option<maka_agent_facts::tool_output::ImageDetail>,
},
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ToolMetadata {
pub name: String,
pub description: String,
}
/// A cell's JSON scratch data and emitted output, separate from its interpreter heap.
/// Host observers own delivery; the VM only appends and requests an observation.
#[derive(Clone)]
pub struct CellContext(Arc<Inner>);
struct Inner {
store: CellStore,
state: Mutex<State>,
changed: Notify,
notification_ready: Notify,
metadata: Vec<ToolMetadata>,
notification_gate: NotificationGate,
}
struct State {
values: BTreeMap<String, Value>,
writes: BTreeMap<String, Value>,
output: Vec<CellOutput>,
notifications: VecDeque<String>,
deduplicate_images: bool,
yielded: bool,
failure: Option<ToolError>,
}
impl CellContext {
pub fn new(store: CellStore, metadata: Vec<ToolMetadata>) -> Self {
Self::with_notification_gate(store, metadata, NotificationGate::default())
}
pub fn with_notification_gate(
store: CellStore,
metadata: Vec<ToolMetadata>,
notification_gate: NotificationGate,
) -> Self {
let values = store.0.lock().unwrap().clone();
Self(Arc::new(Inner {
store,
state: Mutex::new(State {
values,
writes: BTreeMap::new(),
output: Vec::new(),
notifications: VecDeque::new(),
deduplicate_images: false,
yielded: false,
failure: None,
}),
changed: Notify::new(),
notification_ready: Notify::new(),
metadata,
notification_gate,
}))
}
pub fn metadata(&self) -> &[ToolMetadata] {
&self.0.metadata
}
/// Observation tools can emit a screenshot automatically and then receive
/// the same image through explicit output. Count it once in this batch;
/// retain no image cache across evaluations or drained batches.
pub fn with_image_deduplication(self) -> Self {
self.0.state.lock().unwrap().deduplicate_images = true;
self
}
/// Host failures are visible before drain, but never erased by user exception handling.
pub fn failure(&self) -> Option<ToolError> {
self.0.state.lock().unwrap().failure.clone()
}
pub fn fail(&self, error: ToolError) {
self.0.state.lock().unwrap().failure.get_or_insert(error);
self.yield_output();
}
pub fn load(&self, key: &str) -> Option<Value> {
self.0.state.lock().unwrap().values.get(key).cloned()
}
pub fn store(&self, key: String, value: Value) -> Result<(), CellDiagnostic> {
let mut state = self.0.state.lock().unwrap();
state.values.insert(key.clone(), value.clone());
state.writes.insert(key, value);
Ok(())
}
/// Called only after the cell's effects and canonical completion settle.
pub fn commit(&self) -> Result<(), CellDiagnostic> {
let state = self.0.state.lock().unwrap();
let mut store = self.0.store.0.lock().unwrap();
let mut next = store.clone();
next.extend(state.writes.clone());
*store = next;
Ok(())
}
pub fn emit(&self, output: CellOutput) -> Result<(), CellDiagnostic> {
if let CellOutput::Media { content, .. } = &output
&& !matches!(
content,
maka_agent_facts::capability::ContentBlock::Image { .. }
| maka_agent_facts::capability::ContentBlock::Audio { .. }
)
{
return Err(CellDiagnostic::new(
crate::CellDiagnosticKind::ExecutionError,
"expected image or audio content",
));
}
let mut state = self.0.state.lock().unwrap();
if state.deduplicate_images
&& let CellOutput::Media {
content: image @ maka_agent_facts::capability::ContentBlock::Image { .. },
detail,
} = &output
&& state
.output
.iter()
.any(|prior| matches!(prior, CellOutput::Media { content, detail: previous } if content == image && previous == detail))
{
return Ok(());
}
state.output.push(output);
Ok(())
}
pub fn yield_output(&self) {
self.0.state.lock().unwrap().yielded = true;
self.0.changed.notify_one();
}
pub async fn yielded(&self) {
loop {
let changed = self.0.changed.notified();
if self.0.state.lock().unwrap().yielded {
return;
}
changed.await;
}
}
pub fn take_output(&self) -> Vec<CellOutput> {
let mut state = self.0.state.lock().unwrap();
state.yielded = false;
std::mem::take(&mut state.output)
}
pub fn notify(&self, text: String) -> Result<(), CellDiagnostic> {
let closed = self.0.notification_gate.0.lock().unwrap();
if *closed {
return Err(CellDiagnostic::new(
crate::CellDiagnosticKind::ExecutionError,
"notification Run is closed",
));
}
if text.trim().is_empty() {
return Err(CellDiagnostic::new(
crate::CellDiagnosticKind::ExecutionError,
"notify expects non-empty text",
));
}
let mut state = self.0.state.lock().unwrap();
state.notifications.push_back(text);
self.0.notification_ready.notify_one();
Ok(())
}
pub async fn notified(&self) {
loop {
let ready = self.0.notification_ready.notified();
if !self.0.state.lock().unwrap().notifications.is_empty() {
return;
}
ready.await;
}
}
pub fn take_notifications(&self) -> VecDeque<String> {
std::mem::take(&mut self.0.state.lock().unwrap().notifications)
}
pub fn has_notifications(&self) -> bool {
!self.0.state.lock().unwrap().notifications.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn observation_image_deduplication_precedes_budget_and_does_not_cross_batches() {
let image = CellOutput::Media {
content: maka_agent_facts::capability::ContentBlock::Image {
data: "a".repeat(2048),
mime_type: "image/png".into(),
},
detail: None,
};
let context = CellContext::new(CellStore::default(), vec![]).with_image_deduplication();
context.emit(image.clone()).unwrap();
context.emit(image.clone()).unwrap();
assert_eq!(context.take_output().len(), 1);
// A drained batch does not suppress a new observation.
context.emit(image.clone()).unwrap();
assert_eq!(context.take_output().len(), 1);
let next = CellContext::new(CellStore::default(), vec![]).with_image_deduplication();
next.emit(image.clone()).unwrap();
assert_eq!(next.take_output().len(), 1);
let ordinary = CellContext::new(CellStore::default(), vec![]);
ordinary.emit(image.clone()).unwrap();
ordinary.emit(image).unwrap();
assert_eq!(ordinary.take_output().len(), 2);
}
#[test]
fn notification_acceptance_and_empty_queue_closure_are_atomic() {
let gate = NotificationGate::default();
let context =
CellContext::with_notification_gate(CellStore::default(), vec![], gate.clone());
context.notify("accepted".into()).unwrap();
assert!(!gate.close_if_empty(std::iter::once(&context)));
assert_eq!(context.take_notifications(), ["accepted".to_string()]);
let empty_checked = std::sync::Barrier::new(2);
let close = std::sync::Barrier::new(2);
std::thread::scope(|scope| {
let closing = scope.spawn(|| {
gate.close_if_empty(std::iter::once(&context).chain(std::iter::from_fn(|| {
empty_checked.wait();
close.wait();
None
})))
});
empty_checked.wait();
let late = scope.spawn(|| context.notify("after the empty check".into()));
close.wait();
assert!(closing.join().unwrap());
assert!(late.join().unwrap().is_err());
});
assert!(context.take_notifications().is_empty());
}
}