Add operator Mentra memory browser
Some checks failed
CI / rust-skia (Rust only) (push) Has been cancelled
CI / required (push) Has been cancelled

This commit is contained in:
2026-08-23 19:18:41 +02:00
parent 95cca3e777
commit 360203d647
190 changed files with 70246 additions and 45 deletions

2
Cargo.lock generated
View File

@@ -4648,8 +4648,6 @@ dependencies = [
[[package]]
name = "mentra"
version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94ab26ca4cd71f5c8329d5d1ed122f1de3ce75d2331702466ce0e1adc41b6c3b"
dependencies = [
"async-trait",
"base64",

View File

@@ -27,7 +27,7 @@ members = [
"tools/concurrency-audit",
"tools/performance",
]
exclude = ["crates/libremetaverse-openjpeg", "vendor/mentra-provider"]
exclude = ["crates/libremetaverse-openjpeg", "vendor/mentra", "vendor/mentra-provider"]
default-members = [
"crates/metacrate",
"crates/libremetaverse-types",
@@ -86,3 +86,6 @@ incremental = false
[patch.crates-io]
# Mentra 0.5.1 duplicates `v1` for nested OpenAI-compatible base URLs.
mentra-provider = { path = "vendor/mentra-provider" }
# Mentra 0.18.3 lacks the host-facing bounded memory browse API required by
# the grid-agent operator control plane. Keep the storage schema inside Mentra.
mentra = { path = "vendor/mentra" }

View File

@@ -25,6 +25,7 @@ const MAX_TOKEN_BYTES: usize = 16 * 1024;
const MAX_OPERATOR_MESSAGE_BYTES: usize = 16 * 1024;
const MAX_PAGE_SIZE: u16 = 100;
const MAX_EVENT_BYTES: usize = 4 * 1024;
const MAX_MEMORY_QUERY_BYTES: usize = 1_024;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -248,6 +249,81 @@ pub struct AuditEventView {
pub authorization_id: Option<u64>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemoryKindView {
Episode,
Summary,
Fact,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemorySortView {
#[default]
Newest,
Oldest,
Relevance,
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MemoryFilterView {
pub kind: Option<MemoryKindView>,
pub pinned: Option<bool>,
pub source: Option<String>,
pub created_from: Option<i64>,
pub created_to: Option<i64>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct MemoryCursorView {
pub created_at: i64,
pub record_id: String,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct MemoryHealthView {
pub agent_count: usize,
pub record_count: usize,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct MemoryAgentView {
pub avatar_id: String,
pub logical_agent_id: String,
pub mentra_agent_id: String,
pub record_count: usize,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MemoryRecordView {
pub record_id: String,
pub kind: MemoryKindView,
pub preview: String,
pub source: Option<String>,
pub source_revision: u64,
pub pinned: bool,
pub created_at: i64,
pub score: Option<f64>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MemoryPageView {
pub items: Vec<MemoryRecordView>,
pub next_cursor: Option<MemoryCursorView>,
pub next_offset: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MemoryDetailView {
pub record: MemoryRecordView,
pub content: String,
pub metadata_json: String,
pub content_truncated: bool,
pub metadata_truncated: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
pub enum ControlPayload {
@@ -259,6 +335,10 @@ pub enum ControlPayload {
PendingApprovals(Page<PendingApprovalView>),
AuditEvents(Page<AuditEventView>),
ObservabilityEvents(Page<StructuredEvent>),
MemoryHealth(MemoryHealthView),
MemoryAgents(Page<MemoryAgentView>),
Memories(MemoryPageView),
MemoryDetail(MemoryDetailView),
Accepted { operation_id: String },
Completed,
Subscribed { current_sequence: u64 },
@@ -293,6 +373,32 @@ pub enum ControlRequest {
ListObservabilityEvents {
page: PageRequest,
},
MemoryHealth,
ListMemoryAgents {
page: PageRequest,
},
BrowseMemories {
mentra_agent_id: String,
cursor: Option<MemoryCursorView>,
limit: u16,
filter: MemoryFilterView,
sort: MemorySortView,
},
SearchMemories {
mentra_agent_id: String,
query: String,
page: PageRequest,
filter: MemoryFilterView,
sort: MemorySortView,
},
GetMemory {
mentra_agent_id: String,
record_id: String,
},
ForgetMemory {
mentra_agent_id: String,
record_id: String,
},
SubscribeEvents {
after_sequence: Option<u64>,
},
@@ -347,6 +453,11 @@ impl ControlRequest {
| Self::ListPendingApprovals { .. }
| Self::ListAuditEvents { .. }
| Self::ListObservabilityEvents { .. }
| Self::MemoryHealth
| Self::ListMemoryAgents { .. }
| Self::BrowseMemories { .. }
| Self::SearchMemories { .. }
| Self::GetMemory { .. }
| Self::SubscribeEvents { .. }
)
}
@@ -357,7 +468,44 @@ impl ControlRequest {
| Self::ListScheduledJobs { page }
| Self::ListPendingApprovals { page }
| Self::ListAuditEvents { page }
| Self::ListObservabilityEvents { page } => page.valid(),
| Self::ListObservabilityEvents { page }
| Self::ListMemoryAgents { page } => page.valid(),
Self::BrowseMemories {
mentra_agent_id,
cursor,
limit,
filter,
sort,
} => {
valid_identifier(mentra_agent_id)
&& (1..=MAX_PAGE_SIZE).contains(limit)
&& *sort != MemorySortView::Relevance
&& cursor
.as_ref()
.is_none_or(|cursor| valid_identifier(&cursor.record_id))
&& memory_filter_valid(filter)
}
Self::SearchMemories {
mentra_agent_id,
query,
page,
filter,
..
} => {
valid_identifier(mentra_agent_id)
&& query.len() <= MAX_MEMORY_QUERY_BYTES
&& !query.contains('\0')
&& page.valid()
&& memory_filter_valid(filter)
}
Self::GetMemory {
mentra_agent_id,
record_id,
}
| Self::ForgetMemory {
mentra_agent_id,
record_id,
} => valid_identifier(mentra_agent_id) && valid_identifier(record_id),
Self::CancelRequest { target_request_id } => valid_identifier(target_request_id),
Self::CancelAction { action_id } => valid_identifier(action_id),
Self::DecideApproval { approval_id, .. } => *approval_id != 0,
@@ -1726,12 +1874,25 @@ fn valid_uuid_text(value: &str) -> bool {
libremetaverse_types::UUID::parse(value.to_owned()).is_ok()
}
fn memory_filter_valid(filter: &MemoryFilterView) -> bool {
filter
.source
.as_ref()
.is_none_or(|source| !source.is_empty() && source.len() <= 256 && !source.contains('\0'))
&& match (filter.created_from, filter.created_to) {
(Some(from), Some(to)) => from <= to,
_ => true,
}
}
fn payload_valid(payload: &ControlPayload, maximum_frame_bytes: usize) -> bool {
let page_is_bounded = match payload {
ControlPayload::Sessions(page) => page.items.len() <= usize::from(MAX_PAGE_SIZE),
ControlPayload::ScheduledJobs(page) => page.items.len() <= usize::from(MAX_PAGE_SIZE),
ControlPayload::PendingApprovals(page) => page.items.len() <= usize::from(MAX_PAGE_SIZE),
ControlPayload::AuditEvents(page) => page.items.len() <= usize::from(MAX_PAGE_SIZE),
ControlPayload::MemoryAgents(page) => page.items.len() <= usize::from(MAX_PAGE_SIZE),
ControlPayload::Memories(page) => page.items.len() <= usize::from(MAX_PAGE_SIZE),
_ => true,
};
page_is_bounded
@@ -1771,6 +1932,12 @@ fn request_name(request: &ControlRequest) -> &'static str {
ControlRequest::ListPendingApprovals { .. } => "list_pending_approvals",
ControlRequest::ListAuditEvents { .. } => "list_audit_events",
ControlRequest::ListObservabilityEvents { .. } => "list_observability_events",
ControlRequest::MemoryHealth => "memory_health",
ControlRequest::ListMemoryAgents { .. } => "list_memory_agents",
ControlRequest::BrowseMemories { .. } => "browse_memories",
ControlRequest::SearchMemories { .. } => "search_memories",
ControlRequest::GetMemory { .. } => "get_memory",
ControlRequest::ForgetMemory { .. } => "forget_memory",
ControlRequest::SubscribeEvents { .. } => "subscribe_events",
ControlRequest::CancelRequest { .. } => "cancel_request",
ControlRequest::PauseAutonomy => "pause_autonomy",

View File

@@ -6,8 +6,9 @@ use crate::behavior::{BehaviorIngress, BehaviorMode};
use crate::control_plane::{
AuditEventView, CONTROL_PROTOCOL_VERSION, ControlContext, ControlError, ControlErrorCode,
ControlFuture, ControlPayload, ControlRequest, ControlTarget, ConversationChannelView,
HealthView, Page, PageRequest, PendingApprovalView, RuntimeView, ScheduledJobView,
SessionMetadataView,
HealthView, MemoryAgentView, MemoryCursorView, MemoryDetailView, MemoryFilterView,
MemoryHealthView, MemoryKindView, MemoryPageView, MemoryRecordView, MemorySortView, Page,
PageRequest, PendingApprovalView, RuntimeView, ScheduledJobView, SessionMetadataView,
};
use crate::conversation::{ConversationChannel, ConversationKey, ConversationStore};
use crate::observability::{
@@ -88,6 +89,7 @@ pub struct AgentControlTarget {
observability: Mutex<Option<Arc<Observability>>>,
build: Mutex<Option<Arc<dyn crate::build::BuildControl>>>,
vision: Mutex<Option<Arc<dyn crate::vision::VisionControl>>>,
memory: Mutex<Option<Arc<crate::memory_admin::MentraMemoryAdmin>>>,
behavior: BehaviorIngress,
commands: mpsc::Sender<RuntimeControlCommand>,
command_capacity: usize,
@@ -133,6 +135,7 @@ impl AgentControlTarget {
observability: Mutex::new(None),
build: Mutex::new(None),
vision: Mutex::new(None),
memory: Mutex::new(None),
behavior,
commands,
command_capacity,
@@ -158,6 +161,10 @@ impl AgentControlTarget {
*lock(&self.vision) = Some(vision);
}
pub fn attach_memory_control(&self, memory: Arc<crate::memory_admin::MentraMemoryAdmin>) {
*lock(&self.memory) = Some(memory);
}
pub fn update_session(&self, session: SessionStatus) {
let mut state = lock(&self.state);
state.session = session;
@@ -362,6 +369,156 @@ impl AgentControlTarget {
&page, values,
)))
}
ControlRequest::MemoryHealth => {
let memory = memory_admin(&self.memory)?;
let (agent_count, record_count) = memory.total_counts().map_err(memory_error)?;
Ok(ControlPayload::MemoryHealth(MemoryHealthView {
agent_count,
record_count,
}))
}
ControlRequest::ListMemoryAgents { page } => {
require_operator(context)?;
let values = memory_admin(&self.memory)?
.agents()
.map_err(memory_error)?
.into_iter()
.map(|(mapping, record_count)| MemoryAgentView {
avatar_id: mapping.avatar_id,
logical_agent_id: mapping.logical_agent_id,
mentra_agent_id: mapping.mentra_agent_id,
record_count,
})
.collect();
Ok(ControlPayload::MemoryAgents(page_values(&page, values)))
}
ControlRequest::BrowseMemories {
mentra_agent_id,
cursor,
limit,
filter,
sort,
} => {
require_operator(context)?;
let (records, next_cursor) = memory_admin(&self.memory)?
.browse(
&mentra_agent_id,
cursor.map(|cursor| mentra::memory::MemoryListCursor {
created_at: cursor.created_at,
record_id: cursor.record_id,
}),
usize::from(limit),
memory_filter(filter),
memory_sort(sort)?,
)
.map_err(memory_error)?;
Ok(ControlPayload::Memories(MemoryPageView {
items: records.into_iter().map(memory_record_view).collect(),
next_cursor: next_cursor.map(|cursor| MemoryCursorView {
created_at: cursor.created_at,
record_id: cursor.record_id,
}),
next_offset: None,
}))
}
ControlRequest::SearchMemories {
mentra_agent_id,
query,
page,
filter,
sort,
} => {
require_operator(context)?;
let start = usize::try_from(page.cursor.unwrap_or(0)).unwrap_or(usize::MAX);
let requested = start
.saturating_add(usize::from(page.limit))
.saturating_add(1);
let memory = memory_admin(&self.memory)?;
let mut records = if query.trim().is_empty() {
memory
.browse(
&mentra_agent_id,
None,
requested.min(500),
memory_filter(filter.clone()),
match sort {
MemorySortView::Oldest => mentra::memory::MemoryListSort::Oldest,
_ => mentra::memory::MemoryListSort::Newest,
},
)
.map(|(records, _)| records)
} else {
memory.search(
&mentra_agent_id,
&query,
requested.min(500),
memory_filter(filter.clone()),
)
}
.map_err(memory_error)?;
records.retain(|record| record_matches(record, &filter));
match sort {
MemorySortView::Newest => records.sort_by(|a, b| {
(b.created_at, &b.record_id).cmp(&(a.created_at, &a.record_id))
}),
MemorySortView::Oldest => records.sort_by(|a, b| {
(a.created_at, &a.record_id).cmp(&(b.created_at, &b.record_id))
}),
MemorySortView::Relevance => {}
}
let has_more = records.len() > start.saturating_add(usize::from(page.limit));
let items = records
.into_iter()
.skip(start)
.take(usize::from(page.limit))
.map(memory_record_view)
.collect();
Ok(ControlPayload::Memories(MemoryPageView {
items,
next_cursor: None,
next_offset: has_more.then(|| {
u64::try_from(start.saturating_add(usize::from(page.limit)))
.unwrap_or(u64::MAX)
}),
}))
}
ControlRequest::GetMemory {
mentra_agent_id,
record_id,
} => {
require_operator(context)?;
let record = memory_admin(&self.memory)?
.detail(&mentra_agent_id, &record_id)
.map_err(memory_error)?
.ok_or_else(|| {
control_error(ControlErrorCode::NotFound, "memory record not found", false)
})?;
Ok(ControlPayload::MemoryDetail(MemoryDetailView {
record: memory_record_view(record.clone()),
content: truncate_utf8(&record.content, 24 * 1024),
metadata_json: truncate_utf8(&record.metadata_json, 8 * 1024),
content_truncated: record.content.len() > 24 * 1024,
metadata_truncated: record.metadata_json.len() > 8 * 1024,
}))
}
ControlRequest::ForgetMemory {
mentra_agent_id,
record_id,
} => {
require_operator(context)?;
if memory_admin(&self.memory)?
.forget(&mentra_agent_id, &record_id)
.map_err(memory_error)?
{
Ok(ControlPayload::Completed)
} else {
Err(control_error(
ControlErrorCode::NotFound,
"memory record not found",
false,
))
}
}
ControlRequest::PauseAutonomy => {
let response = self.enqueue("pause", RuntimeControlCommand::Pause)?;
self.behavior.pause();
@@ -639,6 +796,25 @@ fn runtime_domain_event(
.result_code("completed")?
.code_field("decision", if *approve { "approved" } else { "denied" })?,
),
ControlRequest::ForgetMemory {
mentra_agent_id,
record_id,
} => Some(
EventDraft::new(
EventFamily::ControlCommand,
EventSeverity::Info,
"memory",
EventOrigin::Control,
)?
.correlation(CorrelationIds {
session_id: Some(pseudonymous_identifier(mentra_agent_id)),
action_id: Some(pseudonymous_identifier(record_id)),
..CorrelationIds::default()
})?
.result_code("completed")?
.code_field("operation", "forget_memory")?
.redacted("memory_content")?,
),
ControlRequest::GracefulShutdown => Some(
EventDraft::new(
EventFamily::Shutdown,
@@ -659,6 +835,7 @@ fn request_correlation(request: &ControlRequest) -> CorrelationIds {
ControlRequest::DecideApproval { approval_id, .. } => {
Some(format!("approval-{approval_id}"))
}
ControlRequest::ForgetMemory { record_id, .. } => Some(record_id.clone()),
_ => None,
};
CorrelationIds {
@@ -677,6 +854,12 @@ const fn runtime_request_name(request: &ControlRequest) -> &'static str {
ControlRequest::ListPendingApprovals { .. } => "list_pending_approvals",
ControlRequest::ListAuditEvents { .. } => "list_audit_events",
ControlRequest::ListObservabilityEvents { .. } => "list_observability_events",
ControlRequest::MemoryHealth => "memory_health",
ControlRequest::ListMemoryAgents { .. } => "list_memory_agents",
ControlRequest::BrowseMemories { .. } => "browse_memories",
ControlRequest::SearchMemories { .. } => "search_memories",
ControlRequest::GetMemory { .. } => "get_memory",
ControlRequest::ForgetMemory { .. } => "forget_memory",
ControlRequest::SubscribeEvents { .. } => "subscribe_events",
ControlRequest::CancelRequest { .. } => "cancel_request",
ControlRequest::PauseAutonomy => "pause_autonomy",
@@ -753,6 +936,111 @@ const fn policy_outcome_name(outcome: PolicyFinalOutcome) -> &'static str {
}
}
fn memory_admin(
slot: &Mutex<Option<Arc<crate::memory_admin::MentraMemoryAdmin>>>,
) -> Result<Arc<crate::memory_admin::MentraMemoryAdmin>, ControlError> {
lock(slot).clone().ok_or_else(|| {
control_error(
ControlErrorCode::Conflict,
"memory service is not available",
true,
)
})
}
fn require_operator(context: &ControlContext) -> Result<(), ControlError> {
(context.role == crate::control_plane::ControlRole::Operator)
.then_some(())
.ok_or_else(|| {
control_error(
ControlErrorCode::PermissionDenied,
"memory content requires operator role",
false,
)
})
}
fn memory_filter(filter: MemoryFilterView) -> mentra::memory::MemoryListFilter {
mentra::memory::MemoryListFilter {
kind: filter.kind.map(|kind| match kind {
MemoryKindView::Episode => mentra::memory::MemoryRecordKind::Episode,
MemoryKindView::Summary => mentra::memory::MemoryRecordKind::Summary,
MemoryKindView::Fact => mentra::memory::MemoryRecordKind::Fact,
}),
pinned: filter.pinned,
source: filter.source,
created_from: filter.created_from,
created_to: filter.created_to,
}
}
fn memory_sort(sort: MemorySortView) -> Result<mentra::memory::MemoryListSort, ControlError> {
match sort {
MemorySortView::Newest => Ok(mentra::memory::MemoryListSort::Newest),
MemorySortView::Oldest => Ok(mentra::memory::MemoryListSort::Oldest),
MemorySortView::Relevance => Err(control_error(
ControlErrorCode::InvalidRequest,
"relevance sort requires a search query",
false,
)),
}
}
fn record_matches(record: &mentra::memory::MemoryRecord, filter: &MemoryFilterView) -> bool {
filter.kind.is_none_or(|kind| {
matches!(
(kind, record.kind),
(
MemoryKindView::Episode,
mentra::memory::MemoryRecordKind::Episode
) | (
MemoryKindView::Summary,
mentra::memory::MemoryRecordKind::Summary
) | (MemoryKindView::Fact, mentra::memory::MemoryRecordKind::Fact)
)
}) && filter.pinned.is_none_or(|pinned| record.pinned == pinned)
&& filter
.source
.as_ref()
.is_none_or(|source| record.source.as_ref() == Some(source))
&& filter
.created_from
.is_none_or(|from| record.created_at >= from)
&& filter.created_to.is_none_or(|to| record.created_at <= to)
}
fn memory_record_view(record: mentra::memory::MemoryRecord) -> MemoryRecordView {
MemoryRecordView {
record_id: record.record_id,
kind: match record.kind {
mentra::memory::MemoryRecordKind::Episode => MemoryKindView::Episode,
mentra::memory::MemoryRecordKind::Summary => MemoryKindView::Summary,
mentra::memory::MemoryRecordKind::Fact => MemoryKindView::Fact,
},
preview: truncate_utf8(&record.content, 160),
source: record.source.map(|source| truncate_utf8(&source, 128)),
source_revision: record.source_revision,
pinned: record.pinned,
created_at: record.created_at,
score: record.score,
}
}
fn truncate_utf8(value: &str, maximum: usize) -> String {
if value.len() <= maximum {
return value.to_owned();
}
let mut end = maximum;
while !value.is_char_boundary(end) {
end -= 1;
}
value[..end].to_owned()
}
fn memory_error(_: String) -> ControlError {
control_error(ControlErrorCode::Internal, "memory operation failed", false)
}
fn unix_seconds() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)

View File

@@ -1,7 +1,7 @@
use crate::behavior::{BehaviorController, BehaviorError, EmbodimentFuture, EmbodimentSink};
use crate::control_plane::{
CONTROL_PROTOCOL_VERSION, ControlLimits, ControlPayload, ControlPlane, ControlRequest,
ControlRequestEnvelope, PageRequest,
ControlRequestEnvelope, MemoryFilterView, MemorySortView, PageRequest,
};
use crate::control_runtime::{AgentControlTarget, RuntimeControlCommand};
use crate::conversation::{ConversationLimits, ConversationStore};
@@ -12,6 +12,7 @@ use crate::{
};
use libremetaverse_types::UUID;
use libremetaverse_types::compat::CancellationToken;
use mentra::memory::{MemoryRecord, MemoryRecordKind, MemoryStore};
use std::collections::BTreeSet;
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -152,6 +153,31 @@ async fn production_target_projects_state_and_routes_real_mutations() {
target.attach_observability(observability);
let vision = Arc::new(FakeVision(Mutex::new(Some("visual-1".into()))));
target.attach_vision_control(vision.clone());
let memory_root =
std::env::temp_dir().join(format!("metacrate-control-memory-{}", std::process::id()));
let memory_store = mentra::runtime::HybridRuntimeStore::new(memory_root.join("runtime.sqlite"));
memory_store
.upsert_records(&[MemoryRecord {
record_id: "fact:operator".into(),
agent_id: "mentra-agent".into(),
kind: MemoryRecordKind::Fact,
content: format!("operator-only durable detail {}", "x".repeat(100_000)),
source_revision: 1,
created_at: 1,
metadata_json: "{}".into(),
source: Some("manual".into()),
pinned: true,
score: None,
}])
.expect("seed memory");
let memory = Arc::new(
crate::MentraMemoryAdmin::new(memory_store, memory_root.join("memory-agents.json"))
.expect("memory admin"),
);
memory
.register("avatar-id", "logical-agent", "mentra-agent")
.expect("mapping");
target.attach_memory_control(memory);
target.update_region(
Some("00000000-0000-4000-8000-000000000001".into()),
Some(9_007_199_254_740_992),
@@ -200,6 +226,81 @@ async fn production_target_projects_state_and_routes_real_mutations() {
assert!(vision.active_capture().is_none(), "pause cancels capture");
let observer = plane.connect("observer-token").expect("observer");
let health = observer
.request(envelope("memory-health", ControlRequest::MemoryHealth))
.await;
assert!(matches!(health.result, Ok(ControlPayload::MemoryHealth(_))));
let denied_memory = observer
.request(envelope(
"memory-denied",
ControlRequest::BrowseMemories {
mentra_agent_id: "mentra-agent".into(),
cursor: None,
limit: 10,
filter: MemoryFilterView::default(),
sort: MemorySortView::Newest,
},
))
.await;
assert!(denied_memory.result.is_err());
let memories = operator
.request(envelope(
"memory-browse",
ControlRequest::BrowseMemories {
mentra_agent_id: "mentra-agent".into(),
cursor: None,
limit: 10,
filter: MemoryFilterView::default(),
sort: MemorySortView::Newest,
},
))
.await;
let Ok(ControlPayload::Memories(memories)) = memories.result else {
panic!("operator memory browse")
};
assert_eq!(memories.items.len(), 1);
assert!(memories.items[0].preview.contains("durable detail"));
for (id, query) in [("memory-empty", ""), ("memory-punctuation", "(durable)!!!")] {
let search = operator
.request(envelope(
id,
ControlRequest::SearchMemories {
mentra_agent_id: "mentra-agent".into(),
query: query.into(),
page: PageRequest {
cursor: None,
limit: 10,
},
filter: MemoryFilterView {
source: Some("manual".into()),
..MemoryFilterView::default()
},
sort: if query.is_empty() {
MemorySortView::Newest
} else {
MemorySortView::Relevance
},
},
))
.await;
let Ok(ControlPayload::Memories(search)) = search.result else {
panic!("memory search")
};
assert_eq!(search.items.len(), 1);
}
let detail = operator
.request(envelope(
"memory-detail",
ControlRequest::GetMemory {
mentra_agent_id: "mentra-agent".into(),
record_id: "fact:operator".into(),
},
))
.await;
let Ok(ControlPayload::MemoryDetail(detail)) = detail.result else {
panic!("bounded memory detail")
};
assert_eq!(detail.content.len(), 24 * 1024);
let denied = observer
.request(envelope("shutdown", ControlRequest::GracefulShutdown))
.await;

View File

@@ -1779,7 +1779,8 @@ pub struct PolicyLlmResponder {
client: Arc<crate::llm::LlmClient>,
approval_reviewer: Arc<AutonomousApprovalReviewer>,
runtime: Arc<mentra::Runtime>,
agents: Mutex<BTreeMap<String, Arc<tokio::sync::Mutex<mentra::Agent>>>>,
agents: Mutex<BTreeMap<String, MentraAgentHandle>>,
memory_admin: Arc<crate::memory_admin::MentraMemoryAdmin>,
active_tools: Arc<Mutex<BTreeMap<String, ActiveMentraRequest>>>,
gateway: Arc<crate::policy::PolicyGateway>,
backend: Arc<dyn crate::backend::AuthorizedToolBackend>,
@@ -1788,6 +1789,12 @@ pub struct PolicyLlmResponder {
storage_path: std::path::PathBuf,
}
#[derive(Clone)]
struct MentraAgentHandle {
id: String,
agent: Arc<tokio::sync::Mutex<mentra::Agent>>,
}
#[derive(Clone)]
struct ActiveMentraRequest {
executor: Arc<crate::policy::PolicyToolExecutor>,
@@ -1994,11 +2001,17 @@ impl PolicyLlmResponder {
let active_tools = Arc::new(Mutex::new(BTreeMap::new()));
std::fs::create_dir_all(&storage_path)
.map_err(|error| InteractionError::Persistence(error.to_string()))?;
let store = mentra::runtime::HybridRuntimeStore::new(storage_path.join("runtime.sqlite"));
let memory_admin = Arc::new(
crate::memory_admin::MentraMemoryAdmin::new(
store.clone(),
storage_path.join("memory-agents.json"),
)
.map_err(InteractionError::Persistence)?,
);
let mut builder = mentra::Runtime::builder()
.with_runtime_identifier(MENTRA_RUNTIME_IDENTIFIER)
.with_store(mentra::runtime::HybridRuntimeStore::new(
storage_path.join("runtime.sqlite"),
))
.with_store(store)
.with_registered_provider(client.mentra_provider());
for definition in gateway.registered_tool_definitions() {
builder = builder.with_tool(MentraGridTool {
@@ -2015,9 +2028,13 @@ impl PolicyLlmResponder {
.into_iter()
.filter(|agent| agent.name().starts_with(MENTRA_AGENT_PREFIX))
.map(|agent| {
let id = agent.id().to_owned();
(
agent.name().to_owned(),
Arc::new(tokio::sync::Mutex::new(agent)),
MentraAgentHandle {
id,
agent: Arc::new(tokio::sync::Mutex::new(agent)),
},
)
})
.collect();
@@ -2029,6 +2046,7 @@ impl PolicyLlmResponder {
client,
runtime: Arc::new(runtime),
agents: Mutex::new(agents),
memory_admin,
active_tools,
gateway,
backend,
@@ -2048,8 +2066,13 @@ impl PolicyLlmResponder {
.agents
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(agent) = agents.get(&key) {
return Ok(Arc::clone(agent));
if let Some(handle) = agents.get(&key) {
if request.origin == InteractionOrigin::AuthorizedIm {
self.memory_admin
.register(&request.sender_id.to_string(), &key, &handle.id)
.map_err(|_| InteractionModelError::Failed)?;
}
return Ok(Arc::clone(&handle.agent));
}
let tools = self.gateway.tools_for(context, (self.now)());
let mut tool_names = tools
@@ -2081,10 +2104,27 @@ impl PolicyLlmResponder {
.runtime
.spawn_with_config(key.clone(), model, config)
.map_err(|_| InteractionModelError::Failed)?;
let id = agent.id().to_owned();
let agent = Arc::new(tokio::sync::Mutex::new(agent));
agents.insert(key, Arc::clone(&agent));
agents.insert(
key.clone(),
MentraAgentHandle {
id: id.clone(),
agent: Arc::clone(&agent),
},
);
if request.origin == InteractionOrigin::AuthorizedIm {
self.memory_admin
.register(&request.sender_id.to_string(), &key, &id)
.map_err(|_| InteractionModelError::Failed)?;
}
Ok(agent)
}
#[must_use]
pub fn memory_admin(&self) -> Arc<crate::memory_admin::MentraMemoryAdmin> {
Arc::clone(&self.memory_admin)
}
}
pub(crate) fn mentra_agent_name(request: &ResponseRequest) -> String {

View File

@@ -15,6 +15,7 @@ pub mod conversation;
pub mod interaction;
pub mod landmarks;
pub mod llm;
pub mod memory_admin;
pub mod observability;
pub mod perception;
pub mod policy;
@@ -96,8 +97,10 @@ pub use control_plane::{
ControlErrorCode, ControlEvent, ControlEventKind, ControlFuture, ControlLimits, ControlPayload,
ControlPlane, ControlRequest, ControlRequestEnvelope, ControlResponseEnvelope, ControlRole,
ControlSubscription, ControlTarget, ConversationChannelView, HealthView,
InProcessControlClient, Page, PageRequest, PendingApprovalView, RuntimeView, ScheduledJobView,
SessionMetadataView, TcpControlClient, TcpControlConfig, TcpControlServer,
InProcessControlClient, MemoryAgentView, MemoryCursorView, MemoryDetailView, MemoryFilterView,
MemoryHealthView, MemoryKindView, MemoryPageView, MemoryRecordView, MemorySortView, Page,
PageRequest, PendingApprovalView, RuntimeView, ScheduledJobView, SessionMetadataView,
TcpControlClient, TcpControlConfig, TcpControlServer,
};
pub use control_runtime::{AgentControlTarget, RuntimeControlCommand};
pub use conversation::{
@@ -130,6 +133,7 @@ pub use landmarks::{
pub use llm::{
CompletionMessage, ContentPart, ImageDetail, LlmClient, LlmError, ToolDefinition, ToolSchema,
};
pub use memory_admin::{MemoryAgentMapping, MentraMemoryAdmin};
pub use observability::{
AgentMetrics, CorrelationIds, DiagnosticDirection, EventDraft, EventFamily, EventOrigin,
EventSeverity, EventSubscription, JournalConfig, LatencyMetrics, MetricsSnapshot,

View File

@@ -415,6 +415,7 @@ async fn run_live(
control_target.attach_observability(live.observability.clone());
control_target.attach_build_control(live.build_control.clone());
control_target.attach_vision_control(live.vision.clone());
control_target.attach_memory_control(live.memory_admin.clone());
control_target.update_session(handle.status());
let erased_target: Arc<dyn ControlTarget> = control_target.clone();
let (control_plane, integrated_client, control_server) = match config.mode {
@@ -732,6 +733,7 @@ struct LiveInteractions {
policy: Arc<metacrate_grid_agent::PolicyGateway>,
audit: Arc<metacrate_grid_agent::MemoryPolicyAudit>,
observability: Arc<metacrate_grid_agent::Observability>,
memory_admin: Arc<metacrate_grid_agent::MentraMemoryAdmin>,
_appearance_recovery: libremetaverse_types::compat::Subscription,
_landmark_intake: metacrate_grid_agent::LibremetaverseLandmarkIntake,
landmark_roaming: metacrate_grid_agent::LandmarkRoamingHandle,
@@ -903,6 +905,7 @@ async fn start_live_interactions(
now,
config.storage_path.join("mentra"),
)?);
let memory_admin = responder.memory_admin();
let vision_limits = config.vision;
let scene_source = Arc::new(LibremetaverseSceneSource::new(owner, vision_limits));
scene_source.start_prefetch();
@@ -936,6 +939,7 @@ async fn start_live_interactions(
policy: gateway,
audit,
observability,
memory_admin,
_appearance_recovery: appearance_recovery,
_landmark_intake: landmark_intake,
landmark_roaming,

View File

@@ -0,0 +1,288 @@
//! Operator-only access to durable Mentra memory.
#![allow(clippy::missing_errors_doc)]
use mentra::memory::{
MemoryListCursor, MemoryListFilter, MemoryListRequest, MemoryListSort, MemoryRecord,
MemorySearchMode, MemorySearchRequest, MemoryStore,
};
use mentra::runtime::HybridRuntimeStore;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, MutexGuard};
const MAX_MAPPINGS: usize = 4_096;
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub struct MemoryAgentMapping {
pub avatar_id: String,
pub logical_agent_id: String,
pub mentra_agent_id: String,
}
pub struct MentraMemoryAdmin {
store: HybridRuntimeStore,
mappings_path: PathBuf,
mappings: Mutex<BTreeMap<String, MemoryAgentMapping>>,
}
impl MentraMemoryAdmin {
pub fn new(store: HybridRuntimeStore, mappings_path: PathBuf) -> Result<Self, String> {
let mappings = load_mappings(&mappings_path)?;
Ok(Self {
store,
mappings_path,
mappings: Mutex::new(mappings),
})
}
pub fn register(
&self,
avatar_id: &str,
logical_agent_id: &str,
mentra_agent_id: &str,
) -> Result<(), String> {
let mapping = MemoryAgentMapping {
avatar_id: avatar_id.to_owned(),
logical_agent_id: logical_agent_id.to_owned(),
mentra_agent_id: mentra_agent_id.to_owned(),
};
if !valid_mapping(&mapping) {
return Err("invalid memory-agent mapping".to_owned());
}
let mut mappings = lock(&self.mappings);
if !mappings.contains_key(avatar_id) && mappings.len() >= MAX_MAPPINGS {
return Err("memory-agent mapping limit reached".to_owned());
}
if mappings.get(avatar_id).is_some_and(|mapping| {
mapping.logical_agent_id == logical_agent_id
&& mapping.mentra_agent_id == mentra_agent_id
}) {
return Ok(());
}
let previous = mappings.insert(avatar_id.to_owned(), mapping);
if let Err(error) = persist_mappings(&self.mappings_path, mappings.values()) {
if let Some(previous) = previous {
mappings.insert(avatar_id.to_owned(), previous);
} else {
mappings.remove(avatar_id);
}
return Err(error);
}
Ok(())
}
pub fn agents(&self) -> Result<Vec<(MemoryAgentMapping, usize)>, String> {
lock(&self.mappings)
.values()
.map(|mapping| {
self.store
.count_records(&mapping.mentra_agent_id)
.map(|count| (mapping.clone(), count))
.map_err(|error| error.to_string())
})
.collect()
}
pub fn total_counts(&self) -> Result<(usize, usize), String> {
let agents = self.agents()?;
Ok((agents.len(), agents.iter().map(|(_, count)| count).sum()))
}
pub fn browse(
&self,
mentra_agent_id: &str,
cursor: Option<MemoryListCursor>,
limit: usize,
filter: MemoryListFilter,
sort: MemoryListSort,
) -> Result<(Vec<MemoryRecord>, Option<MemoryListCursor>), String> {
self.require_agent(mentra_agent_id)?;
self.store
.list_records(&MemoryListRequest {
agent_id: mentra_agent_id.to_owned(),
cursor,
limit,
filter,
sort,
})
.map(|page| (page.records, page.next_cursor))
.map_err(|error| error.to_string())
}
pub fn search(
&self,
mentra_agent_id: &str,
query: &str,
limit: usize,
filter: MemoryListFilter,
) -> Result<Vec<MemoryRecord>, String> {
self.require_agent(mentra_agent_id)?;
self.store
.search_records_with_options(&MemorySearchRequest {
agent_id: mentra_agent_id.to_owned(),
query: query.to_owned(),
limit,
char_budget: None,
mode: MemorySearchMode::Tool,
filter,
})
.map_err(|error| error.to_string())
}
pub fn detail(
&self,
mentra_agent_id: &str,
record_id: &str,
) -> Result<Option<MemoryRecord>, String> {
self.require_agent(mentra_agent_id)?;
self.store
.get_record(mentra_agent_id, record_id)
.map_err(|error| error.to_string())
}
pub fn forget(&self, mentra_agent_id: &str, record_id: &str) -> Result<bool, String> {
self.require_agent(mentra_agent_id)?;
self.store
.tombstone_records(mentra_agent_id, &[record_id.to_owned()])
.map(|affected| affected == 1)
.map_err(|error| error.to_string())
}
fn require_agent(&self, mentra_agent_id: &str) -> Result<(), String> {
lock(&self.mappings)
.values()
.any(|mapping| mapping.mentra_agent_id == mentra_agent_id)
.then_some(())
.ok_or_else(|| "memory agent not found".to_owned())
}
}
fn load_mappings(path: &Path) -> Result<BTreeMap<String, MemoryAgentMapping>, String> {
let bytes = match std::fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
match std::fs::read(path.with_extension("json.bak")) {
Ok(bytes) => bytes,
Err(backup) if backup.kind() == std::io::ErrorKind::NotFound => {
return Ok(BTreeMap::new());
}
Err(backup) => return Err(backup.to_string()),
}
}
Err(error) => return Err(error.to_string()),
};
let values: Vec<MemoryAgentMapping> =
serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
if values.len() > MAX_MAPPINGS || values.iter().any(|mapping| !valid_mapping(mapping)) {
return Err("invalid or excessive memory-agent mappings".to_owned());
}
let mut mappings = BTreeMap::new();
for mapping in values {
if mappings
.insert(mapping.avatar_id.clone(), mapping)
.is_some()
{
return Err("duplicate memory-agent mapping".to_owned());
}
}
Ok(mappings)
}
fn valid_mapping(mapping: &MemoryAgentMapping) -> bool {
[
mapping.avatar_id.as_str(),
mapping.logical_agent_id.as_str(),
mapping.mentra_agent_id.as_str(),
]
.iter()
.all(|value| !value.is_empty() && value.len() <= 256 && !value.contains('\0'))
}
fn persist_mappings<'a>(
path: &Path,
mappings: impl Iterator<Item = &'a MemoryAgentMapping>,
) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
}
let bytes = serde_json::to_vec_pretty(&mappings.collect::<Vec<_>>())
.map_err(|error| error.to_string())?;
let temporary = path.with_extension("json.tmp");
let backup = path.with_extension("json.bak");
std::fs::write(&temporary, bytes).map_err(|error| error.to_string())?;
let had_previous = path.exists();
if had_previous {
let _ = std::fs::remove_file(&backup);
std::fs::rename(path, &backup).map_err(|error| error.to_string())?;
}
if let Err(error) = std::fs::rename(&temporary, path) {
if had_previous {
let _ = std::fs::rename(&backup, path);
}
return Err(error.to_string());
}
if had_previous {
let _ = std::fs::remove_file(backup);
}
Ok(())
}
fn lock<T>(value: &Mutex<T>) -> MutexGuard<'_, T> {
value
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
#[cfg(test)]
mod tests {
use super::*;
use mentra::memory::{MemoryRecordKind, MemoryStore};
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn mapping_persists_and_cross_agent_forget_fails_closed() {
let root = std::env::temp_dir().join(format!(
"metacrate-memory-admin-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let database = root.join("runtime.sqlite");
let mappings = root.join("memory-agents.json");
let store = HybridRuntimeStore::new(&database);
store
.upsert_records(&[record("fact:a", "mentra-a"), record("fact:b", "mentra-b")])
.unwrap();
let admin = MentraMemoryAdmin::new(store.clone(), mappings.clone()).unwrap();
admin.register("avatar-a", "logical-a", "mentra-a").unwrap();
admin.register("avatar-b", "logical-b", "mentra-b").unwrap();
assert!(!admin.forget("mentra-a", "fact:b").unwrap());
assert!(admin.detail("mentra-b", "fact:b").unwrap().is_some());
drop(admin);
let reopened = MentraMemoryAdmin::new(store, mappings).unwrap();
assert_eq!(reopened.agents().unwrap().len(), 2);
assert!(reopened.forget("mentra-a", "fact:a").unwrap());
assert!(reopened.detail("mentra-a", "fact:a").unwrap().is_none());
let _ = std::fs::remove_dir_all(root);
}
fn record(id: &str, agent_id: &str) -> MemoryRecord {
MemoryRecord {
record_id: id.to_owned(),
agent_id: agent_id.to_owned(),
kind: MemoryRecordKind::Fact,
content: "durable memory".to_owned(),
source_revision: 1,
created_at: 1,
metadata_json: "{}".to_owned(),
source: Some("manual".to_owned()),
pinned: true,
score: None,
}
}
}

View File

@@ -8,8 +8,9 @@
use crate::control_plane::{
AuditEventView, CONTROL_PROTOCOL_VERSION, ControlEvent, ControlEventKind, ControlPayload,
ControlRequest, ControlRequestEnvelope, ControlResponseEnvelope, HealthView,
InProcessControlClient, Page, PageRequest, PendingApprovalView, RuntimeView, ScheduledJobView,
SessionMetadataView, TcpControlClient,
InProcessControlClient, MemoryAgentView, MemoryCursorView, MemoryDetailView, MemoryFilterView,
MemoryHealthView, MemoryKindView, MemoryRecordView, MemorySortView, Page, PageRequest,
PendingApprovalView, RuntimeView, ScheduledJobView, SessionMetadataView, TcpControlClient,
};
use crate::observability::{EventSeverity, MetricsSnapshot, StructuredEvent};
use crate::{AgentPreferences, ControlLimits, OperatingMode, PreferencesSummary, SecretString};
@@ -42,6 +43,7 @@ const PAGE_LIMIT: u16 = 100;
pub enum OperatorScreen {
Overview,
Sessions,
Memories,
QueuesAndBudgets,
Roaming,
Approvals,
@@ -53,9 +55,10 @@ pub enum OperatorScreen {
}
impl OperatorScreen {
const ALL: [Self; 10] = [
const ALL: [Self; 11] = [
Self::Overview,
Self::Sessions,
Self::Memories,
Self::QueuesAndBudgets,
Self::Roaming,
Self::Approvals,
@@ -71,6 +74,7 @@ impl OperatorScreen {
match self {
Self::Overview => "Overview",
Self::Sessions => "Sessions",
Self::Memories => "Memory",
Self::QueuesAndBudgets => "Queues & budgets",
Self::Roaming => "Roaming",
Self::Approvals => "Approvals",
@@ -337,6 +341,10 @@ pub struct OperatorSnapshot {
pub sessions: Vec<SessionMetadataView>,
pub schedules: Vec<ScheduledJobView>,
pub approvals: Vec<PendingApprovalView>,
pub memory_health: Option<MemoryHealthView>,
pub memory_agents: Vec<MemoryAgentView>,
pub memories: Vec<MemoryRecordView>,
pub memory_detail: Option<MemoryDetailView>,
pub audit: Vec<AuditEventView>,
pub timeline: VecDeque<StructuredEvent>,
pub gap_count: u64,
@@ -352,6 +360,10 @@ impl Default for OperatorSnapshot {
sessions: Vec::new(),
schedules: Vec::new(),
approvals: Vec::new(),
memory_health: None,
memory_agents: Vec::new(),
memories: Vec::new(),
memory_detail: None,
audit: Vec::new(),
timeline: VecDeque::with_capacity(MAX_TIMELINE),
gap_count: 0,
@@ -376,10 +388,23 @@ pub enum OperatorCommand {
Pause,
Resume,
CancelAction(String),
DecideApproval { id: u64, approve: bool },
DecideApproval {
id: u64,
approve: bool,
},
Reconnect,
ExpireSession { avatar_id: String, direct_im: bool },
ToggleSchedule { job_id: String, enabled: bool },
ExpireSession {
avatar_id: String,
direct_im: bool,
},
ToggleSchedule {
job_id: String,
enabled: bool,
},
ForgetMemory {
mentra_agent_id: String,
record_id: String,
},
Shutdown,
}
@@ -411,6 +436,13 @@ impl OperatorCommand {
job_id: job_id.clone(),
enabled: *enabled,
},
Self::ForgetMemory {
mentra_agent_id,
record_id,
} => ControlRequest::ForgetMemory {
mentra_agent_id: mentra_agent_id.clone(),
record_id: record_id.clone(),
},
Self::Shutdown => ControlRequest::GracefulShutdown,
}
}
@@ -424,7 +456,7 @@ impl OperatorCommand {
| Self::Reconnect
| Self::ExpireSession { .. }
| Self::ToggleSchedule { .. } => CommandConfirmation::Normal,
Self::Shutdown => CommandConfirmation::HighRisk,
Self::ForgetMemory { .. } | Self::Shutdown => CommandConfirmation::HighRisk,
}
}
}
@@ -459,13 +491,21 @@ pub enum TuiInput {
FilterBackspace,
FilterCommit,
FilterCancel,
MemoryLoad,
MemoryNextAgent,
MemoryNextPage,
MemoryPreviousPage,
MemoryCycleKind,
MemoryTogglePinned,
MemoryCycleSort,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Clone, Debug, PartialEq)]
pub enum TuiAction {
None,
Refresh,
Execute(OperatorCommand),
Request(ControlRequest),
Exit,
}
@@ -605,6 +645,14 @@ pub struct OperatorTui {
cadence_millis: ValueCell<u64>,
last_frame: ValueCell<Option<Instant>>,
filter_editing: bool,
memory_agent: usize,
memory_filter: MemoryFilterView,
memory_sort: MemorySortView,
memory_next_cursor: Option<MemoryCursorView>,
memory_cursor_stack: Vec<Option<MemoryCursorView>>,
memory_current_offset: u64,
memory_next_offset: Option<u64>,
memory_refresh_pending: bool,
}
impl Default for OperatorTui {
@@ -627,6 +675,14 @@ impl Default for OperatorTui {
cadence_millis: ValueCell::new(0),
last_frame: ValueCell::new(None),
filter_editing: false,
memory_agent: 0,
memory_filter: MemoryFilterView::default(),
memory_sort: MemorySortView::Newest,
memory_next_cursor: None,
memory_cursor_stack: Vec::new(),
memory_current_offset: 0,
memory_next_offset: None,
memory_refresh_pending: false,
}
}
}
@@ -647,9 +703,59 @@ impl OperatorTui {
self.selected[Self::screen_index(self.screen)]
}
fn memory_request(&self, cursor: Option<MemoryCursorView>) -> Option<ControlRequest> {
let agent = self.snapshot.memory_agents.get(self.memory_agent)?;
if self.filter.query.trim().is_empty() {
Some(ControlRequest::BrowseMemories {
mentra_agent_id: agent.mentra_agent_id.clone(),
cursor,
limit: 50,
filter: self.memory_filter.clone(),
sort: if self.memory_sort == MemorySortView::Relevance {
MemorySortView::Newest
} else {
self.memory_sort
},
})
} else {
Some(ControlRequest::SearchMemories {
mentra_agent_id: agent.mentra_agent_id.clone(),
query: self.filter.query.clone(),
page: PageRequest {
cursor: (self.memory_current_offset != 0).then_some(self.memory_current_offset),
limit: 50,
},
filter: self.memory_filter.clone(),
sort: self.memory_sort,
})
}
}
fn current_memory_request(&self) -> Option<ControlRequest> {
let cursor = self.memory_cursor_stack.last().cloned().flatten();
self.memory_request(cursor)
}
fn parse_memory_filter(&mut self) {
let mut query = Vec::new();
for token in self.filter.query.split_whitespace() {
if let Some(value) = token.strip_prefix("source:") {
self.memory_filter.source = (!value.is_empty()).then(|| value.to_owned());
} else if let Some(value) = token.strip_prefix("from:") {
self.memory_filter.created_from = value.parse().ok();
} else if let Some(value) = token.strip_prefix("to:") {
self.memory_filter.created_to = value.parse().ok();
} else {
query.push(token);
}
}
self.filter.query = query.join(" ");
}
fn visible_rows(&self) -> usize {
match self.screen {
OperatorScreen::Sessions => self.snapshot.sessions.len(),
OperatorScreen::Memories => self.snapshot.memories.len(),
OperatorScreen::Roaming => self.snapshot.schedules.len(),
OperatorScreen::Approvals => self.snapshot.approvals.len(),
OperatorScreen::Timeline => {
@@ -687,6 +793,14 @@ impl OperatorTui {
#[must_use]
pub fn command_shortcut(&self, key: char) -> Option<OperatorCommand> {
if self.screen == OperatorScreen::Memories && key == 'd' {
let agent = self.snapshot.memory_agents.get(self.memory_agent)?;
let record = self.snapshot.memories.get(self.selected_row())?;
return Some(OperatorCommand::ForgetMemory {
mentra_agent_id: agent.mentra_agent_id.clone(),
record_id: record.record_id.clone(),
});
}
match key {
'p' => Some(OperatorCommand::Pause),
'u' => Some(OperatorCommand::Resume),
@@ -737,7 +851,11 @@ impl OperatorTui {
.unwrap_or(0);
self.screen = OperatorScreen::ALL[(i + 1) % OperatorScreen::ALL.len()];
self.scroll = 0;
TuiAction::None
if self.screen == OperatorScreen::Memories {
TuiAction::Request(ControlRequest::ListMemoryAgents { page: page() })
} else {
TuiAction::None
}
}
TuiInput::PreviousScreen => {
let i = OperatorScreen::ALL
@@ -747,7 +865,11 @@ impl OperatorTui {
self.screen = OperatorScreen::ALL
[(i + OperatorScreen::ALL.len() - 1) % OperatorScreen::ALL.len()];
self.scroll = 0;
TuiAction::None
if self.screen == OperatorScreen::Memories {
TuiAction::Request(ControlRequest::ListMemoryAgents { page: page() })
} else {
TuiAction::None
}
}
TuiInput::ScrollUp => {
let index = Self::screen_index(self.screen);
@@ -821,7 +943,11 @@ impl OperatorTui {
}
TuiInput::BeginFilter => {
self.filter_editing = true;
self.status = "event/audit filter: type query, Enter applies, Esc clears".into();
self.status = if self.screen == OperatorScreen::Memories {
"memory search/filter: type query, Enter applies, Esc clears".into()
} else {
"event/audit filter: type query, Enter applies, Esc clears".into()
};
TuiAction::None
}
TuiInput::FilterCharacter(value) => {
@@ -840,14 +966,122 @@ impl OperatorTui {
self.filter_editing = false;
self.selected[Self::screen_index(self.screen)] = 0;
self.status = format!("filter applied: {}", self.filter.query);
TuiAction::None
if self.screen == OperatorScreen::Memories {
self.parse_memory_filter();
self.memory_cursor_stack.clear();
self.memory_current_offset = 0;
self.memory_next_offset = None;
self.memory_request(None)
.map_or(TuiAction::None, TuiAction::Request)
} else {
TuiAction::None
}
}
TuiInput::FilterCancel => {
self.filter_editing = false;
self.filter.query.clear();
self.status = "filter cleared".into();
if self.screen == OperatorScreen::Memories {
self.memory_current_offset = 0;
self.memory_sort = match self.memory_sort {
MemorySortView::Relevance => MemorySortView::Newest,
sort => sort,
};
self.memory_request(None)
.map_or(TuiAction::None, TuiAction::Request)
} else {
TuiAction::None
}
}
TuiInput::MemoryLoad => {
if let (Some(agent), Some(record)) = (
self.snapshot.memory_agents.get(self.memory_agent),
self.snapshot.memories.get(self.selected_row()),
) {
TuiAction::Request(ControlRequest::GetMemory {
mentra_agent_id: agent.mentra_agent_id.clone(),
record_id: record.record_id.clone(),
})
} else {
self.memory_request(None)
.map_or(TuiAction::None, TuiAction::Request)
}
}
TuiInput::MemoryNextAgent => {
if !self.snapshot.memory_agents.is_empty() {
self.memory_agent = (self.memory_agent + 1) % self.snapshot.memory_agents.len();
}
self.memory_cursor_stack.clear();
self.memory_current_offset = 0;
self.memory_next_offset = None;
self.memory_request(None)
.map_or(TuiAction::None, TuiAction::Request)
}
TuiInput::MemoryNextPage => {
if self.filter.query.trim().is_empty() {
if let Some(cursor) = self.memory_next_cursor.clone() {
self.memory_cursor_stack.push(Some(cursor.clone()));
return self
.memory_request(Some(cursor))
.map_or(TuiAction::None, TuiAction::Request);
}
} else if let Some(offset) = self.memory_next_offset {
self.memory_current_offset = offset;
return self
.memory_request(None)
.map_or(TuiAction::None, TuiAction::Request);
}
TuiAction::None
}
TuiInput::MemoryPreviousPage => {
if self.filter.query.trim().is_empty() {
self.memory_cursor_stack.pop();
let cursor = self.memory_cursor_stack.last().cloned().flatten();
self.memory_request(cursor)
.map_or(TuiAction::None, TuiAction::Request)
} else {
self.memory_current_offset = self.memory_current_offset.saturating_sub(50);
self.memory_request(None)
.map_or(TuiAction::None, TuiAction::Request)
}
}
TuiInput::MemoryCycleKind => {
self.memory_filter.kind = match self.memory_filter.kind {
None => Some(MemoryKindView::Episode),
Some(MemoryKindView::Episode) => Some(MemoryKindView::Summary),
Some(MemoryKindView::Summary) => Some(MemoryKindView::Fact),
Some(MemoryKindView::Fact) => None,
};
self.memory_current_offset = 0;
self.memory_cursor_stack.clear();
self.memory_request(None)
.map_or(TuiAction::None, TuiAction::Request)
}
TuiInput::MemoryTogglePinned => {
self.memory_filter.pinned = match self.memory_filter.pinned {
None => Some(true),
Some(true) => Some(false),
Some(false) => None,
};
self.memory_current_offset = 0;
self.memory_cursor_stack.clear();
self.memory_request(None)
.map_or(TuiAction::None, TuiAction::Request)
}
TuiInput::MemoryCycleSort => {
self.memory_sort = match self.memory_sort {
MemorySortView::Newest => MemorySortView::Oldest,
MemorySortView::Oldest if self.filter.query.trim().is_empty() => {
MemorySortView::Newest
}
MemorySortView::Oldest => MemorySortView::Relevance,
MemorySortView::Relevance => MemorySortView::Newest,
};
self.memory_current_offset = 0;
self.memory_cursor_stack.clear();
self.memory_request(None)
.map_or(TuiAction::None, TuiAction::Request)
}
TuiInput::Command(command) if command.confirmation() == CommandConfirmation::None => {
TuiAction::Execute(command)
}
@@ -856,10 +1090,13 @@ impl OperatorTui {
self.pending = Some(command);
TuiAction::None
}
TuiInput::Confirm => self
.pending
.take()
.map_or(TuiAction::None, TuiAction::Execute),
TuiInput::Confirm => {
let command = self.pending.take();
self.memory_refresh_pending = command
.as_ref()
.is_some_and(|command| matches!(command, OperatorCommand::ForgetMemory { .. }));
command.map_or(TuiAction::None, TuiAction::Execute)
}
TuiInput::Reject => {
self.pending = None;
self.status = "command cancelled".into();
@@ -897,6 +1134,7 @@ impl OperatorTui {
ControlRequest::Health,
ControlRequest::Runtime,
ControlRequest::Metrics,
ControlRequest::MemoryHealth,
ControlRequest::ListSessions { page: page() },
ControlRequest::ListScheduledJobs { page: page() },
ControlRequest::ListPendingApprovals { page: page() },
@@ -957,8 +1195,13 @@ impl OperatorTui {
client: &dyn TuiTransport,
command: OperatorCommand,
) -> Result<(), TuiError> {
let refresh_memory = matches!(command, OperatorCommand::ForgetMemory { .. });
self.send_and_apply(client, command.request()).await?;
self.refresh(client).await
self.refresh(client).await?;
if refresh_memory && let Some(request) = self.current_memory_request() {
self.send_and_apply(client, request).await?;
}
Ok(())
}
async fn send_and_apply(
@@ -990,6 +1233,34 @@ impl OperatorTui {
ControlPayload::Health(value) => self.snapshot.health = Some(value),
ControlPayload::Metrics(value) => self.snapshot.metrics = Some(value),
ControlPayload::Runtime(value) => self.snapshot.runtime = Some(value),
ControlPayload::MemoryHealth(value) => self.snapshot.memory_health = Some(value),
ControlPayload::MemoryAgents(Page { items, .. }) => {
let identity = self
.snapshot
.memory_agents
.get(self.memory_agent)
.map(|agent| agent.mentra_agent_id.clone());
self.snapshot.memory_agents = items;
self.memory_agent = identity
.and_then(|id| {
self.snapshot
.memory_agents
.iter()
.position(|agent| agent.mentra_agent_id == id)
})
.unwrap_or(0)
.min(self.snapshot.memory_agents.len().saturating_sub(1));
}
ControlPayload::Memories(page) => {
self.snapshot.memories = page.items;
self.snapshot.memory_detail = None;
self.memory_next_cursor = page.next_cursor;
self.memory_next_offset = page.next_offset;
self.selected[Self::screen_index(OperatorScreen::Memories)] = 0;
}
ControlPayload::MemoryDetail(detail) => {
self.snapshot.memory_detail = Some(detail);
}
ControlPayload::Sessions(Page { items, .. }) => {
let index = Self::screen_index(OperatorScreen::Sessions);
let identity = self
@@ -1205,6 +1476,7 @@ impl OperatorTui {
match self.screen {
OperatorScreen::Overview => self.draw_overview(frame, chunks[1], color),
OperatorScreen::Sessions => self.draw_sessions(frame, chunks[1], color),
OperatorScreen::Memories => self.draw_memories(frame, chunks[1], color),
OperatorScreen::QueuesAndBudgets => self.draw_performance(frame, chunks[1], color),
OperatorScreen::Roaming => self.draw_roaming(frame, chunks[1], color),
OperatorScreen::Approvals => self.draw_approvals(frame, chunks[1], color),
@@ -1320,6 +1592,15 @@ impl OperatorTui {
)
}
OperatorScreen::Sessions => format!("sessions={}", self.snapshot.sessions.len()),
OperatorScreen::Memories => self.snapshot.memory_health.as_ref().map_or_else(
|| "memory health unavailable".into(),
|health| {
format!(
"memory agents={} records={}",
health.agent_count, health.record_count
)
},
),
OperatorScreen::QueuesAndBudgets => self.snapshot.metrics.as_ref().map_or_else(
|| "metrics unavailable".into(),
|metrics| {
@@ -1462,6 +1743,79 @@ impl OperatorTui {
);
}
fn draw_memories(&self, frame: &mut Frame<'_>, area: Rect, color: bool) {
let rows = self
.snapshot
.memories
.iter()
.enumerate()
.skip(self.selected_row().saturating_sub(5))
.map(|(index, item)| {
styled_row(
index == self.selected_row(),
color,
vec![
item.record_id.clone(),
format!("{:?}", item.kind).to_lowercase(),
item.created_at.to_string(),
if item.pinned { "yes" } else { "no" }.to_owned(),
item.preview.clone(),
],
)
});
let agent = self.snapshot.memory_agents.get(self.memory_agent);
let detail = self.snapshot.memory_detail.as_ref().map_or_else(
|| {
vec![
Line::raw(agent.map_or_else(
|| "No operator-visible memory agents".into(),
|agent| format!(
"agent: {} avatar={} records={}",
agent.logical_agent_id, agent.avatar_id, agent.record_count
),
)),
Line::raw(format!(
"query={:?} kind={:?} pinned={:?} sort={:?}",
self.filter.query,
self.memory_filter.kind,
self.memory_filter.pinned,
self.memory_sort
)),
Line::raw("Enter load/detail / search (source:/from:/to:) g agent n/b page k kind i pinned o sort d forget"),
]
},
|detail| {
vec![
Line::raw(format!("record: {}", detail.record.record_id)),
Line::raw(format!(
"source: {:?} revision={}",
detail.record.source, detail.record.source_revision
)),
Line::raw(format!(
"{}{}",
detail.content,
if detail.content_truncated { " [truncated]" } else { "" }
)),
Line::raw(format!(
"metadata: {}{}",
detail.metadata_json,
if detail.metadata_truncated { " [truncated]" } else { "" }
)),
Line::raw("[d] forget (high-risk confirmation)"),
]
},
);
Self::draw_table_detail(
frame,
area,
color,
"Mentra durable memory",
["Record", "Kind", "Created", "Pinned", "Preview"],
rows,
detail,
);
}
fn draw_roaming(&self, frame: &mut Frame<'_>, area: Rect, color: bool) {
let rows = self
.snapshot
@@ -2401,7 +2755,10 @@ pub fn run_preferences_terminal(path: PathBuf, color: bool) -> Result<(), TuiErr
};
match app.reduce(input) {
TuiAction::Exit => return Ok(()),
TuiAction::None | TuiAction::Refresh | TuiAction::Execute(_) => {}
TuiAction::None
| TuiAction::Refresh
| TuiAction::Execute(_)
| TuiAction::Request(_) => {}
}
}
}
@@ -2458,6 +2815,10 @@ pub async fn run_terminal_with_preferences(
work_tx.try_send(WorkerRequest::Command(command)).map_err(|_| TuiError("control worker busy".into()))?;
app.status = "control command queued".into();
}
TuiAction::Request(request) => {
work_tx.try_send(WorkerRequest::Request(request)).map_err(|_| TuiError("control worker busy".into()))?;
app.status = "memory request queued".into();
}
}
}
_ = refresh.tick() => {
@@ -2471,6 +2832,12 @@ pub async fn run_terminal_with_preferences(
app.refresh_millis = result.elapsed_millis;
app.sample_performance();
app.status = result.status;
if app.memory_refresh_pending {
app.memory_refresh_pending = false;
if let Some(request) = app.current_memory_request() {
let _ = work_tx.try_send(WorkerRequest::Request(request));
}
}
}
Err(error) => {
app.record_error(error.to_string());
@@ -2485,6 +2852,7 @@ pub async fn run_terminal_with_preferences(
enum WorkerRequest {
Refresh,
Command(OperatorCommand),
Request(ControlRequest),
}
struct WorkerResult {
@@ -2528,11 +2896,16 @@ async fn worker_cycle(
payloads.push(worker_send(client, sequence, command.request()).await?);
"command completed".to_owned()
}
WorkerRequest::Request(request) => {
payloads.push(worker_send(client, sequence, request).await?);
return Ok((payloads, "memory request completed".to_owned()));
}
};
for request in [
ControlRequest::Health,
ControlRequest::Runtime,
ControlRequest::Metrics,
ControlRequest::MemoryHealth,
ControlRequest::ListSessions { page: page() },
ControlRequest::ListScheduledJobs { page: page() },
ControlRequest::ListPendingApprovals { page: page() },
@@ -2611,9 +2984,35 @@ fn map_event(app: &OperatorTui, value: &event::Event) -> Option<TuiInput> {
event::KeyCode::BackTab | event::KeyCode::Left => Some(TuiInput::PreviousScreen),
event::KeyCode::Up => Some(TuiInput::ScrollUp),
event::KeyCode::Down => Some(TuiInput::ScrollDown),
event::KeyCode::Char('/') if app.screen == OperatorScreen::Timeline => {
event::KeyCode::Char('/')
if matches!(
app.screen,
OperatorScreen::Timeline | OperatorScreen::Memories
) =>
{
Some(TuiInput::BeginFilter)
}
event::KeyCode::Enter if app.screen == OperatorScreen::Memories => {
Some(TuiInput::MemoryLoad)
}
event::KeyCode::Char('g') if app.screen == OperatorScreen::Memories => {
Some(TuiInput::MemoryNextAgent)
}
event::KeyCode::Char('n') if app.screen == OperatorScreen::Memories => {
Some(TuiInput::MemoryNextPage)
}
event::KeyCode::Char('b') if app.screen == OperatorScreen::Memories => {
Some(TuiInput::MemoryPreviousPage)
}
event::KeyCode::Char('k') if app.screen == OperatorScreen::Memories => {
Some(TuiInput::MemoryCycleKind)
}
event::KeyCode::Char('i') if app.screen == OperatorScreen::Memories => {
Some(TuiInput::MemoryTogglePinned)
}
event::KeyCode::Char('o') if app.screen == OperatorScreen::Memories => {
Some(TuiInput::MemoryCycleSort)
}
event::KeyCode::Char('r') => Some(TuiInput::Refresh),
event::KeyCode::Char(key @ ('p' | 'u' | 'f' | 'a' | 'd' | 'x' | 'e' | 't' | 's')) => {
app.command_shortcut(key).map(TuiInput::Command)

View File

@@ -80,6 +80,12 @@ impl TuiTransport for FakeTransport {
})
}
ControlRequest::Metrics => ControlPayload::Metrics(empty_metrics()),
ControlRequest::MemoryHealth => {
ControlPayload::MemoryHealth(crate::MemoryHealthView {
agent_count: 1,
record_count: 2,
})
}
_ => ControlPayload::Completed,
};
Ok(ControlResponseEnvelope {
@@ -140,7 +146,7 @@ async fn snapshot_refresh_is_transport_neutral_and_bounded() {
app.snapshot.health.as_ref().expect("health").service_state,
"running"
);
assert_eq!(transport.0.lock().expect("requests").len(), 8);
assert_eq!(transport.0.lock().expect("requests").len(), 9);
app.refresh(&transport).await.expect("stable refresh");
assert_eq!(
app.snapshot.timeline.len(),
@@ -149,6 +155,54 @@ async fn snapshot_refresh_is_transport_neutral_and_bounded() {
);
}
#[test]
fn memory_panel_builds_filtered_requests_and_confirms_forget() {
let mut app = OperatorTui::default();
assert_eq!(app.reduce(TuiInput::NextScreen), TuiAction::None);
assert!(matches!(
app.reduce(TuiInput::NextScreen),
TuiAction::Request(ControlRequest::ListMemoryAgents { .. })
));
app.snapshot.memory_agents.push(crate::MemoryAgentView {
avatar_id: "avatar-a".into(),
logical_agent_id: "logical-a".into(),
mentra_agent_id: "mentra-a".into(),
record_count: 1,
});
app.snapshot.memories.push(crate::MemoryRecordView {
record_id: "fact:1".into(),
kind: crate::MemoryKindView::Fact,
preview: "durable detail".into(),
source: Some("manual".into()),
source_revision: 1,
pinned: true,
created_at: 7,
score: None,
});
let forget = app.command_shortcut('d').expect("forget command");
assert_eq!(forget.confirmation(), CommandConfirmation::HighRisk);
app.reduce(TuiInput::BeginFilter);
for character in "source:manual hello".chars() {
app.reduce(TuiInput::FilterCharacter(character));
}
let TuiAction::Request(ControlRequest::SearchMemories { query, filter, .. }) =
app.reduce(TuiInput::FilterCommit)
else {
panic!("memory search request")
};
assert_eq!(query, "hello");
assert_eq!(filter.source.as_deref(), Some("manual"));
assert!(
app.render(TuiRenderOptions {
width: 120,
height: 30,
color: false
})
.contains("durable detail")
);
}
#[tokio::test]
async fn performance_history_is_bounded_and_large_dashboards_are_deterministic() {
let transport = FakeTransport::new();
@@ -167,6 +221,7 @@ async fn performance_history_is_bounded_and_large_dashboards_are_deterministic()
for (screen, marker) in [
(OperatorScreen::Overview, "Runtime"),
(OperatorScreen::Sessions, "Sessions"),
(OperatorScreen::Memories, "Mentra durable memory"),
(OperatorScreen::QueuesAndBudgets, "Work queue"),
(OperatorScreen::Roaming, "Roaming schedules"),
(OperatorScreen::Approvals, "Pending approvals"),
@@ -239,6 +294,7 @@ fn every_screen_renders_without_a_terminal_at_small_unicode_and_mono_sizes() {
for screen in [
OperatorScreen::Overview,
OperatorScreen::Sessions,
OperatorScreen::Memories,
OperatorScreen::QueuesAndBudgets,
OperatorScreen::Roaming,
OperatorScreen::Approvals,

View File

@@ -58,10 +58,10 @@ fn package_has_only_reviewed_rust_dependencies_and_no_build_script() {
#[test]
fn runtime_source_has_no_subprocess_or_native_abi_escape_hatch() {
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = Vec::with_capacity(38);
let mut files = Vec::with_capacity(39);
collect_rust_files(&source, &mut files);
assert!(
files.len() <= 38,
files.len() <= 39,
"source-file count needs a reviewed bound update"
);
for path in files {

View File

@@ -48,15 +48,27 @@ The JSON request envelope is stable and versioned. For example:
{"version":1,"request_id":"health-1","request":{"method":"health"}}
```
Observers can call `health`, `metrics`, `runtime`, `list_sessions`,
Observers can call `health`, `metrics`, `runtime`, `memory_health`, `list_sessions`,
`list_scheduled_jobs`, `list_pending_approvals`, `list_audit_events`,
`list_observability_events`, and `subscribe_events`. Operators can additionally
call `cancel_request`, `pause_autonomy`, `resume_autonomy`, `cancel_action`,
`decide_approval`, `force_reconnect`, `expire_conversation`, `set_roaming_job`,
`inject_operator_message`, and `graceful_shutdown`. Cancellation is a mutation
`list_memory_agents`, `browse_memories`, `search_memories`, `get_memory`,
`forget_memory`, `inject_operator_message`, and `graceful_shutdown`. Memory
identity and content operations enforce operator role in the runtime target as
well as at the mutation gate; observers receive aggregate memory health only.
Cancellation is a mutation
and is operator-only. List requests use an opaque numeric cursor and a page
size of 1 through 100.
Memory agents come from the persisted authenticated-avatar mapping written
when an authorized IM agent is used; names are never parsed to recover that
association. Browsing uses Mentra-owned stable `(created_at, record_id)`
keyset cursors, filters and tombstones. Queries, pages, previews, detail content,
metadata, concurrent requests, and response frames remain bounded. Forget uses
Mentra's agent-scoped tombstone operation and audit records contain identifiers
and outcomes, never memory content.
Runtime projections contain lifecycle/readiness, session generation, safe
region and pose fields when known, behavior mode, control-queue utilization,
and aggregate budget use. Conversation responses contain metadata only. Audit

View File

@@ -36,6 +36,9 @@ learning without displacing the command being handled.
Runtime records use `HybridRuntimeStore`: conversation/runtime state is stored
in `runtime.sqlite`, with the associated Mentra memory store and transcript,
task, team, and workspace paths under the configured state directory.
`memory-agents.json` atomically persists the authenticated avatar, logical
agent key, and actual Mentra agent ID for operator browsing. This explicit
mapping is restored independently of generated agent names.
## Tools and autonomous safety review

View File

@@ -36,10 +36,18 @@ The Overview dashboard shows readiness, region/pose, behavior, build and visual
work, approvals, and recent failures. Queues & budgets shows real queue and
rate-limit gauges, active work, outcome counts, dropped events, aggregate
latency, and a bounded 120-refresh history. Sessions, roaming schedules,
approvals, timeline/audit, errors, diagnostics, health, and preferences retain
approvals, durable memory, timeline/audit, errors, diagnostics, health, and preferences retain
focused tables or panels and contextual detail. Missing backend data is marked
unavailable rather than estimated.
The Memory panel is operator-only. Enter loads the selected agent or record,
`g` changes the explicitly mapped avatar agent, `n`/`b` page, `k` cycles record
kind, `i` cycles pinned state, `o` changes sort, and `d` confirms a durable
tombstone. `/` searches content; `source:value`, `from:unix-seconds`, and
`to:unix-seconds` tokens set provenance and creation-time filters. Empty search
text browses all non-tombstoned records. Observer clients receive aggregate
agent/record health only and never memory identity, preview, or content.
Use Tab/Shift-Tab or Left/Right to change panels and Up/Down to select rows.
`/` searches event, audit, correlation, time, duration, result, and reason data;
Enter applies the search and Esc clears it. `r` refreshes; `p`/`u` pause or
@@ -60,9 +68,11 @@ when the bounded worker queue is full. The terminal guard restores raw mode,
cursor visibility, and the alternate screen on success, error, Ctrl-C, or
panic unwinding.
Diagnostic panels show only explicitly captured redacted envelopes. Prompt
and response content, API keys, grid passwords, operator tokens, capability
URLs, and model reasoning have no TUI representation.
Diagnostic panels show only explicitly captured redacted envelopes. Memory
content appears only in the operator-only Memory detail panel; it is never
copied into diagnostics, audit events, or observer responses. API keys, grid
passwords, operator tokens, capability URLs, and model reasoning have no TUI
representation.
Focused verification:

2082
vendor/mentra/Cargo.lock generated vendored Normal file

File diff suppressed because it is too large Load Diff

177
vendor/mentra/Cargo.toml vendored Normal file
View File

@@ -0,0 +1,177 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2024"
rust-version = "1.88"
name = "mentra"
version = "0.18.3"
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "An agent runtime for tool-using LLM applications"
homepage = "https://github.com/oops-rs/mentra"
documentation = "https://docs.rs/mentra"
readme = "README.md"
keywords = [
"agents",
"llm",
"runtime",
"tools",
"ai",
]
categories = [
"asynchronous",
"development-tools",
]
license = "MIT"
repository = "https://github.com/oops-rs/mentra"
[features]
default = ["responses-websocket"]
openai-oauth = ["dep:ring"]
responses-websocket = ["mentra-provider/responses-websocket"]
test-utils = []
[lib]
name = "mentra"
path = "src/lib.rs"
[[test]]
name = "agent_runtime"
path = "tests/agent_runtime.rs"
[[test]]
name = "branching"
path = "tests/branching.rs"
[[test]]
name = "mcp_sse_smoke"
path = "tests/mcp_sse_smoke.rs"
[[test]]
name = "public_api"
path = "tests/public_api.rs"
[[test]]
name = "responses_transport"
path = "tests/responses_transport.rs"
[[test]]
name = "skills_api"
path = "tests/skills_api.rs"
[[bench]]
name = "long_session"
path = "benches/long_session.rs"
harness = false
required-features = ["test-utils"]
[dependencies.async-trait]
version = "0.1.89"
[dependencies.base64]
version = "0.22.1"
[dependencies.directories]
version = "6.0.0"
[dependencies.futures-util]
version = "0.3.31"
[dependencies.glob-match]
version = "0.2"
[dependencies.libc]
version = "0.2"
[dependencies.mentra-provider]
version = "0.5.1"
default-features = false
[dependencies.rand]
version = "0.9.2"
[dependencies.regex]
version = "1.12.2"
[dependencies.reqwest]
version = "0.12.23"
features = [
"json",
"rustls-tls",
"stream",
]
default-features = false
[dependencies.ring]
version = "0.17.14"
optional = true
[dependencies.rusqlite]
version = "0.39"
features = ["bundled"]
[dependencies.serde]
version = "1.0.228"
features = ["derive"]
[dependencies.serde_json]
version = "1.0.149"
[dependencies.serde_yaml_ng]
version = "0.10"
[dependencies.similar]
version = "2.7.0"
[dependencies.strum]
version = "0.27"
features = ["derive"]
[dependencies.thiserror]
version = "2.0.18"
[dependencies.time]
version = "0.3"
features = [
"formatting",
"parsing",
"serde",
]
[dependencies.tokio]
version = "1.50.0"
features = ["full"]
[dependencies.unicode-normalization]
version = "0.1.24"
[dependencies.url]
version = "2.5"
[dev-dependencies.criterion]
version = "0.5"
features = ["async_tokio"]
[dev-dependencies.tokio]
version = "1.50.0"
features = ["test-util"]
[target."cfg(windows)".dependencies.windows-sys]
version = "0.61.2"
features = [
"Win32_Foundation",
"Win32_System_Threading",
]

68
vendor/mentra/Cargo.toml.orig generated vendored Normal file
View File

@@ -0,0 +1,68 @@
[package]
name = "mentra"
version = "0.18.3"
edition.workspace = true
rust-version.workspace = true
description = "An agent runtime for tool-using LLM applications"
license.workspace = true
repository.workspace = true
homepage.workspace = true
documentation = "https://docs.rs/mentra"
readme = "README.md"
keywords = ["agents", "llm", "runtime", "tools", "ai"]
categories = ["asynchronous", "development-tools"]
[features]
# Default-on so an existing dependant sees no change: every caller who linked
# mentra before this feature existed had the Responses websocket transport, and
# an upgrade should not quietly take a transport away. Forwarded rather than
# left to mentra-provider's own default (which is why the dependency below sets
# `default-features = false`) so a host that only ever streams over HTTP+SSE can
# turn it off here and drop tokio-tungstenite from its tree.
default = ["responses-websocket"]
responses-websocket = ["mentra-provider/responses-websocket"]
openai-oauth = ["dep:ring"]
test-utils = []
[dependencies]
mentra-provider = { version = "0.5.1", path = "../mentra-provider", default-features = false }
tokio = { version = "1.50.0", features = ["full"] }
reqwest = { version = "0.12.23", default-features = false, features = [
"json",
"rustls-tls",
"stream",
] }
url = "2.5"
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
async-trait = "0.1.89"
futures-util = "0.3.31"
time = { version = "0.3", features = ["formatting", "parsing", "serde"] }
base64 = "0.22.1"
serde_yaml_ng = "0.10"
directories = "6.0.0"
rusqlite = { version = "0.39", features = ["bundled"] }
thiserror = "2.0.18"
strum = { version = "0.27", features = ["derive"] }
libc = "0.2"
regex = "1.12.2"
rand = "0.9.2"
ring = { version = "0.17.14", optional = true }
glob-match = { workspace = true }
similar = "2.7.0"
unicode-normalization = "0.1.24"
[dev-dependencies]
criterion = { version = "0.5", features = ["async_tokio"] }
# Tests only: `start_paused` lets a retry schedule measured in tens of seconds
# be asserted without waiting them out. Not enabled for the library build, so
# nothing a dependant links changes.
tokio = { version = "1.50.0", features = ["test-util"] }
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_System_Threading"] }
[[bench]]
name = "long_session"
harness = false
required-features = ["test-utils"]

21
vendor/mentra/LICENSE vendored Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Wendell Wang
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

6
vendor/mentra/METACRATE.md vendored Normal file
View File

@@ -0,0 +1,6 @@
# MetaCrate adaptation
This is Mentra 0.18.3 with a host-facing, bounded memory administration API.
`MemoryStore` adds stable list, agent-scoped detail, and count operations;
the volatile, SQLite, and hybrid stores implement the same contract. Storage
schema and tombstone behavior remain owned by Mentra.

811
vendor/mentra/README.md vendored Normal file
View File

@@ -0,0 +1,811 @@
# mentra
Mentra is an agent runtime for building tool-using LLM applications.
MSRV: Rust 1.88.
## Current Features
- streaming model response handling
- provider-neutral token usage reporting across OpenAI, OpenRouter, Anthropic, Gemini, Ollama, and LM Studio
- optional tool authorization with structured previews and fail-closed execution blocking
- recoverable malformed tool-call input handling that feeds retry guidance back to the model
- custom tool execution through `ToolDefinition + ToolExecutor`, with `ToolSpec::builder(...)` as the convenience metadata API
- builtin `shell`, `background_run`, `check_background`, and `files` tools
- builtin `task` subagents with isolated child context and parent-side tracking
- persistent agent teams with `team_spawn`, `team_send`, `broadcast`, `team_read_inbox`, and generic request-response protocols via `team_request`, `team_respond`, and `team_list_requests`
- three-layer context compaction with silent tool-result shrinking, auto-summary compaction, and a builtin `compact` tool
- Model Context Protocol servers over stdio and the legacy HTTP+SSE transport, with their tools bridged into the runtime
- agent events and snapshots for CLI or UI watchers
- Anthropic provider support
- Gemini Developer API provider support
- OpenAI provider support via the Responses API
- OpenRouter provider support via the Responses API
- Ollama provider support via the OpenAI-compatible Responses API
- LM Studio provider support via the OpenAI-compatible Responses API
- image inputs for OpenAI and Anthropic, plus inline image bytes for Gemini
## Quickstart Example
Clone the repository and run the workspace quickstart example:
```bash
cargo run -p mentra-examples --example quickstart -- "Summarize the benefits of tool-using agents."
```
The quickstart example accepts a prompt from CLI args or stdin. Set `MENTRA_MODEL` to force a specific OpenAI model; otherwise it resolves the newest available OpenAI model automatically.
## Building A Runtime
Use `Runtime::builder()` when you want Mentra's builtin runtime tools, or `Runtime::empty_builder()` when you want to opt into every tool explicitly.
```rust,no_run
use mentra::{BuiltinProvider, Runtime};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let runtime = Runtime::builder()
.with_provider(BuiltinProvider::OpenAI, std::env::var("OPENAI_API_KEY")?)
.with_optional_provider(
BuiltinProvider::OpenRouter,
std::env::var("OPENROUTER_API_KEY").ok(),
)
.with_optional_provider(
BuiltinProvider::Gemini,
std::env::var("GEMINI_API_KEY").ok(),
)
.with_ollama()
.with_lmstudio()
.build()?;
let _ = runtime;
Ok(())
}
```
`with_ollama()` targets `http://127.0.0.1:11434/` and `with_lmstudio()` targets
`http://127.0.0.1:1234/`, using each server's OpenAI-compatible API surface.
## Custom Compatible Providers
If you need a non-default OpenAI-compatible or Anthropic-compatible endpoint,
register a provider-core instance with a customized `ProviderDefinition`.
Using a distinct provider ID lets you keep the builtin provider alongside your
custom endpoint.
```rust,no_run
use mentra::{ModelSelector, ProviderId, Runtime};
# async fn demo() -> Result<(), Box<dyn std::error::Error>> {
let mut definition = mentra::provider_core::responses::openai_definition();
definition.descriptor.id = ProviderId::new("custom-openai-compatible");
definition.descriptor.display_name = Some("Custom OpenAI-Compatible".to_string());
definition.base_url = Some("https://llm.example.com/".to_string());
let runtime = Runtime::builder()
.with_registered_provider(mentra::provider_core::responses::ResponsesProvider::new(
definition,
mentra::provider_core::StaticCredentialSource::new(std::env::var("CUSTOM_API_KEY")?),
))
.build()?;
let model = runtime
.resolve_model(
ProviderId::new("custom-openai-compatible"),
ModelSelector::NewestAvailable,
)
.await?;
# let _ = model;
# Ok(())
# }
```
Anthropic-compatible endpoints follow the same pattern:
```rust,no_run
use mentra::{ProviderId, Runtime};
# fn demo() -> Result<(), Box<dyn std::error::Error>> {
let mut definition = mentra::provider_core::anthropic::definition();
definition.descriptor.id = ProviderId::new("custom-anthropic-compatible");
definition.descriptor.display_name = Some("Custom Anthropic-Compatible".to_string());
definition.base_url = Some("https://claude.example.com/".to_string());
let runtime = Runtime::builder()
.with_registered_provider(
mentra::provider_core::anthropic::AnthropicProvider::with_definition_and_credential_source(
definition,
mentra::provider_core::StaticCredentialSource::new(std::env::var("CUSTOM_API_KEY")?),
),
)
.build()?;
# let _ = runtime;
# Ok(())
# }
```
If your compatible endpoint needs different auth or extra headers, mutate the
definition's `auth_scheme`, `headers`, `query_params`, or `retry` fields before
registering it.
## Architecture
Mentra is organized around four runtime subsystems:
- execution: model providers, runtime policy, hooks, turn execution, and shell/background command routing
- persistence: agent records, run state, task snapshots, leases, team state, background notifications, and memory
- tooling: builtin and custom tools, optional skills, and typed app context
- collaboration: persistent teammates, team inbox/request flows, and background task wakeups
Persistent teammates are hosted as async actors on a shared Tokio runtime. Live actors are wake-driven rather than steady-state polled: inbox appends, protocol updates, background task completion, explicit resume, and autonomy timers wake the actor to process durable state already written to the store. After a restart, the persisted team inbox, protocol requests, and background notifications remain the source of truth, and `Runtime::resume(...)` revives teammate actors against that stored state.
## Resolving A Model
Use `Runtime::resolve_model(...)` when you want provider-aware model selection without reimplementing discovery or `ModelInfo` construction in application code.
```rust,no_run
use mentra::{BuiltinProvider, ModelSelector, Runtime};
# async fn demo() -> Result<(), Box<dyn std::error::Error>> {
let runtime = Runtime::builder()
.with_provider(BuiltinProvider::OpenAI, std::env::var("OPENAI_API_KEY")?)
.build()?;
let model = runtime
.resolve_model(
BuiltinProvider::OpenAI,
std::env::var("MENTRA_MODEL")
.map(ModelSelector::Id)
.unwrap_or(ModelSelector::NewestAvailable),
)
.await?;
let _ = model;
# Ok(())
# }
```
## Coding Agent Setup
`Runtime::builder()` registers Mentra's builtin tools, including `shell`, `background_run`, `check_background`, `files`, and the runtime/task/team intrinsics. Shell and background execution remain disabled by default, so coding-agent setups must opt in with a runtime policy. If you want semantic review before tools execute, install a `ToolAuthorizer`.
The builtin local executor is a host executor, not a filesystem or network
sandbox. `RuntimePolicy::permissive()` therefore grants the model the same host
access as the Mentra process. Use it only inside a disposable container or
another boundary you trust. On a normal host, install an OS-enforced custom
executor with `RuntimeBuilder::with_executor(...)`; authorization and shell
validation decide whether a command may start, but they do not contain an
allowed command.
For Responses API transport, xipe-compatible endpoints, and provider-side state
options, see the workspace
[`Responses Coding Agent Guide`](../docs/responses-coding-agent.md).
```rust,no_run
use async_trait::async_trait;
use mentra::{BuiltinProvider, Runtime, RuntimePolicy};
use mentra::tool::{
ToolAuthorizationDecision, ToolAuthorizationRequest, ToolAuthorizer,
};
struct AllowAllAuthorizer;
#[async_trait]
impl ToolAuthorizer for AllowAllAuthorizer {
async fn authorize(
&self,
_request: &ToolAuthorizationRequest,
) -> Result<ToolAuthorizationDecision, mentra::error::RuntimeError> {
Ok(ToolAuthorizationDecision::allow())
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let runtime = Runtime::builder()
.with_provider(BuiltinProvider::OpenAI, std::env::var("OPENAI_API_KEY")?)
// Full host shell access. Use only inside a trusted external sandbox.
.with_policy(RuntimePolicy::permissive())
.with_tool_authorizer(AllowAllAuthorizer)
.build()?;
let _ = runtime;
Ok(())
}
```
## Runtime Policy Defaults
Mentra's builtin runtime tools are available by default, but command execution is not:
- `Runtime::builder()` registers the builtin shell, background, file, task, team, and memory-oriented intrinsics
- foreground shell execution is disabled by default
- background command execution is disabled by default
- `RuntimePolicy::permissive()` enables both shell and background command execution
- `RuntimePolicy::workspace_bounded(...)` and `RuntimePolicy::read_only(...)` keep shell execution disabled; their roots constrain builtin file tools and the requested shell working directory, not shell process effects
- builtin shell commands run through `/bin/sh -c` on Unix and `cmd.exe /C` on Windows
- the local executor clears unlisted environment variables and enforces timeouts, output caps, and process-tree cleanup on timeout, but it does not restrict filesystem or network access
- semantic review is opt-in through `RuntimeBuilder::with_tool_authorizer(...)`
Use the default policy when you want a safer runtime surface. Opt into
`RuntimePolicy::permissive()` only when an external sandbox already contains the
entire Mentra process and full host access is intentional.
If you need different command semantics, such as PowerShell on Windows, or
filesystem/network confinement, replace the default local executor with
`RuntimeBuilder::with_executor(...)`. A workspace-bounded or read-only policy
can then explicitly enable foreground and background shell switches; Mentra
treats that executor as a trusted enforcement boundary and does not fall back
to the local executor.
## Tool Authorization
Mentra can run a caller-provided authorization pass before any tool executes. This is the recommended integration point for LLM-based security review, human approval, or custom policy engines.
- no authorizer installed: tools run under the remaining hard runtime constraints
- authorizer returns `Allow`: the tool executes
- authorizer returns `Prompt` or `Deny`: Mentra blocks execution and returns an error `tool_result`
- authorizer timeout or error: Mentra fails closed and blocks execution
Every authorization request includes a `ToolAuthorizationPreview` with tool metadata plus structured input. Builtin tools provide more specific previews:
- `shell` and `background_run` include the raw command, resolved working directory, timeout, background flag, and justification
- `files` includes resolved paths and operation kinds such as `read`, `search`, `set`, `move`, and `delete`, without file contents
```rust,no_run
use async_trait::async_trait;
use mentra::tool::{
ToolAuthorizationDecision, ToolAuthorizationRequest, ToolAuthorizer,
};
struct DenyDeletes;
#[async_trait]
impl ToolAuthorizer for DenyDeletes {
async fn authorize(
&self,
request: &ToolAuthorizationRequest,
) -> Result<ToolAuthorizationDecision, mentra::error::RuntimeError> {
let structured = &request.preview.structured_input;
let denies_delete = structured
.get("operations")
.and_then(|value| value.as_array())
.is_some_and(|ops| ops.iter().any(|op| op.get("op").and_then(|v| v.as_str()) == Some("delete")));
if request.tool_name == "files" && denies_delete {
Ok(ToolAuthorizationDecision::deny("delete operations require manual approval"))
} else {
Ok(ToolAuthorizationDecision::allow())
}
}
}
```
Registering a skills directory also makes the builtin `load_skill` tool available:
```rust,no_run
use mentra::{BuiltinProvider, Runtime};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let runtime = Runtime::builder()
.with_provider(BuiltinProvider::OpenAI, std::env::var("OPENAI_API_KEY")?)
.with_skills_dir("./skills")?
.build()?;
let _ = runtime;
Ok(())
}
```
## App Context
If your tools need access to typed host-side state, register it on the runtime and retrieve it from `ToolContext` or `ParallelToolContext`:
```rust,no_run
use std::sync::Arc;
use async_trait::async_trait;
use mentra::{
BuiltinProvider, Runtime,
tool::{ToolContext, ToolDefinition, ToolExecutor, ToolResult, ToolSpec},
};
use serde_json::{Value, json};
struct AppState {
api_base: String,
}
struct InspectStateTool;
impl ToolDefinition for InspectStateTool {
fn descriptor(&self) -> ToolSpec {
ToolSpec::builder("inspect_state")
.description("Return the configured API base URL.")
.input_schema(json!({
"type": "object",
"properties": {}
}))
.build()
}
}
#[async_trait]
impl ToolExecutor for InspectStateTool {
async fn execute_mut(&self, ctx: ToolContext<'_>, _input: Value) -> ToolResult {
let state = ctx.app_context::<AppState>()?;
Ok(state.api_base.clone())
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let runtime = Runtime::builder()
.with_provider(BuiltinProvider::OpenAI, std::env::var("OPENAI_API_KEY")?)
.with_context(Arc::new(AppState {
api_base: "https://api.example.com".to_string(),
}))
.with_tool(InspectStateTool)
.build()?;
let _ = runtime;
Ok(())
}
```
## Custom Tools
Use `ToolSpec::builder(...)` to define custom tools without hand-assembling the metadata struct:
```rust,no_run
use async_trait::async_trait;
use mentra::tool::{
ParallelToolContext, ToolCapability, ToolDefinition, ToolDurability, ToolExecutor,
ToolResult, ToolSideEffectLevel, ToolSpec,
};
use serde_json::{Value, json};
struct UppercaseTool;
impl ToolDefinition for UppercaseTool {
fn descriptor(&self) -> ToolSpec {
ToolSpec::builder("uppercase_text")
.description("Uppercase the provided text")
.input_schema(json!({
"type": "object",
"properties": {
"text": { "type": "string" }
},
"required": ["text"]
}))
.capability(ToolCapability::ReadOnly)
.side_effect_level(ToolSideEffectLevel::None)
.durability(ToolDurability::ReplaySafe)
.execution_timeout(std::time::Duration::from_secs(5))
.build()
}
}
#[async_trait]
impl ToolExecutor for UppercaseTool {
async fn execute(&self, _ctx: ParallelToolContext, input: Value) -> ToolResult {
let text = input
.get("text")
.and_then(|value| value.as_str())
.ok_or_else(|| "text is required".to_string())?;
Ok(text.to_uppercase())
}
}
```
`ToolSpec::execution_timeout(...)` is enforced by Mentra around the tool future itself, which is useful for network-backed tools that need a tighter budget than the overall agent run.
Internally, Mentra translates `ToolSpec` into a runtime-only `RuntimeToolDescriptor`, but custom runtime integrations should continue to treat `ToolSpec::builder(...)` as the supported public metadata surface. `ExecutableTool` remains available in this release as a compatibility trait alias over `ToolDefinition + ToolExecutor`.
When a tool needs disposable delegated work, `ParallelToolContext::spawn_subagent()` can create a child agent that inherits the current runtime and model defaults. See the `subagent_tool` example in the workspace examples crate for a complete usage pattern.
Override `ToolExecutor::authorization_preview(...)` when your custom tool needs to expose structured metadata to the installed `ToolAuthorizer`. The default preview includes the resolved working directory, tool capabilities, side-effect level, durability, the raw JSON input, and the same JSON as `structured_input`.
## Tooling Layers
Mentra now separates tool contracts into explicit layers:
- `ProviderToolSpec` in `mentra-provider` for provider-facing serialization
- `RuntimeToolDescriptor` in Mentra for scheduling, approval, and durability metadata
- `ToolDefinition + ToolExecutor` for executable runtime tools
Provider adapters should serialize provider-facing tool specs only. Runtime integrations should continue to implement custom tools with `ToolSpec::builder(...)`, `ToolDefinition`, and `ToolExecutor`.
## Hosted Tool Search
Mentra can mark custom tools as deferred and let a provider load them on demand with native hosted tool search.
Mark a tool as deferred in its `ToolSpec`:
```rust,no_run
use async_trait::async_trait;
use mentra::tool::{ParallelToolContext, ToolDefinition, ToolExecutor, ToolResult, ToolSpec};
use serde_json::{Value, json};
struct LookupOrderTool;
impl ToolDefinition for LookupOrderTool {
fn descriptor(&self) -> ToolSpec {
ToolSpec::builder("lookup_order")
.description("Look up an order by id.")
.input_schema(json!({
"type": "object",
"properties": {
"order_id": { "type": "string" }
},
"required": ["order_id"]
}))
.defer_loading(true)
.build()
}
}
#[async_trait]
impl ToolExecutor for LookupOrderTool {
async fn execute(&self, _ctx: ParallelToolContext, _input: Value) -> ToolResult {
Ok("order loaded".to_string())
}
}
```
Enable hosted tool search per agent with `ProviderRequestOptions`:
```rust,no_run
use mentra::agent::AgentConfig;
use mentra::provider::{ProviderRequestOptions, ReasoningEffort, ReasoningOptions, ToolSearchMode};
let config = AgentConfig {
provider_request_options: ProviderRequestOptions {
tool_search_mode: ToolSearchMode::Hosted,
reasoning: Some(ReasoningOptions {
effort: Some(ReasoningEffort::Medium),
summary: None,
}),
..Default::default()
},
..Default::default()
};
```
Current provider support:
- OpenAI: supported through the Responses API hosted `tool_search` surface
- Anthropic: supported through the Messages API BM25 tool-search server tool
- Gemini: deferred custom tools are not supported; Mentra returns `InvalidRequest`
Reasoning effort support:
- The shared levels are `low`, `medium`, `high`, `xhigh`, and `max`; omitting
effort leaves the provider default unchanged.
- OpenAI and OpenRouter: Mentra forwards all five levels as
`reasoning.effort` on the Responses API.
- Anthropic: Mentra writes the requested level to `output_config.effort` and
enables adaptive thinking on models that support it. Opus 4.5 accepts
`low`/`medium`/`high` effort without adaptive thinking; availability of
`xhigh` and `max` depends on the Claude model.
- Gemini: Mentra maps the shared `low`, `medium`, and `high` levels to
`thinkingLevel` on Gemini 3 models, subject to that model's accepted values.
`xhigh` and `max` return `InvalidRequest` instead of being silently
downgraded.
- Anthropic models without effort support and Gemini models older than 3 return
`InvalidRequest` when unified reasoning effort is set.
Deferred tools are filtered through `ToolProfile` just like immediate tools. If you force a deferred tool with `ToolChoice::Tool { name }`, Mentra serializes that specific tool as immediate for the request so explicit invocation still works.
## Model Context Protocol Servers
Mentra connects to external MCP servers and bridges every tool they advertise
into the runtime under a namespaced `mcp__<server>__<tool>` name. Bridged tools
run through the same authorization, result limiter, and paging path as builtin
and custom tools.
Two transports are supported, selected by which configuration type you register.
**stdio** spawns the server as a child process:
```rust,no_run
use mentra::{BuiltinProvider, McpServerConfig, Runtime};
# async fn demo() -> Result<(), Box<dyn std::error::Error>> {
let runtime = Runtime::builder()
.with_provider(BuiltinProvider::Anthropic, std::env::var("ANTHROPIC_API_KEY")?)
.with_mcp_server(McpServerConfig {
name: "filesystem".to_string(),
command: "npx".to_string(),
args: vec![
"-y".to_string(),
"@modelcontextprotocol/server-filesystem".to_string(),
"/tmp".to_string(),
],
env: Default::default(),
cwd: None,
})
.build_async()
.await?;
# let _ = runtime;
# Ok(())
# }
```
**Legacy HTTP+SSE** reaches a hosted server over the network:
```rust,no_run
use mentra::{BuiltinProvider, McpSseServerConfig, Runtime};
# async fn demo() -> Result<(), Box<dyn std::error::Error>> {
let runtime = Runtime::builder()
.with_provider(BuiltinProvider::Anthropic, std::env::var("ANTHROPIC_API_KEY")?)
.with_mcp_sse_server(
McpSseServerConfig::new("observability", "https://mcp.example.com/sse")
.with_bearer_token(std::env::var("MCP_TOKEN")?),
)
.build_async()
.await?;
# let _ = runtime;
# Ok(())
# }
```
A server that answers `404` on `/mcp` but serves `/sse` needs this transport.
### HTTP+SSE is not Streamable HTTP
`McpSseServerConfig` speaks the transport from MCP protocol revision
`2024-11-05`, which is a different protocol from the newer Streamable HTTP:
| | legacy HTTP+SSE | Streamable HTTP |
|---|---|---|
| Endpoints | a `GET` stream plus a separate `POST` URL | one URL for both |
| POST target | named by the server in an `endpoint` event | the configured URL |
| Responses | always on the `GET` stream | in the POST response or a stream |
| Session | a query parameter in the endpoint URL | the `Mcp-Session-Id` header |
The client opens the configured URL with `Accept: text/event-stream`, waits for
an `endpoint` event naming the POST URL, then posts `initialize`, a
`notifications/initialized` notification, and a paginated `tools/list`. Servers
answer each POST `202 Accepted` and deliver the actual JSON-RPC result as a
`message` event on the stream.
### Security and failure behavior
The endpoint URL is chosen by the server, so it is validated before anything is
sent to it. A resolved endpoint must match the configured URL's scheme, host,
and effective port; a cross-origin endpoint, a protocol-relative `//other.host`
value, embedded credentials, and non-`http(s)` schemes are all refused. Redirects
are never followed on either request.
Configured headers are sent on both the stream and every POST, stored as
`SecretString` so they never appear in `Debug` output, errors, or logs.
Configuring headers against a plaintext `http://` URL on a non-loopback host is
rejected unless `allowing_plaintext_credentials()` is set. No error carries a
response body or SSE payload, so a malicious server cannot write text into your
logs.
Losing the stream ends the session — the client fails closed rather than
hanging, and never reconnects or re-sends a `tools/call`. A call whose response
never arrived surfaces as `McpSseError::RequestIndeterminate`, because the POST
and the response travel on different connections: the tool may have run. Treat
that differently from a rejected POST, which definitely did not execute.
### Using the client directly
Hosts that need their own allowlist, redaction, or evidence policy can drive
`McpSseClient` without registering anything:
```rust,no_run
use mentra::{McpSseClient, McpSseServerConfig};
# async fn demo() -> Result<(), Box<dyn std::error::Error>> {
let config = McpSseServerConfig::new("observability", "https://mcp.example.com/sse")
.with_bearer_token(std::env::var("MCP_TOKEN")?);
let client = McpSseClient::connect(&config).await?;
for tool in client.tools() {
println!("{}", tool.name);
}
let result = client
.call_tool("search_logs", Some(serde_json::json!({"query": "error"})))
.await?;
println!("{}", result.is_error);
client.shutdown().await;
# Ok(())
# }
```
## Tool Profiles
Register tools once on the runtime, then use `AgentConfig::tool_profile` to expose different subsets for different operating modes.
```rust,no_run
use mentra::{BuiltinProvider, ModelSelector, Runtime};
use mentra::agent::{AgentConfig, ToolProfile};
# async fn demo() -> Result<(), Box<dyn std::error::Error>> {
let runtime = Runtime::builder()
.with_provider(BuiltinProvider::OpenAI, std::env::var("OPENAI_API_KEY")?)
.build()?;
let model = runtime
.resolve_model(
BuiltinProvider::OpenAI,
ModelSelector::Id("gpt-5.4-mini".to_string()),
)
.await?;
let queue_mode = AgentConfig {
tool_profile: ToolProfile::only([
"shell",
"background_run",
"check_background",
"files",
"task",
]),
..Default::default()
};
let direct_mode = AgentConfig {
tool_profile: ToolProfile::hide(["task", "background_run"]),
..Default::default()
};
let _queue_agent = runtime.spawn_with_config("Queue Agent", model.clone(), queue_mode)?;
let _direct_agent = runtime.spawn_with_config("Direct Agent", model, direct_mode)?;
# Ok(())
# }
```
This is the recommended pattern when one application needs multiple tool surfaces such as a queue-backed agent with delegation enabled and a direct mode that keeps the same runtime but hides long-running or task-oriented tools.
## CLI Integration Pattern
For CLI-style coding or analysis tools, the usual setup is:
- register a superset of builtin and custom tools on one runtime
- scope shell and file access with `RuntimePolicy`
- keep application-specific output paths in app context for custom tools
- switch behavior per mode by changing `AgentConfig::tool_profile`, not by rebuilding the runtime
- inspect `agent.history()` after the run when you want to render a compact tool log or transcript summary
The `cli_runtime` example in the workspace examples crate shows this pattern end to end with custom tools, policy setup, mode-specific tool surfaces, and transcript inspection.
## Disposable Tasks vs Persistent Teams
Mentra supports two different delegation models:
- use the builtin `task` tool or `ParallelToolContext::spawn_subagent()` for short-lived disposable delegation that should return a single summary to the parent
- use `team_spawn`, `team_send`, `team_read_inbox`, `team_request`, and `team_respond` when you want a persistent teammate with a durable mailbox and request/response workflow across turns
The `task` path is ideal for one-off decomposition inside a single run. The `team_*` tools are for longer-lived collaborators that should keep state, receive follow-up work, and participate in approval or shutdown flows.
## Sending Images
You can attach image blocks alongside text when sending a user turn:
```rust,no_run
# use mentra::{ContentBlock, Agent};
# async fn demo(agent: &mut Agent) -> Result<(), Box<dyn std::error::Error>> {
agent
.send(vec![
ContentBlock::text("What is happening in this screenshot?"),
ContentBlock::image_bytes("image/png", std::fs::read("screenshot.png")?),
])
.await?;
# Ok(())
# }
```
For already-hosted assets, use `ContentBlock::image_url(...)` instead. Gemini currently supports inline `image_bytes(...)` inputs only and rejects `image_url(...)`.
## Long-Term Memory
Agents automatically recall from long-term memory by default. When you use `Runtime::builder()`, the builtin runtime intrinsics include:
- `memory_search` for explicit recall
- `memory_pin` for writing important facts
- `memory_forget` for tombstoning a specific memory record
`MemoryConfig` controls recall and write behavior per agent. The default configuration enables automatic recall and memory write tools, which is useful for long-running assistants and teammate workflows. Disable write tools when you want recall without model-initiated mutation.
## Context Compaction
Agents compact context by default:
- old tool results are micro-compacted in outbound requests
- when estimated request context exceeds roughly 50k tokens, Mentra writes the full transcript to the default transcript directory and replaces older history with a model-generated summary
- the model can also call the builtin `compact` tool explicitly
You can tune or disable this per-agent with `CompactionConfig`:
```rust
use mentra::agent::{AgentConfig, CompactionConfig};
let config = AgentConfig {
compaction: CompactionConfig {
auto_compact_threshold_tokens: Some(75_000),
..Default::default()
},
..Default::default()
};
```
## Data And Persistence Defaults
For non-test builds, Mentra keeps all default persisted state under a workspace-scoped app-data directory:
- store: `<platform data dir>/mentra/workspaces/<workspace-hash>/runtime.sqlite`
- runtime-scoped stores: `<platform data dir>/mentra/workspaces/<workspace-hash>/runtime-<runtime-id>.sqlite`
- team state: `<platform data dir>/mentra/workspaces/<workspace-hash>/team/`
- task state: `<platform data dir>/mentra/workspaces/<workspace-hash>/tasks/`
- transcripts: `<platform data dir>/mentra/workspaces/<workspace-hash>/transcripts/`
If the platform data directory cannot be resolved, Mentra falls back to `.mentra/workspaces/<workspace-hash>/...` inside the current workspace.
Override these defaults when needed:
- use `Runtime::builder().with_store(...)` for the SQLite store
- customize `AgentConfig::task.tasks_dir`, `AgentConfig::team.team_dir`, and `AgentConfig::compaction.transcript_dir` for task, team, and transcript storage
## Persistence Extension Points
The public persistence surface is intentionally split into narrower traits:
- `AgentStore` for agent records and working-memory snapshots
- `RunStore` for turn and run lifecycle tracking
- `TaskStore` for the dependency-aware task board
- `LeaseStore` for runtime ownership and resume coordination
`RuntimeStore` composes those traits with `TeamStore`, `BackgroundStore`, and `MemoryStore`. `SqliteRuntimeStore` is the default all-in-one backend. `HybridRuntimeStore` keeps SQLite runtime state and swaps in the hybrid memory engine for richer long-term memory behavior.
## Testing With MockRuntime
Enable the `test-utils` feature when you want a deterministic scripted runtime for unit and integration tests.
`mentra::test::MockRuntime` wraps a real runtime with:
- a scripted provider
- a `VolatileRuntimeStore`, so a mock writes nothing to disk and two mocks never
share state — pass `MockRuntimeBuilder::with_store` a `SqliteRuntimeStore`
when a test needs state that outlives the mock
- deterministic per-turn helper methods for assistant text, streamed text, tool-call turns, and provider failures
This is the recommended way to test Mentra-based agents and tools without live API keys.
The common pattern is:
- build a `MockRuntime`
- register the same custom tools you use in production
- spawn an agent with the `AgentConfig` or `ToolProfile` you want to verify
- assert against `mock.recorded_requests()` to confirm the runtime exposed the expected tools and tool-choice hints
See `mentra::test` and the crate tests for a full example of asserting runtime assembly with custom tools and filtered tool surfaces.
## Interactive Repo Example
Clone the repository when you want the richer interactive demo with provider selection, persisted runtime inspection, skills loading, and team/task visibility.
Set `OPENAI_API_KEY`, `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, or `GEMINI_API_KEY`, then run. The example lets you choose a provider and shows up to 10 models from that provider ordered newest to oldest.
```bash
cargo run -p mentra-examples --example chat
```
Additional focused examples live in the same crate:
```bash
cargo run -p mentra-examples --example custom_tool
cargo run -p mentra-examples --example subagent_tool
cargo run -p mentra-examples --example team_collaboration
cargo run -p mentra-examples --example cli_runtime -- --mode direct
```
`cli_runtime` is the closest example to a real integration. It combines runtime policy setup, custom tools, mode-specific `ToolProfile` selection, and transcript inspection after the run.
## Run Checks
```bash
cargo fmt --all --check
cargo check --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
```

186
vendor/mentra/benches/long_session.rs vendored Normal file
View File

@@ -0,0 +1,186 @@
use criterion::{Criterion, criterion_group, criterion_main};
use mentra::{
ContentBlock,
memory::{MemoryRecord, MemoryRecordKind, MemorySearchMode, MemorySearchRequest, MemoryStore},
runtime::SqliteRuntimeStore,
test::{MockRuntimeBuilder, MockTurn},
};
use std::time::SystemTime;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
fn temp_sqlite_path(label: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"mentra-bench-{}-{}.sqlite",
label,
SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
))
}
fn build_mock_runtime(
n_turns: usize,
store_path: std::path::PathBuf,
) -> (mentra::test::MockRuntime, mentra::ModelInfo) {
let store = SqliteRuntimeStore::new(store_path);
let mut builder = MockRuntimeBuilder::default().with_store(store);
for i in 0..n_turns {
builder = builder.push_turn(MockTurn::Text(format!("turn {i} response")));
}
let mock = builder.build().expect("build mock runtime");
let model = mock.model();
(mock, model)
}
// ---------------------------------------------------------------------------
// bench_500_turn_session
// Measures total wall time for driving 500 send/response cycles.
// ---------------------------------------------------------------------------
fn bench_500_turn_session(c: &mut Criterion) {
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
c.bench_function("500_turn_session", |b| {
b.to_async(&rt).iter(|| async {
let store_path = temp_sqlite_path("500turns");
let (mock, model) = build_mock_runtime(500, store_path);
let mut agent = mock
.runtime()
.spawn("bench-agent", model)
.expect("spawn agent");
for i in 0u32..500 {
agent
.send(vec![ContentBlock::text(format!("message {i}"))])
.await
.expect("send turn");
}
});
});
}
// ---------------------------------------------------------------------------
// bench_resume_after_heavy_session
// 200 turns followed by a resume from the persisted agent record.
// ---------------------------------------------------------------------------
fn bench_resume_after_heavy_session(c: &mut Criterion) {
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
c.bench_function("resume_after_200_turn_session", |b| {
b.to_async(&rt).iter(|| async {
let store_path = temp_sqlite_path("resume");
// Prepare: 200 turns for the heavy session + 1 turn for the resumed send
let store = SqliteRuntimeStore::new(store_path.clone());
let mut builder = MockRuntimeBuilder::default().with_store(store);
for i in 0..201usize {
builder = builder.push_turn(MockTurn::Text(format!("turn {i} response")));
}
let mock = builder.build().expect("build mock runtime");
let model = mock.model();
// Drive the heavy session
let agent_id = {
let mut agent = mock
.runtime()
.spawn("bench-agent", model.clone())
.expect("spawn agent");
for i in 0u32..200 {
agent
.send(vec![ContentBlock::text(format!("message {i}"))])
.await
.expect("send turn");
}
agent.id().to_string()
};
// Measure: resume and send one more turn
let mut resumed = mock
.runtime()
.resume_agent(&agent_id)
.expect("resume agent");
resumed
.send(vec![ContentBlock::text("resumed message")])
.await
.expect("send after resume");
});
});
}
// ---------------------------------------------------------------------------
// bench_memory_scaling_1000_records
// Seeds 1000 memory records into a SQLite store and measures search latency.
// ---------------------------------------------------------------------------
fn bench_memory_scaling_1000_records(c: &mut Criterion) {
use mentra::memory::SqliteHybridMemoryStore;
let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
c.bench_function("memory_search_1000_records", |b| {
b.to_async(&rt).iter(|| async {
let store_path = temp_sqlite_path("memory");
let store = SqliteHybridMemoryStore::new(&store_path);
// Seed 1000 records
let records: Vec<MemoryRecord> = (0..1000usize)
.map(|i| MemoryRecord {
record_id: format!("rec-{i:04}"),
agent_id: "bench-agent".to_string(),
kind: if i % 3 == 0 {
MemoryRecordKind::Episode
} else if i % 3 == 1 {
MemoryRecordKind::Fact
} else {
MemoryRecordKind::Summary
},
content: format!(
"memory record {i}: the agent discussed topic {i} in session {i}"
),
source_revision: i as u64,
created_at: now_secs() - i as i64,
metadata_json: "{}".to_string(),
source: None,
pinned: false,
score: None,
})
.collect();
store.upsert_records(&records).expect("seed records");
// Measure search latency
let request = MemorySearchRequest {
agent_id: "bench-agent".to_string(),
query: "agent discussed topic session".to_string(),
limit: 20,
char_budget: None,
mode: MemorySearchMode::Automatic,
};
let _hits = store
.search_records_with_options(&request)
.expect("search records");
});
});
}
// ---------------------------------------------------------------------------
// Criterion group
// ---------------------------------------------------------------------------
criterion_group! {
name = benches;
config = Criterion::default().sample_size(10);
targets = bench_500_turn_session, bench_resume_after_heavy_session, bench_memory_scaling_1000_records
}
criterion_main!(benches);

648
vendor/mentra/src/agent.rs vendored Normal file
View File

@@ -0,0 +1,648 @@
mod compact;
mod config;
mod events;
mod lifecycle;
mod pending;
mod pending_block;
mod round_strategy;
mod runner;
mod snapshot;
mod steering;
mod subagent;
mod task_state;
mod team;
mod terminal_output;
#[cfg(test)]
mod tests;
mod wait;
use std::{
collections::HashSet,
sync::{
Arc, Mutex,
atomic::{AtomicU64, Ordering},
},
};
use serde::{Deserialize, Serialize};
use tokio::sync::{broadcast, watch};
use crate::{
ContentBlock, Message,
background::BackgroundNotification,
error::RuntimeError,
memory::journal::{AgentMemory, AgentMemoryState as MemoryState},
provider::{Provider, ProviderId, ToolChoice},
runtime::{
LoadedAgentState, RuntimeIntrinsicTool, TaskItem,
handle::{AgentExecutionConfig, AgentObserver, RuntimeHandle},
},
team::TeamMessage,
transcript::{DelegationArtifact, DelegationEdge, TranscriptItem},
};
pub(crate) use team::parse_task_input;
pub use config::{
AgentConfig, CompactionConfig, ContextCompactionConfig, MemoryConfig, TaskConfig,
TeamAutonomyConfig, TeamConfig, ToolProfile, ToolResultPagingConfig, WorkspaceConfig,
};
pub use events::{
AgentEvent, AgentSnapshot, AgentStatus, CompactionDetails, CompactionTrigger,
ContextCompactionDetails, ContextCompactionTrigger, PendingToolUseSummary, SpawnedAgentStatus,
SpawnedAgentSummary,
};
pub use pending::PendingAssistantTurn;
pub use round_strategy::{
ReasoningChange, RoundAdjustment, RoundBoundary, RoundContext, RoundDecision, RoundStrategy,
RoundToolResult,
};
use runner::TurnRunner;
pub use steering::{QueueMode, SteeringHandle};
pub(crate) use subagent::DisposableSubagentTemplate;
use terminal_output::TerminalToolGate;
pub use terminal_output::{FinalOutput, TerminalOutputSpec};
pub use wait::{AgentWaitFuture, AgentWaitHandle};
static NEXT_AGENT_ID: AtomicU64 = AtomicU64::new(1);
/// Running or persisted agent managed by a [`crate::Runtime`].
pub struct Agent {
id: String,
runtime: RuntimeHandle,
model: String,
provider_id: ProviderId,
name: String,
config: AgentConfig,
memory: AgentMemory,
tasks: Vec<TaskItem>,
rounds_since_task: usize,
event_bus: AgentEventBus,
snapshot: Arc<Mutex<AgentSnapshot>>,
snapshot_tx: watch::Sender<AgentSnapshot>,
provider: Arc<dyn Provider>,
hidden_tools: HashSet<String>,
terminal_tool_gate: Arc<Mutex<Option<TerminalToolGate>>>,
max_rounds: Option<usize>,
inflight_background_notifications: Vec<BackgroundNotification>,
inflight_team_messages: Vec<TeamMessage>,
steering: SteeringHandle,
inflight_steer: Vec<Vec<ContentBlock>>,
inflight_follow_up: Vec<Vec<ContentBlock>>,
teammate_identity: Option<TeammateIdentity>,
idle_requested: bool,
current_run_id: Option<String>,
/// Full texts of results this agent received paged, keyed by
/// `tool_use_id` — the backing store for `read_tool_result`. Empty and
/// unused unless `config.tool_result_paging` is set.
paged_tool_results: crate::tool::paging::PagedToolResults,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct TeammateIdentity {
pub(crate) role: String,
pub(crate) lead: String,
}
#[derive(Default)]
pub(crate) struct AgentSpawnOptions {
pub(crate) hidden_tools: HashSet<String>,
pub(crate) max_rounds: Option<usize>,
pub(crate) teammate_identity: Option<TeammateIdentity>,
}
type AgentEventTap = Arc<dyn Fn(&AgentEvent) + Send + Sync>;
#[derive(Default)]
struct AgentEventTapRegistry {
next_id: u64,
taps: Vec<(u64, AgentEventTap)>,
}
pub(crate) struct AgentEventTapGuard {
registry: Arc<Mutex<AgentEventTapRegistry>>,
id: u64,
}
#[derive(Clone)]
pub(crate) struct AgentEventBus {
tx: broadcast::Sender<AgentEvent>,
taps: Arc<Mutex<AgentEventTapRegistry>>,
}
impl AgentEventBus {
fn new(capacity: usize) -> Self {
let (tx, _) = broadcast::channel(capacity);
Self {
tx,
taps: Arc::new(Mutex::new(AgentEventTapRegistry::default())),
}
}
pub(crate) fn send(&self, event: AgentEvent) {
let taps = {
let registry = self.taps.lock().expect("agent event tap registry poisoned");
registry
.taps
.iter()
.map(|(_, tap)| Arc::clone(tap))
.collect::<Vec<_>>()
};
for tap in taps {
tap(&event);
}
let _ = self.tx.send(event);
}
pub(crate) fn subscribe(&self) -> broadcast::Receiver<AgentEvent> {
self.tx.subscribe()
}
pub(crate) fn register_tap(
&self,
tap: impl Fn(&AgentEvent) + Send + Sync + 'static,
) -> AgentEventTapGuard {
let mut registry = self.taps.lock().expect("agent event tap registry poisoned");
let id = registry.next_id;
registry.next_id += 1;
registry.taps.push((id, Arc::new(tap)));
AgentEventTapGuard {
registry: Arc::clone(&self.taps),
id,
}
}
}
impl Drop for AgentEventTapGuard {
fn drop(&mut self) {
let mut registry = self
.registry
.lock()
.expect("agent event tap registry poisoned");
registry.taps.retain(|(tap_id, _)| *tap_id != self.id);
}
}
impl Agent {
pub(crate) fn new(
runtime: RuntimeHandle,
model: String,
name: String,
config: AgentConfig,
provider: Arc<dyn Provider>,
options: AgentSpawnOptions,
) -> Result<Self, RuntimeError> {
let AgentSpawnOptions {
hidden_tools,
max_rounds,
teammate_identity,
} = options;
let store = runtime.store();
let agent_id = format!(
"agent-{:x}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos(),
NEXT_AGENT_ID.fetch_add(1, Ordering::Relaxed)
);
let memory = AgentMemory::new(agent_id.clone(), store.clone(), MemoryState::default());
let event_bus = AgentEventBus::new(256);
let memory_view = memory.snapshot_view();
let snapshot = AgentSnapshot {
history_len: memory_view.history_len,
current_text: memory_view.current_text,
pending_tool_uses: memory_view.pending_tool_uses,
..Default::default()
};
let snapshot = Arc::new(Mutex::new(snapshot));
let (snapshot_tx, _) =
watch::channel(snapshot.lock().expect("agent snapshot poisoned").clone());
let mut agent = Self {
id: agent_id,
runtime,
model,
provider_id: provider.descriptor().id,
name,
config,
memory,
tasks: Vec::new(),
rounds_since_task: 0,
event_bus,
snapshot,
snapshot_tx,
provider,
hidden_tools,
terminal_tool_gate: Arc::new(Mutex::new(None)),
max_rounds,
inflight_background_notifications: Vec::new(),
inflight_team_messages: Vec::new(),
steering: SteeringHandle::new(),
inflight_steer: Vec::new(),
inflight_follow_up: Vec::new(),
teammate_identity,
idle_requested: false,
current_run_id: None,
paged_tool_results: Default::default(),
};
agent
.runtime
.store()
.create_agent(&agent.persisted_record(), agent.memory.state())?;
let execution_config = AgentExecutionConfig {
name: agent.name.clone(),
team_dir: agent.config.team.team_dir.clone(),
tasks_dir: agent.config.task.tasks_dir.clone(),
base_dir: agent.config.workspace.base_dir.clone(),
memory_tool_search_limit: agent.config.memory.tool_search_limit,
auto_route_shell: agent.config.workspace.auto_route_shell,
is_teammate: agent.teammate_identity.is_some(),
};
let observer = AgentObserver {
events: agent.event_bus.clone(),
snapshot_tx: agent.snapshot_tx.clone(),
snapshot: Arc::clone(&agent.snapshot),
};
agent
.runtime
.register_agent(&agent.id, &agent.name, execution_config, &observer)?;
agent.register_tool_result_pager();
agent.refresh_tasks_from_disk()?;
Ok(agent)
}
pub(crate) fn from_loaded(
runtime: RuntimeHandle,
mut state: LoadedAgentState,
provider: Arc<dyn Provider>,
) -> Result<Self, RuntimeError> {
let mut memory = AgentMemory::new(state.record.id.clone(), runtime.store(), state.memory);
let recovery = memory.recover()?;
if recovery.interrupted {
state.record.status = AgentStatus::Interrupted;
runtime.store().update_run_state(
recovery
.interrupted_run_id
.as_deref()
.expect("recovery should include run id"),
"interrupted",
Some("recovered after interruption"),
)?;
runtime.store().save_agent_record(&state.record)?;
}
let memory_view = memory.snapshot_view();
let snapshot = AgentSnapshot {
status: state.record.status.clone(),
history_len: memory_view.history_len,
current_text: memory_view.current_text,
pending_tool_uses: memory_view.pending_tool_uses,
pending_team_messages: 0,
subagents: state.record.subagents.clone(),
..Default::default()
};
let snapshot = Arc::new(Mutex::new(snapshot));
let (snapshot_tx, _) =
watch::channel(snapshot.lock().expect("agent snapshot poisoned").clone());
let event_bus = AgentEventBus::new(256);
let mut agent = Self {
id: state.record.id.clone(),
runtime,
model: state.record.model.clone(),
provider_id: state.record.provider_id.clone(),
name: state.record.name.clone(),
config: state.record.config.clone(),
memory,
tasks: Vec::new(),
rounds_since_task: state.record.rounds_since_task,
event_bus,
snapshot,
snapshot_tx,
provider,
hidden_tools: state.record.hidden_tools,
terminal_tool_gate: Arc::new(Mutex::new(None)),
max_rounds: state.record.max_rounds,
inflight_background_notifications: Vec::new(),
inflight_team_messages: Vec::new(),
steering: SteeringHandle::new(),
inflight_steer: Vec::new(),
inflight_follow_up: Vec::new(),
teammate_identity: state.record.teammate_identity,
idle_requested: state.record.idle_requested,
current_run_id: None,
paged_tool_results: Default::default(),
};
let execution_config = AgentExecutionConfig {
name: agent.name.clone(),
team_dir: agent.config.team.team_dir.clone(),
tasks_dir: agent.config.task.tasks_dir.clone(),
base_dir: agent.config.workspace.base_dir.clone(),
memory_tool_search_limit: agent.config.memory.tool_search_limit,
auto_route_shell: agent.config.workspace.auto_route_shell,
is_teammate: agent.teammate_identity.is_some(),
};
let observer = AgentObserver {
events: agent.event_bus.clone(),
snapshot_tx: agent.snapshot_tx.clone(),
snapshot: Arc::clone(&agent.snapshot),
};
agent
.runtime
.register_agent(&agent.id, &agent.name, execution_config, &observer)?;
agent.register_tool_result_pager();
agent.refresh_tasks_from_disk()?;
Ok(agent)
}
/// Returns the agent's display name.
pub fn name(&self) -> &str {
&self.name
}
/// Returns the stable persisted agent identifier.
pub fn id(&self) -> &str {
&self.id
}
/// Returns the model identifier used by the agent.
pub fn model(&self) -> &str {
&self.model
}
/// Updates the model and provider used for future turns, then persists the
/// new agent record so resumed sessions continue with the same setting.
pub fn set_model(&mut self, model: crate::ModelInfo) -> Result<(), RuntimeError> {
let provider = self
.runtime
.get_provider(Some(&model.provider))
.ok_or_else(|| RuntimeError::ProviderNotFound(Some(model.provider.clone())))?;
self.model = model.id;
self.provider_id = provider.descriptor().id;
self.provider = provider;
self.persist_agent_record()
}
/// Updates the reasoning options requested on future turns, then persists the
/// agent record so resumed sessions continue with the same setting.
///
/// Mirrors [`set_model`](Self::set_model): a stateful override threaded into
/// every subsequent model request (the runner reads
/// `config.provider_request_options.reasoning` live). It composes with
/// `set_model` for **per-phase tiering** — e.g. run the gather rounds at a low
/// reasoning effort, then raise the effort (and switch to a stronger model) for
/// a final synthesis turn on the same agent, without re-spawning and losing the
/// gathered context. `None` clears any configured reasoning, restoring the
/// provider's default effort.
pub fn set_reasoning(
&mut self,
reasoning: Option<crate::provider::ReasoningOptions>,
) -> Result<(), RuntimeError> {
self.config.provider_request_options.reasoning = reasoning;
self.persist_agent_record()
}
/// Returns the effective agent configuration.
pub fn config(&self) -> &AgentConfig {
&self.config
}
/// Returns the committed transcript history.
pub fn history(&self) -> &[Message] {
self.memory.history()
}
/// Returns the canonical transcript items stored for this agent.
pub fn transcript(&self) -> &crate::AgentTranscript {
self.memory.transcript()
}
/// The transcript entry the next turn will continue from.
pub fn leaf(&self) -> Option<&crate::transcript::EntryId> {
self.transcript().leaf()
}
/// Returns to an earlier entry, so the next turn explores a new path from
/// there.
///
/// The abandoned entries stay in the transcript, reachable through
/// [`children`](Self::children) — nothing is deleted, so the path just
/// left can be returned to the same way. Returns how many entries left
/// the active path.
pub fn branch_from(
&mut self,
entry: &crate::transcript::EntryId,
) -> Result<usize, RuntimeError> {
self.memory.branch_from(entry)
}
/// The entries recorded as continuing from `entry`. More than one means
/// the conversation branched there.
pub fn children(
&self,
entry: &crate::transcript::EntryId,
) -> Vec<&crate::transcript::TranscriptItem> {
self.transcript().children(entry)
}
fn append_transcript_item(&mut self, item: TranscriptItem) -> Result<(), RuntimeError> {
self.memory.append_transcript_item(item)
}
pub(crate) fn record_canonical_context(
&mut self,
content: impl Into<String>,
) -> Result<(), RuntimeError> {
self.append_transcript_item(TranscriptItem::canonical_context(Message::user(
ContentBlock::text(content.into()),
)))
}
pub(crate) fn record_delegation_request(
&mut self,
content: impl Into<String>,
delegation: DelegationArtifact,
edge: Option<DelegationEdge>,
) -> Result<(), RuntimeError> {
self.append_transcript_item(TranscriptItem::delegation_request(
Message::user(ContentBlock::text(content.into())),
delegation,
edge,
))
}
pub(crate) fn record_delegation_result(
&mut self,
content: impl Into<String>,
delegation: DelegationArtifact,
edge: Option<DelegationEdge>,
) -> Result<(), RuntimeError> {
self.append_transcript_item(TranscriptItem::delegation_result(
Message::user(ContentBlock::text(content.into())),
delegation,
edge,
))
}
pub(crate) fn memory_revision(&self) -> u64 {
self.memory.revision()
}
pub(crate) fn memory_engine(&self) -> Arc<crate::memory::MemoryEngine> {
self.runtime.memory_engine()
}
/// Returns whether this agent is a persistent teammate rather than the lead agent.
pub fn is_teammate(&self) -> bool {
self.teammate_identity.is_some()
}
pub(crate) fn tasks(&self) -> &[TaskItem] {
&self.tasks
}
/// Returns the most recent committed message, if any.
pub fn last_message(&self) -> Option<&Message> {
self.memory.last_message()
}
/// Subscribes to the agent's transient event stream.
pub fn subscribe_events(&self) -> broadcast::Receiver<AgentEvent> {
self.event_bus.subscribe()
}
/// Watches the current agent snapshot for state updates.
pub fn watch_snapshot(&self) -> watch::Receiver<AgentSnapshot> {
self.snapshot_tx.subscribe()
}
/// The tools this agent offers the model on the next round.
///
/// A shaping typed turn (see [`Agent::run_to_output`]) narrows this to
/// exactly one tool — the terminal tool it generated — because the whole
/// point of that turn is that the model has nothing to decide but the
/// answer's shape. A working typed turn narrows nothing: its terminal tool
/// is admitted by [`can_use_tool`](Self::can_use_tool) like any other, so
/// it simply joins the ordinary roster.
pub(crate) fn tools(&self) -> Arc<[crate::tool::ProviderToolSpec]> {
let gate = self
.terminal_tool_gate
.lock()
.expect("terminal tool gate poisoned")
.clone();
self.runtime
.tools()
.iter()
.filter(|tool| match &gate {
Some(gate) if !gate.keeps_tools => {
gate.tool_name == tool.name
&& self.runtime.tool_is_visible_to_agent(&tool.name, &self.id)
}
_ => self.can_use_tool(&tool.name),
})
.cloned()
.collect::<Vec<_>>()
.into()
}
pub(crate) fn can_use_tool(&self, name: &str) -> bool {
if !self.runtime.tool_is_visible_to_agent(name, &self.id) {
return false;
}
if self
.terminal_tool_gate
.lock()
.expect("terminal tool gate poisoned")
.as_ref()
.is_some_and(|gate| gate.tool_name == name)
{
return true;
}
if self.hidden_tools.contains(name) {
return false;
}
if !self.config.tool_profile.allows(name) {
return false;
}
if name == RuntimeIntrinsicTool::Idle.to_string() {
return self.teammate_identity.is_some();
}
// The pager's reader exists for the model only while there can be
// paged results to read. Registration is runtime-wide (the registry
// is keyed by tool name), so this per-agent gate — not registration —
// is what keeps the tool out of an unpaged agent's roster, even when
// a paging agent shares the same runtime.
if name == crate::tool::paging::READ_TOOL_RESULT_TOOL {
return self.config.tool_result_paging.is_some();
}
true
}
pub(crate) fn runtime_handle(&self) -> RuntimeHandle {
self.runtime.clone()
}
/// Registers the pager's reader when this agent enables paging. The tool
/// itself is stateless — it resolves both the retained results and the
/// page size from the calling agent's context — so one registration
/// serves every paging agent on the runtime, and re-registering is a
/// no-op.
fn register_tool_result_pager(&self) {
if self.config.tool_result_paging.is_some() {
self.runtime.register_tool(crate::tool::ReadToolResultTool);
}
}
/// Retains the full text of a result that entered the transcript paged,
/// so `read_tool_result` can serve its later windows. In memory only, for
/// this agent's lifetime — a result the model never asks to continue
/// simply goes away with the agent.
pub(crate) fn record_paged_tool_result(&self, tool_use_id: &str, full: &str) {
self.paged_tool_results.record(tool_use_id, full);
}
/// Returns a retained full result by `tool_use_id`. Only this agent's own
/// paged results are reachable: the store is per-agent, so one agent can
/// never read another's.
pub(crate) fn paged_tool_result(&self, tool_use_id: &str) -> Option<Arc<str>> {
self.paged_tool_results.get(tool_use_id)
}
pub(crate) fn max_rounds(&self) -> Option<usize> {
self.max_rounds
}
/// What the model is told about choosing a tool on the next round.
///
/// A shaping typed turn forces its terminal tool: it is the only tool on
/// the request, and the turn exists to produce that one call. A working
/// typed turn forces nothing — not the terminal tool, which would end the
/// turn before any work happened, and not the agent's own configured
/// choice, which would keep the turn from ever reaching the call that ends
/// it. Both would defeat the mode, so while one runs the choice is `Auto`.
pub(crate) fn tool_choice(&self) -> Option<ToolChoice> {
let gate = self
.terminal_tool_gate
.lock()
.expect("terminal tool gate poisoned")
.clone();
if let Some(gate) = gate {
return Some(if gate.keeps_tools {
ToolChoice::Auto
} else {
ToolChoice::Tool {
name: gate.tool_name,
}
});
}
match self.config.tool_choice.clone() {
Some(ToolChoice::Tool { name }) if !self.can_use_tool(&name) => Some(ToolChoice::Auto),
other => other,
}
}
}

201
vendor/mentra/src/agent/compact.rs vendored Normal file
View File

@@ -0,0 +1,201 @@
use crate::memory::journal::CompactionOutcome;
use crate::{
ContentBlock, Message,
agent::AgentEvent,
compaction::compaction_request_from_agent,
error::{ErrorCategory, RuntimeError},
memory::{
estimated_request_tokens, micro_compact_history, required_tail_start_for_continuation,
},
};
use super::{Agent, CompactionDetails, CompactionTrigger};
const AUTO_COMPACT_MAX_ATTEMPTS: u32 = 3;
const AUTO_COMPACT_RETRY_DELAY_MS: u64 = 500;
impl Agent {
pub(crate) fn micro_compacted_history(&self) -> Vec<Message> {
micro_compact_history(
self.history(),
self.config.compaction.keep_recent_tool_results,
)
}
pub(crate) fn estimated_request_tokens(&self, messages: &[Message]) -> usize {
estimated_request_tokens(messages, self.effective_system_prompt().as_deref())
}
pub(crate) async fn auto_compact_if_needed(&mut self) -> Result<(), RuntimeError> {
let Some(threshold) = self.config.compaction.auto_compact_threshold_tokens else {
return Ok(());
};
let messages = self.micro_compacted_history();
if self.estimated_request_tokens(&messages) <= threshold {
return Ok(());
}
let preserve_from = required_tail_start_for_continuation(self.history());
for attempt in 1..=AUTO_COMPACT_MAX_ATTEMPTS {
match self
.compact_history(preserve_from, CompactionTrigger::Auto)
.await
{
Ok(_) => return Ok(()),
Err(err)
if err.category() == ErrorCategory::Retryable
&& attempt < AUTO_COMPACT_MAX_ATTEMPTS =>
{
self.emit_event(AgentEvent::RetryAttempt {
agent_id: self.id().to_string(),
error_message: err.to_string(),
attempt,
max_attempts: AUTO_COMPACT_MAX_ATTEMPTS,
next_delay_ms: AUTO_COMPACT_RETRY_DELAY_MS,
});
tokio::time::sleep(tokio::time::Duration::from_millis(
AUTO_COMPACT_RETRY_DELAY_MS,
))
.await;
}
Err(_) => {
// Non-retryable error or all attempts exhausted: degrade gracefully.
// The session continues with micro-compaction only.
return Ok(());
}
}
}
Ok(())
}
pub(crate) async fn compact_history(
&mut self,
preserve_from: usize,
trigger: CompactionTrigger,
) -> Result<Option<CompactionDetails>, RuntimeError> {
if self.history().is_empty() {
return Ok(None);
}
// A `preserve_from` of zero used to end the attempt here. Zero means
// the protected tail is the whole transcript — a single turn that is
// itself over budget — which is precisely when compaction is most
// needed. The engine has a split-turn path for it, so let it decide
// rather than silently doing nothing.
debug_assert!(preserve_from <= self.history().len());
let base_revision = self.memory.revision();
// Compaction is a provider request like any other, so it goes out on
// the transport the runtime chose. Leaving it on the request's own
// value would quietly summarize over HTTP+SSE inside a run the host
// put on a websocket.
let mut provider_request_options = self.config.provider_request_options.clone();
crate::provider::select_responses_transport(
self.provider.as_ref(),
self.runtime.responses_transport(),
&mut provider_request_options,
)?;
let Some(proposal) = self
.runtime
.compaction_engine()
.compact(
self.provider.clone(),
compaction_request_from_agent(
self.model(),
self.transcript().clone(),
&self.config.compaction,
provider_request_options,
),
)
.await?
else {
return Ok(None);
};
let transcript_path = proposal.transcript_path.clone();
let replaced_items = proposal.replaced_items;
let preserved_items = proposal.preserved_items;
let summary = proposal.summary.clone();
self.runtime
.emit_hook(crate::runtime::RuntimeHookEvent::MemoryCompactionProposed {
agent_id: self.id().to_string(),
base_revision,
transcript_path: transcript_path.clone(),
})?;
let applied = self.memory.try_apply_compaction(
base_revision,
CompactionOutcome {
transcript_path: proposal.transcript_path,
transcript: proposal.transcript,
},
)?;
if !applied {
let _ =
self.runtime
.emit_hook(crate::runtime::RuntimeHookEvent::MemoryCompactionSkipped {
agent_id: self.id().to_string(),
base_revision,
});
return Ok(None);
}
self.runtime.memory_engine().store_compaction_summary(
self.id(),
self.memory.revision(),
&summary.render_for_handoff(),
)?;
self.sync_memory_snapshot();
let _ = self
.runtime
.emit_hook(crate::runtime::RuntimeHookEvent::MemoryCompactionApplied {
agent_id: self.id().to_string(),
base_revision,
resulting_history_len: self.transcript().len(),
});
let details = CompactionDetails {
trigger,
mode: proposal.mode,
agent_id: self.id().to_string(),
transcript_path,
replaced_items,
preserved_items,
preserved_user_turns: proposal.preserved_user_turns,
preserved_delegation_results: proposal.preserved_delegation_results,
resulting_transcript_len: self.transcript().len(),
extracted_facts_count: proposal.diagnostics.extracted_facts_count,
summary_preview: proposal.diagnostics.summary_preview.clone(),
};
self.emit_event(AgentEvent::ContextCompacted {
details: details.clone(),
});
Ok(Some(details))
}
pub(crate) fn inject_teammate_identity(&self, messages: &mut Vec<Message>) {
let Some(identity) = &self.teammate_identity else {
return;
};
if messages.len() > 5 {
return;
}
messages.insert(
0,
Message::user(ContentBlock::Text {
text: format!(
"<identity>You are teammate '{}' with role '{}' on the team led by '{}'. Continue your assigned work and stay in character.</identity>",
self.name, identity.role, identity.lead
),
}),
);
messages.insert(
1,
Message::assistant(ContentBlock::Text {
text: format!("I am {}. Continuing.", self.name),
}),
);
}
}

516
vendor/mentra/src/agent/config.rs vendored Normal file
View File

@@ -0,0 +1,516 @@
use std::{
collections::{BTreeMap, BTreeSet},
path::PathBuf,
time::Duration,
};
#[cfg(test)]
use std::sync::atomic::{AtomicU64, Ordering};
use serde::{Deserialize, Serialize};
use crate::compaction::CompactionMode;
#[cfg(test)]
use crate::provider::ToolSearchMode;
use crate::provider::{ProviderRequestOptions, ToolChoice};
#[cfg(test)]
static NEXT_TEST_TRANSCRIPT_DIR_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskConfig {
pub tasks_dir: PathBuf,
pub reminder_threshold: usize,
}
impl Default for TaskConfig {
fn default() -> Self {
Self {
tasks_dir: default_tasks_dir(),
reminder_threshold: 3,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TeamAutonomyConfig {
pub enabled: bool,
pub poll_interval: Duration,
pub idle_timeout: Duration,
}
impl Default for TeamAutonomyConfig {
fn default() -> Self {
Self {
enabled: false,
poll_interval: Duration::from_secs(5),
idle_timeout: Duration::from_secs(60),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TeamConfig {
pub team_dir: PathBuf,
pub autonomy: TeamAutonomyConfig,
}
impl Default for TeamConfig {
fn default() -> Self {
Self {
team_dir: default_team_dir(),
autonomy: TeamAutonomyConfig::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompactionConfig {
pub keep_recent_tool_results: usize,
pub auto_compact_threshold_tokens: Option<usize>,
pub transcript_dir: PathBuf,
pub summary_max_input_chars: usize,
pub summary_max_output_tokens: u32,
#[serde(default)]
pub mode: CompactionMode,
pub preserve_recent_user_tokens: usize,
pub preserve_recent_delegation_results: usize,
pub max_persisted_transcripts: Option<usize>,
}
impl Default for CompactionConfig {
fn default() -> Self {
Self {
keep_recent_tool_results: 3,
auto_compact_threshold_tokens: Some(50_000),
transcript_dir: default_transcript_dir(),
summary_max_input_chars: 80_000,
summary_max_output_tokens: 2_000,
mode: CompactionMode::LocalOnly,
preserve_recent_user_tokens: 20_000,
preserve_recent_delegation_results: 8,
max_persisted_transcripts: Some(10),
}
}
}
pub type ContextCompactionConfig = CompactionConfig;
/// Bounds how much of an oversized tool result enters the model's view.
///
/// A result at or below `threshold_bytes` is inserted byte-identically to a
/// run without paging. Above it, the transcript receives the first window
/// (at most `page_bytes`, cut on a line boundary) plus a trailer naming the
/// `read_tool_result` call that returns the next window; the full result is
/// retained in memory for the life of the agent so nothing is lost.
///
/// Paging is applied *after* the runtime's own tool-result limiter
/// (`RuntimePolicy::with_max_tool_result_bytes` /
/// `with_max_tool_result_lines`), so a `threshold_bytes` above those caps
/// never triggers — the limiter clamps the result first. Enabling paging
/// therefore means raising the policy caps to whatever a tool may legitimately
/// return and leaving them as the anti-abuse backstop.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolResultPagingConfig {
/// Results at or below this size are inserted whole. Default 64 KiB.
pub threshold_bytes: usize,
/// Maximum bytes per inserted page/window. Default 32 KiB.
pub page_bytes: usize,
}
impl Default for ToolResultPagingConfig {
fn default() -> Self {
Self {
threshold_bytes: 64 * 1024,
page_bytes: 32 * 1024,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceConfig {
pub base_dir: PathBuf,
pub auto_route_shell: bool,
}
impl Default for WorkspaceConfig {
fn default() -> Self {
let base_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
Self {
base_dir,
auto_route_shell: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemoryConfig {
pub auto_recall_enabled: bool,
pub auto_recall_limit: usize,
pub auto_recall_char_budget: usize,
pub tool_search_limit: usize,
pub write_tools_enabled: bool,
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
auto_recall_enabled: true,
auto_recall_limit: 3,
auto_recall_char_budget: 2_000,
tool_search_limit: 10,
write_tools_enabled: true,
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolProfile {
#[serde(default)]
pub allowed_tools: Option<BTreeSet<String>>,
#[serde(default)]
pub hidden_tools: BTreeSet<String>,
}
impl ToolProfile {
pub fn all() -> Self {
Self::default()
}
pub fn only<I, S>(tools: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
allowed_tools: Some(tools.into_iter().map(Into::into).collect()),
hidden_tools: BTreeSet::new(),
}
}
pub fn hide<I, S>(tools: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
allowed_tools: None,
hidden_tools: tools.into_iter().map(Into::into).collect(),
}
}
pub fn allows(&self, tool_name: &str) -> bool {
if let Some(allowed_tools) = &self.allowed_tools
&& !allowed_tools.contains(tool_name)
{
return false;
}
!self.hidden_tools.contains(tool_name)
}
}
#[cfg(not(test))]
fn default_team_dir() -> PathBuf {
crate::default_paths::workspace_default_paths().team_dir
}
#[cfg(test)]
fn default_team_dir() -> PathBuf {
let suffix = NEXT_TEST_TRANSCRIPT_DIR_ID.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir()
.join("mentra-test-team")
.join(format!("process-{}-{suffix}", std::process::id()))
}
#[cfg(not(test))]
fn default_transcript_dir() -> PathBuf {
crate::default_paths::workspace_default_paths().transcripts_dir
}
#[cfg(not(test))]
fn default_tasks_dir() -> PathBuf {
crate::default_paths::workspace_default_paths().tasks_dir
}
#[cfg(test)]
fn default_tasks_dir() -> PathBuf {
let suffix = NEXT_TEST_TRANSCRIPT_DIR_ID.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir()
.join("mentra-test-tasks")
.join(format!("process-{}-{suffix}", std::process::id()))
}
#[cfg(test)]
fn default_transcript_dir() -> PathBuf {
let suffix = NEXT_TEST_TRANSCRIPT_DIR_ID.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir()
.join("mentra-test-transcripts")
.join(format!("process-{}-{suffix}", std::process::id()))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
pub system: Option<String>,
pub tool_choice: Option<ToolChoice>,
#[serde(default)]
pub tool_profile: ToolProfile,
pub temperature: Option<f32>,
pub max_output_tokens: Option<u32>,
pub metadata: BTreeMap<String, String>,
#[serde(default)]
pub provider_request_options: ProviderRequestOptions,
pub team: TeamConfig,
pub task: TaskConfig,
pub workspace: WorkspaceConfig,
#[serde(default)]
pub memory: MemoryConfig,
#[serde(alias = "context_compaction")]
pub compaction: CompactionConfig,
/// `None` (the default) preserves the unpaged behaviour exactly: every
/// tool result enters the transcript as produced, and `read_tool_result`
/// is absent from the agent's tool roster.
#[serde(default)]
pub tool_result_paging: Option<ToolResultPagingConfig>,
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
system: None,
tool_choice: Some(ToolChoice::default()),
tool_profile: ToolProfile::default(),
temperature: None,
max_output_tokens: Some(8192),
metadata: BTreeMap::new(),
provider_request_options: ProviderRequestOptions::default(),
team: TeamConfig::default(),
task: TaskConfig::default(),
workspace: WorkspaceConfig::default(),
memory: MemoryConfig::default(),
compaction: CompactionConfig::default(),
tool_result_paging: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use crate::provider::{ReasoningEffort, ReasoningOptions};
fn test_path(label: &str) -> PathBuf {
std::env::temp_dir()
.join("mentra-agent-config-tests")
.join(label)
}
#[test]
fn explicit_paths_override_defaults() {
let tasks_dir = test_path("custom-tasks");
let team_dir = test_path("custom-team");
let transcript_dir = test_path("custom-transcripts");
let config = AgentConfig {
task: TaskConfig {
tasks_dir: tasks_dir.clone(),
..Default::default()
},
team: TeamConfig {
team_dir: team_dir.clone(),
..Default::default()
},
compaction: ContextCompactionConfig {
transcript_dir: transcript_dir.clone(),
..Default::default()
},
..Default::default()
};
assert_eq!(config.task.tasks_dir, tasks_dir);
assert_eq!(config.team.team_dir, team_dir);
assert_eq!(config.compaction.transcript_dir, transcript_dir);
}
#[test]
fn tool_profile_defaults_to_allowing_everything() {
let profile = ToolProfile::default();
assert!(profile.allows("shell"));
assert!(profile.allows("files"));
}
#[test]
fn tool_profile_only_restricts_to_allowlist() {
let profile = ToolProfile::only(["shell", "files"]);
assert!(profile.allows("shell"));
assert!(profile.allows("files"));
assert!(!profile.allows("task"));
}
#[test]
fn tool_profile_hide_blocks_named_tools() {
let profile = ToolProfile::hide(["shell", "background_run"]);
assert!(!profile.allows("shell"));
assert!(!profile.allows("background_run"));
assert!(profile.allows("files"));
}
#[test]
fn tool_profile_respects_allowlist_and_hidden_overrides() {
let profile = ToolProfile {
allowed_tools: Some(["shell", "files"].into_iter().map(str::to_string).collect()),
hidden_tools: ["shell"].into_iter().map(str::to_string).collect(),
};
assert!(!profile.allows("shell"));
assert!(profile.allows("files"));
assert!(!profile.allows("task"));
}
#[test]
fn agent_config_deserializes_without_tool_profile_field() {
let config: AgentConfig = serde_json::from_value(json!({
"system": null,
"tool_choice": serde_json::to_value(ToolChoice::Auto).expect("serialize tool choice"),
"temperature": null,
"max_output_tokens": 8192,
"metadata": {},
"provider_request_options": {},
"team": TeamConfig::default(),
"task": TaskConfig::default(),
"workspace": WorkspaceConfig::default(),
"memory": MemoryConfig::default(),
"context_compaction": ContextCompactionConfig::default()
}))
.expect("deserialize config without tool profile");
assert_eq!(config.tool_profile, ToolProfile::default());
}
#[test]
fn provider_request_options_default_to_disabled_tool_search() {
let options = ProviderRequestOptions::default();
assert_eq!(options.tool_search_mode, ToolSearchMode::Disabled);
assert_eq!(options.reasoning, None);
}
#[test]
fn agent_config_deserializes_without_tool_search_mode() {
let config: AgentConfig = serde_json::from_value(json!({
"system": null,
"tool_choice": serde_json::to_value(ToolChoice::Auto).expect("serialize tool choice"),
"temperature": null,
"max_output_tokens": 8192,
"metadata": {},
"provider_request_options": {
"responses": {
"parallel_tool_calls": true
}
},
"team": TeamConfig::default(),
"task": TaskConfig::default(),
"workspace": WorkspaceConfig::default(),
"memory": MemoryConfig::default(),
"context_compaction": ContextCompactionConfig::default()
}))
.expect("deserialize config without tool search mode");
assert_eq!(
config.provider_request_options.tool_search_mode,
ToolSearchMode::Disabled
);
assert_eq!(
config
.provider_request_options
.responses
.parallel_tool_calls,
Some(true)
);
}
#[test]
fn tool_result_paging_is_disabled_by_default() {
assert_eq!(AgentConfig::default().tool_result_paging, None);
}
#[test]
fn tool_result_paging_defaults_to_64_kib_threshold_and_32_kib_pages() {
let paging = ToolResultPagingConfig::default();
assert_eq!(paging.threshold_bytes, 64 * 1024);
assert_eq!(paging.page_bytes, 32 * 1024);
}
#[test]
fn agent_config_deserializes_without_tool_result_paging_field() {
let config: AgentConfig = serde_json::from_value(json!({
"system": null,
"tool_choice": serde_json::to_value(ToolChoice::Auto).expect("serialize tool choice"),
"temperature": null,
"max_output_tokens": 8192,
"metadata": {},
"provider_request_options": {},
"team": TeamConfig::default(),
"task": TaskConfig::default(),
"workspace": WorkspaceConfig::default(),
"memory": MemoryConfig::default(),
"context_compaction": ContextCompactionConfig::default()
}))
.expect("deserialize config persisted before paging existed");
assert_eq!(config.tool_result_paging, None);
}
#[test]
fn agent_config_round_trips_tool_result_paging() {
let config = AgentConfig {
tool_result_paging: Some(ToolResultPagingConfig {
threshold_bytes: 4_096,
page_bytes: 1_024,
}),
..Default::default()
};
let restored: AgentConfig =
serde_json::from_value(serde_json::to_value(&config).expect("serialize config"))
.expect("deserialize config");
assert_eq!(restored.tool_result_paging, config.tool_result_paging);
}
#[test]
fn agent_config_deserializes_reasoning_options() {
let config: AgentConfig = serde_json::from_value(json!({
"system": null,
"tool_choice": serde_json::to_value(ToolChoice::Auto).expect("serialize tool choice"),
"temperature": null,
"max_output_tokens": 8192,
"metadata": {},
"provider_request_options": {
"reasoning": {
"effort": "high"
}
},
"team": TeamConfig::default(),
"task": TaskConfig::default(),
"workspace": WorkspaceConfig::default(),
"memory": MemoryConfig::default(),
"context_compaction": ContextCompactionConfig::default()
}))
.expect("deserialize config with reasoning options");
assert_eq!(
config.provider_request_options.reasoning,
Some(ReasoningOptions {
effort: Some(ReasoningEffort::High),
summary: None,
})
);
}
}

203
vendor/mentra/src/agent/events.rs vendored Normal file
View File

@@ -0,0 +1,203 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::{
BackgroundTaskSummary, ContentBlock, Message, TeamMemberSummary, TeamProtocolRequestSummary,
compaction::CompactionExecutionMode, runtime::TaskItem, tool::ToolCall,
};
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum AgentStatus {
#[default]
Idle,
AwaitingModel,
Streaming,
ExecutingTool {
id: String,
name: String,
},
Interrupted,
Finished,
Failed(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PendingToolUseSummary {
pub id: String,
pub name: String,
pub input_json: String,
pub complete: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SpawnedAgentStatus {
Running,
Finished,
Failed(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SpawnedAgentSummary {
pub id: String,
pub name: String,
pub model: String,
pub status: SpawnedAgentStatus,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum CompactionTrigger {
Auto,
Manual,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompactionDetails {
pub trigger: CompactionTrigger,
pub mode: CompactionExecutionMode,
pub agent_id: String,
pub transcript_path: PathBuf,
pub replaced_items: usize,
pub preserved_items: usize,
pub preserved_user_turns: usize,
pub preserved_delegation_results: usize,
pub resulting_transcript_len: usize,
pub extracted_facts_count: usize,
pub summary_preview: String,
}
pub type ContextCompactionTrigger = CompactionTrigger;
pub type ContextCompactionDetails = CompactionDetails;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentSnapshot {
pub status: AgentStatus,
/// Monotonic generation of the run currently reflected by this snapshot.
/// Incremented when a new `Agent::run` checkpoint has started.
#[serde(default, skip_serializing_if = "is_zero")]
pub run_generation: u64,
pub history_len: usize,
pub current_text: String,
pub pending_tool_uses: Vec<PendingToolUseSummary>,
pub pending_team_messages: usize,
pub tasks: Vec<TaskItem>,
pub subagents: Vec<SpawnedAgentSummary>,
pub teammates: Vec<TeamMemberSummary>,
pub protocol_requests: Vec<TeamProtocolRequestSummary>,
pub background_tasks: Vec<BackgroundTaskSummary>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AgentEvent {
RunStarted,
ContextCompacted {
details: CompactionDetails,
},
SubagentSpawned {
agent: SpawnedAgentSummary,
},
SubagentFinished {
agent: SpawnedAgentSummary,
},
TeammateSpawned {
teammate: TeamMemberSummary,
},
TeammateUpdated {
teammate: TeamMemberSummary,
},
TeamProtocolRequested {
request: TeamProtocolRequestSummary,
},
TeamProtocolResolved {
request: TeamProtocolRequestSummary,
},
TeamInboxUpdated {
unread_count: usize,
},
BackgroundTaskStarted {
task: BackgroundTaskSummary,
},
BackgroundTaskFinished {
task: BackgroundTaskSummary,
},
TextDelta {
delta: String,
full_text: String,
},
ReasoningDelta {
delta: String,
full_text: String,
},
ToolUseUpdated {
index: usize,
id: String,
name: String,
input_json: String,
},
ToolUseReady {
index: usize,
call: ToolCall,
},
ToolExecutionStarted {
call: ToolCall,
},
ToolExecutionFinished {
result: ContentBlock,
},
AssistantMessageCommitted {
message: Message,
},
/// Token usage from a completed model response.
UsageReport {
input_tokens: u64,
output_tokens: u64,
cache_read_tokens: u64,
cache_creation_tokens: u64,
},
RunFinished,
ToolExecutionProgress {
id: String,
name: String,
progress: String,
},
RetryAttempt {
agent_id: String,
error_message: String,
attempt: u32,
max_attempts: u32,
next_delay_ms: u64,
},
RunFailed {
error: String,
},
}
fn is_zero(value: &u64) -> bool {
*value == 0
}
#[cfg(test)]
mod tests {
use serde_json::Value;
use super::AgentSnapshot;
#[test]
fn zero_run_generation_uses_the_pre_field_json_shape() {
let json = serde_json::to_value(AgentSnapshot::default()).expect("serialize snapshot");
assert!(json.get("run_generation").is_none());
let restored: AgentSnapshot = serde_json::from_value(json).expect("load old snapshot JSON");
assert_eq!(restored.run_generation, 0);
let current = AgentSnapshot {
run_generation: 1,
..AgentSnapshot::default()
};
let Value::Object(current) = serde_json::to_value(current).expect("serialize generation")
else {
panic!("snapshot must serialize as an object");
};
assert_eq!(current.get("run_generation"), Some(&Value::from(1)));
}
}

136
vendor/mentra/src/agent/lifecycle.rs vendored Normal file
View File

@@ -0,0 +1,136 @@
use crate::{ContentBlock, Message, Role, error::RuntimeError, runtime::RunOptions};
use super::{Agent, AgentEvent, AgentStatus, TurnRunner};
impl Agent {
/// Sends a user turn using default run options.
pub async fn send(
&mut self,
content: impl Into<Vec<ContentBlock>>,
) -> Result<Message, RuntimeError> {
self.run(content, RunOptions::default()).await
}
/// Replays the most recent failed or interrupted user turn using default run options.
pub async fn resume(&mut self) -> Result<Message, RuntimeError> {
self.resume_with_options(RunOptions::default()).await
}
/// Replays the most recent failed or interrupted user turn with explicit execution options.
pub async fn resume_with_options(
&mut self,
options: RunOptions,
) -> Result<Message, RuntimeError> {
let content = self
.memory
.resumable_user_message()
.ok_or(RuntimeError::NoResumableTurn)?
.content
.clone();
self.run(content, options).await
}
/// Runs a user turn with explicit execution limits and cancellation settings.
pub async fn run(
&mut self,
content: impl Into<Vec<ContentBlock>>,
options: RunOptions,
) -> Result<Message, RuntimeError> {
self.idle_requested = false;
self.refresh_tasks_from_disk()?;
let tasks_before_run = self.tasks.clone();
let rounds_before_run = self.rounds_since_task;
let task_disk_state = self.capture_task_disk_state()?;
let run_id = self.start_run_checkpoint()?;
self.memory.begin_run(
run_id,
Message {
role: Role::User,
content: content.into(),
},
)?;
self.mutate_snapshot(|snapshot| {
snapshot.run_generation = snapshot.run_generation.saturating_add(1);
snapshot.status = AgentStatus::AwaitingModel;
});
self.sync_memory_snapshot();
self.emit_event(AgentEvent::RunStarted);
match TurnRunner::new(self, options).run().await {
Ok(()) => {
let final_message = self
.memory
.last_message()
.cloned()
.filter(|message| message.role == Role::Assistant);
let run_delta = self.memory.current_run_delta().unwrap_or_default();
let finalization = (|| {
self.memory.finish_run()?;
self.sync_memory_snapshot();
self.set_status(AgentStatus::Finished);
self.persist_agent_record()?;
self.finish_run_checkpoint()
})();
if let Err(error) = finalization {
return Err(self.handle_finalization_error(error));
}
self.clear_inflight_steering();
self.clear_inflight_team_messages();
self.clear_inflight_background_notifications();
self.runtime
.memory_engine()
.schedule_ingest(crate::memory::IngestRequest {
agent_id: self.id().to_string(),
source_revision: self.memory.revision(),
messages: run_delta,
});
self.emit_event(AgentEvent::RunFinished);
final_message.ok_or(RuntimeError::EmptyAssistantResponse)
}
Err(error) => {
self.idle_requested = false;
self.requeue_inflight_steering();
self.requeue_inflight_team_messages()?;
self.requeue_inflight_background_notifications();
self.restore_task_state(tasks_before_run, rounds_before_run, &task_disk_state)?;
self.memory.rollback_failed_run()?;
self.sync_memory_snapshot();
let message = error.to_string();
self.set_status(AgentStatus::Failed(message.clone()));
self.persist_agent_record()?;
self.fail_run_checkpoint(&message)?;
self.emit_event(AgentEvent::RunFailed { error: message });
Err(error)
}
}
}
fn handle_finalization_error(&mut self, error: RuntimeError) -> RuntimeError {
self.idle_requested = false;
self.requeue_inflight_steering();
let team_requeue = self.requeue_inflight_team_messages();
self.requeue_inflight_background_notifications();
let message = error.to_string();
self.set_status(AgentStatus::Failed(message.clone()));
let _ = self.persist_agent_record();
let _ = self.fail_run_checkpoint(&message);
self.emit_event(AgentEvent::RunFailed { error: message });
match team_requeue {
Ok(()) => error,
Err(requeue_error) => RuntimeError::Store(format!(
"{error}; additionally failed to requeue the team inbox: {requeue_error}"
)),
}
}
pub(crate) fn request_idle(&mut self) {
self.idle_requested = true;
}
pub(crate) fn take_idle_requested(&mut self) -> bool {
std::mem::take(&mut self.idle_requested)
}
}

269
vendor/mentra/src/agent/pending.rs vendored Normal file
View File

@@ -0,0 +1,269 @@
use std::collections::BTreeMap;
use crate::{
ContentBlock, Message, Role,
error::RuntimeError,
provider::{ContentBlockDelta, ProviderEvent, TokenUsage},
tool::ToolCall,
};
use super::{AgentEvent, PendingToolUseSummary, pending_block::PendingContentBlock};
#[derive(Debug, Clone, Default)]
pub struct PendingAssistantTurn {
id: Option<String>,
model: Option<String>,
role: Option<Role>,
blocks: BTreeMap<usize, PendingContentBlock>,
invalid_tool_uses: Vec<InvalidToolUse>,
current_text: String,
current_reasoning: String,
stop_reason: Option<String>,
usage: Option<TokenUsage>,
stopped: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InvalidToolUse {
pub index: usize,
pub id: String,
pub name: String,
pub input_json: String,
pub error: String,
}
impl PendingAssistantTurn {
pub fn apply(&mut self, event: ProviderEvent) -> Result<Vec<AgentEvent>, RuntimeError> {
let mut derived_events = Vec::new();
match event {
ProviderEvent::ResponseHeaders(_)
| ProviderEvent::ResponseCreated
| ProviderEvent::ReasoningSummaryDelta { .. }
| ProviderEvent::ReasoningContentDelta { .. }
| ProviderEvent::ReasoningSummaryPartAdded { .. } => {}
ProviderEvent::MessageStarted { id, model, role } => {
self.id = Some(id);
self.model = Some(model);
self.role = Some(role);
}
ProviderEvent::ContentBlockStarted { index, kind } => {
self.blocks.insert(index, PendingContentBlock::from(kind));
}
ProviderEvent::ContentBlockDelta { index, delta } => {
let block = self.blocks.get_mut(&index).ok_or_else(|| {
RuntimeError::MalformedProviderEvent(format!(
"content block delta received before start for index {index}"
))
})?;
match (block, delta) {
(PendingContentBlock::Text { text, .. }, ContentBlockDelta::Text(delta)) => {
text.push_str(&delta);
self.current_text.push_str(&delta);
derived_events.push(AgentEvent::TextDelta {
delta,
full_text: self.current_text.clone(),
});
}
(
PendingContentBlock::Thinking { thinking, .. },
ContentBlockDelta::ThinkingText(delta),
) => {
thinking.push_str(&delta);
self.current_reasoning.push_str(&delta);
derived_events.push(AgentEvent::ReasoningDelta {
delta,
full_text: self.current_reasoning.clone(),
});
}
(
PendingContentBlock::Thinking { signature, .. },
ContentBlockDelta::ThinkingSignature(delta),
) => {
signature.get_or_insert_with(String::new).push_str(&delta);
}
(
PendingContentBlock::Thinking {
encrypted_content, ..
},
ContentBlockDelta::ThinkingEncryptedContent(value),
) => {
*encrypted_content = Some(value);
}
(
PendingContentBlock::ToolUse {
id,
name,
input_json,
..
},
ContentBlockDelta::ToolUseInputJson(delta),
) => {
input_json.push_str(&delta);
derived_events.push(AgentEvent::ToolUseUpdated {
index,
id: id.clone(),
name: name.clone(),
input_json: input_json.clone(),
});
}
(
PendingContentBlock::ToolResult { content, .. },
ContentBlockDelta::ToolResultContent(delta),
) => match (content, delta) {
(
mentra_provider::ToolResultContent::Text(content),
mentra_provider::ToolResultContent::Text(delta),
) => {
content.push_str(&delta);
}
(content, delta) => {
*content = delta;
}
},
(block, delta) => {
if !block.apply_hosted_delta(&delta) {
return Err(RuntimeError::MalformedProviderEvent(format!(
"delta {delta:?} is not valid for block {}",
block.kind_name()
)));
}
}
}
}
ProviderEvent::ContentBlockStopped { index } => {
let block = self.blocks.get_mut(&index).ok_or_else(|| {
RuntimeError::MalformedProviderEvent(format!(
"content block stop received before start for index {index}"
))
})?;
block.mark_complete();
if let PendingContentBlock::ToolUse {
id,
name,
input_json,
..
} = block
{
match serde_json::from_str(input_json) {
Ok(input) => {
derived_events.push(AgentEvent::ToolUseReady {
index,
call: ToolCall {
id: id.clone(),
name: name.clone(),
input,
},
});
}
Err(source) => {
self.invalid_tool_uses.push(InvalidToolUse {
index,
id: id.clone(),
name: name.clone(),
input_json: input_json.clone(),
error: source.to_string(),
});
}
}
}
}
ProviderEvent::MessageDelta { stop_reason, usage } => {
self.stop_reason = stop_reason;
self.usage = usage;
}
ProviderEvent::MessageStopped => self.stopped = true,
}
Ok(derived_events)
}
pub fn to_message(&self) -> Result<Message, RuntimeError> {
if !self.stopped {
return Err(RuntimeError::MalformedProviderEvent(
"assistant turn ended before MessageStopped".to_string(),
));
}
let role = self.role.clone().ok_or_else(|| {
RuntimeError::MalformedProviderEvent("assistant turn missing role".to_string())
})?;
let mut content = Vec::with_capacity(self.blocks.len());
for (index, block) in &self.blocks {
if !block.is_complete() {
return Err(RuntimeError::MalformedProviderEvent(format!(
"content block {index} did not complete"
)));
}
match block {
PendingContentBlock::ToolUse {
id,
name,
input_json,
..
} => {
if let Ok(input) = serde_json::from_str(input_json) {
content.push(ContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input,
});
}
}
_ => content.push(block.to_content_block()?),
}
}
Ok(Message { role, content })
}
pub fn ready_tool_calls(&self) -> Result<Vec<ToolCall>, RuntimeError> {
let mut tool_calls = Vec::new();
for block in self.blocks.values() {
if let PendingContentBlock::ToolUse {
id,
name,
input_json,
complete,
} = block
&& *complete
&& let Ok(input) = serde_json::from_str(input_json)
{
tool_calls.push(ToolCall {
id: id.clone(),
name: name.clone(),
input,
});
}
}
Ok(tool_calls)
}
pub fn pending_tool_use_summaries(&self) -> Vec<PendingToolUseSummary> {
self.blocks
.values()
.filter_map(PendingContentBlock::tool_use_summary)
.collect()
}
pub(crate) fn invalid_tool_uses(&self) -> &[InvalidToolUse] {
&self.invalid_tool_uses
}
pub fn current_text(&self) -> &str {
&self.current_text
}
pub fn usage(&self) -> Option<&TokenUsage> {
self.usage.as_ref()
}
pub fn stop_reason(&self) -> Option<&str> {
self.stop_reason.as_deref()
}
}

309
vendor/mentra/src/agent/pending_block.rs vendored Normal file
View File

@@ -0,0 +1,309 @@
use crate::{ContentBlock, ImageSource, error::RuntimeError, provider::ContentBlockStart};
use mentra_provider::{
HostedToolSearchCall, HostedWebSearchCall, ImageGenerationCall, ImageGenerationResult,
ReasoningProvenance, ToolResultContent, WebSearchAction,
};
use super::PendingToolUseSummary;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum PendingContentBlock {
Text {
text: String,
complete: bool,
},
Thinking {
thinking: String,
signature: Option<String>,
encrypted_content: Option<String>,
id: Option<String>,
provenance: Option<ReasoningProvenance>,
redacted: bool,
complete: bool,
},
Image {
source: ImageSource,
complete: bool,
},
ToolUse {
id: String,
name: String,
input_json: String,
complete: bool,
},
ToolResult {
tool_use_id: String,
content: ToolResultContent,
is_error: bool,
complete: bool,
},
HostedToolSearch {
call: HostedToolSearchCall,
complete: bool,
},
HostedWebSearch {
call: HostedWebSearchCall,
complete: bool,
},
ImageGeneration {
call: ImageGenerationCall,
complete: bool,
},
}
impl PendingContentBlock {
pub(super) fn is_complete(&self) -> bool {
match self {
PendingContentBlock::Text { complete, .. }
| PendingContentBlock::Thinking { complete, .. }
| PendingContentBlock::Image { complete, .. }
| PendingContentBlock::ToolUse { complete, .. }
| PendingContentBlock::ToolResult { complete, .. }
| PendingContentBlock::HostedToolSearch { complete, .. }
| PendingContentBlock::HostedWebSearch { complete, .. }
| PendingContentBlock::ImageGeneration { complete, .. } => *complete,
}
}
pub(super) fn mark_complete(&mut self) {
match self {
PendingContentBlock::Text { complete, .. }
| PendingContentBlock::Thinking { complete, .. }
| PendingContentBlock::Image { complete, .. }
| PendingContentBlock::ToolUse { complete, .. }
| PendingContentBlock::ToolResult { complete, .. }
| PendingContentBlock::HostedToolSearch { complete, .. }
| PendingContentBlock::HostedWebSearch { complete, .. }
| PendingContentBlock::ImageGeneration { complete, .. } => *complete = true,
}
}
pub(super) fn to_content_block(&self) -> Result<ContentBlock, RuntimeError> {
match self {
PendingContentBlock::Text { text, .. } => Ok(ContentBlock::Text { text: text.clone() }),
PendingContentBlock::Thinking {
thinking,
signature,
encrypted_content,
id,
provenance,
redacted,
..
} => Ok(ContentBlock::Thinking {
thinking: thinking.clone(),
signature: signature.clone(),
encrypted_content: encrypted_content.clone(),
id: id.clone(),
provenance: provenance.clone(),
redacted: *redacted,
}),
PendingContentBlock::Image { source, .. } => Ok(ContentBlock::Image {
source: source.clone(),
}),
PendingContentBlock::ToolUse {
id,
name,
input_json,
..
} => Ok(ContentBlock::ToolUse {
id: id.clone(),
name: name.clone(),
input: serde_json::from_str(input_json).map_err(|source| {
RuntimeError::InvalidToolUseInput {
id: id.clone(),
name: name.clone(),
source,
}
})?,
}),
PendingContentBlock::ToolResult {
tool_use_id,
content,
is_error,
..
} => Ok(ContentBlock::ToolResult {
tool_use_id: tool_use_id.clone(),
content: content.clone(),
is_error: *is_error,
}),
PendingContentBlock::HostedToolSearch { call, .. } => {
Ok(ContentBlock::HostedToolSearch { call: call.clone() })
}
PendingContentBlock::HostedWebSearch { call, .. } => {
Ok(ContentBlock::HostedWebSearch { call: call.clone() })
}
PendingContentBlock::ImageGeneration { call, .. } => {
Ok(ContentBlock::ImageGeneration { call: call.clone() })
}
}
}
pub(super) fn kind_name(&self) -> &'static str {
match self {
PendingContentBlock::Text { .. } => "text",
PendingContentBlock::Thinking { .. } => "thinking",
PendingContentBlock::Image { .. } => "image",
PendingContentBlock::ToolUse { .. } => "tool_use",
PendingContentBlock::ToolResult { .. } => "tool_result",
PendingContentBlock::HostedToolSearch { .. } => "hosted_tool_search",
PendingContentBlock::HostedWebSearch { .. } => "hosted_web_search",
PendingContentBlock::ImageGeneration { .. } => "image_generation",
}
}
pub(super) fn tool_use_summary(&self) -> Option<PendingToolUseSummary> {
match self {
PendingContentBlock::ToolUse {
id,
name,
input_json,
complete,
} => Some(PendingToolUseSummary {
id: id.clone(),
name: name.clone(),
input_json: input_json.clone(),
complete: *complete,
}),
_ => None,
}
}
}
impl From<ContentBlockStart> for PendingContentBlock {
fn from(value: ContentBlockStart) -> Self {
match value {
ContentBlockStart::Text => PendingContentBlock::Text {
text: String::new(),
complete: false,
},
ContentBlockStart::Thinking {
encrypted_content,
id,
provenance,
redacted,
} => PendingContentBlock::Thinking {
thinking: String::new(),
signature: None,
encrypted_content,
id,
provenance,
redacted,
complete: false,
},
ContentBlockStart::Image { source } => PendingContentBlock::Image {
source,
complete: false,
},
ContentBlockStart::ToolUse { id, name } => PendingContentBlock::ToolUse {
id,
name,
input_json: String::new(),
complete: false,
},
ContentBlockStart::ToolResult {
tool_use_id,
is_error,
content,
..
} => PendingContentBlock::ToolResult {
tool_use_id,
content: content.unwrap_or_default(),
is_error,
complete: false,
},
ContentBlockStart::HostedToolSearch { call } => PendingContentBlock::HostedToolSearch {
call,
complete: false,
},
ContentBlockStart::HostedWebSearch { call } => PendingContentBlock::HostedWebSearch {
call,
complete: false,
},
ContentBlockStart::ImageGeneration { call } => PendingContentBlock::ImageGeneration {
call,
complete: false,
},
}
}
}
impl PendingContentBlock {
pub(super) fn apply_hosted_delta(
&mut self,
delta: &crate::provider::ContentBlockDelta,
) -> bool {
match (self, delta) {
(
PendingContentBlock::HostedToolSearch { call, .. },
crate::provider::ContentBlockDelta::HostedToolSearchQuery(query),
) => {
call.query = Some(query.clone());
true
}
(
PendingContentBlock::HostedToolSearch { call, .. },
crate::provider::ContentBlockDelta::HostedToolSearchStatus(status),
) => {
call.status = Some(status.clone());
true
}
(
PendingContentBlock::HostedWebSearch { call, .. },
crate::provider::ContentBlockDelta::HostedWebSearchAction(action),
) => {
call.action = Some(match action {
WebSearchAction::Search { query, queries } => WebSearchAction::Search {
query: query.clone(),
queries: queries.clone(),
},
WebSearchAction::OpenPage { url } => {
WebSearchAction::OpenPage { url: url.clone() }
}
WebSearchAction::FindInPage { url, pattern } => WebSearchAction::FindInPage {
url: url.clone(),
pattern: pattern.clone(),
},
});
true
}
(
PendingContentBlock::HostedWebSearch { call, .. },
crate::provider::ContentBlockDelta::HostedWebSearchStatus(status),
) => {
call.status = Some(status.clone());
true
}
(
PendingContentBlock::ImageGeneration { call, .. },
crate::provider::ContentBlockDelta::ImageGenerationStatus(status),
) => {
call.status = status.clone();
true
}
(
PendingContentBlock::ImageGeneration { call, .. },
crate::provider::ContentBlockDelta::ImageGenerationRevisedPrompt(prompt),
) => {
call.revised_prompt = Some(prompt.clone());
true
}
(
PendingContentBlock::ImageGeneration { call, .. },
crate::provider::ContentBlockDelta::ImageGenerationResult(result),
) => {
call.result = Some(match result {
ImageGenerationResult::Image { source } => ImageGenerationResult::Image {
source: source.clone(),
},
ImageGenerationResult::ArtifactRef { artifact_id } => {
ImageGenerationResult::ArtifactRef {
artifact_id: artifact_id.clone(),
}
}
});
true
}
_ => false,
}
}
}

View File

@@ -0,0 +1,229 @@
//! Per-run round strategy: a host-supplied policy invoked at the two round
//! boundaries of [`Agent::run`](crate::Agent::run).
//!
//! A [`RoundStrategy`] rides on [`RunOptions`](crate::runtime::RunOptions) and so
//! belongs to exactly one run. Its state lives and dies with that run and can
//! never leak through a pooled [`Runtime`](crate::Runtime) into another run. Its
//! absence — the `None` default on `RunOptions` — reproduces mentra's built-in
//! round loop byte-for-byte.
//!
//! The runner invokes the strategy at two commit points: after a tool round's
//! results are committed to the transcript, and after a tool-free assistant
//! message is committed but before the run returns it. At each point the strategy
//! decides how the run proceeds via [`RoundDecision`].
use async_trait::async_trait;
use crate::{ContentBlock, Message, ModelInfo, ReasoningOptions};
/// Identifies which round boundary invoked a [`RoundStrategy`].
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RoundBoundary {
/// A tool round's results were just committed to the transcript. The run is
/// about to advance to the next round.
ToolResultsCommitted,
/// A tool-free assistant message was just committed. The run is about to
/// return it as the final message unless the strategy injects another round.
AssistantMessageCommitted,
}
/// Provider-neutral summary of one committed tool result.
///
/// Exposes only what a host needs to reason about a completed tool round without
/// coupling to mentra's internal tool-result representation.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct RoundToolResult {
/// The `tool_use_id` correlating this result with its originating tool call.
pub tool_use_id: String,
/// The name of the tool that produced the result.
pub tool_name: String,
/// Whether the tool reported an error.
pub is_error: bool,
}
/// How a [`RoundAdjustment`] changes reasoning settings for subsequent rounds.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ReasoningChange {
/// Set reasoning to the given options.
Set(ReasoningOptions),
/// Clear any configured reasoning, restoring the provider's default effort.
Clear,
}
/// A model and/or reasoning override applied to the run's subsequent rounds.
///
/// Applying an adjustment reuses the same live-config mechanics as
/// [`Agent::set_model`](crate::Agent::set_model) and
/// [`Agent::set_reasoning`](crate::Agent::set_reasoning): the change takes effect
/// on the next model request and persists for the remainder of the run (and, under
/// a persisting store, on the agent record) exactly as those methods do. Build one
/// with [`RoundAdjustment::new`] and the `with_*` methods.
#[derive(Clone, Debug, Default)]
#[non_exhaustive]
pub struct RoundAdjustment {
pub(crate) model: Option<ModelInfo>,
pub(crate) reasoning: Option<ReasoningChange>,
}
impl RoundAdjustment {
/// An adjustment that changes nothing.
pub fn new() -> Self {
Self::default()
}
/// Switch the model (and its provider) for subsequent rounds.
pub fn with_model(mut self, model: ModelInfo) -> Self {
self.model = Some(model);
self
}
/// Change the reasoning settings for subsequent rounds.
pub fn with_reasoning(mut self, reasoning: ReasoningChange) -> Self {
self.reasoning = Some(reasoning);
self
}
/// Whether this adjustment would change nothing.
pub fn is_empty(&self) -> bool {
self.model.is_none() && self.reasoning.is_none()
}
}
/// The decision a [`RoundStrategy`] returns at a round boundary.
///
/// Construct one with [`RoundDecision::proceed`], [`RoundDecision::inject`], or
/// [`RoundDecision::stop`] for the common cases, or the variants directly to carry
/// a [`RoundAdjustment`].
#[non_exhaustive]
pub enum RoundDecision {
/// Proceed with the default flow. At [`RoundBoundary::AssistantMessageCommitted`]
/// this accepts the terminal message and lets the run return; at
/// [`RoundBoundary::ToolResultsCommitted`] it advances to the next round. Any
/// carried [`RoundAdjustment`] applies to subsequent rounds.
Continue(RoundAdjustment),
/// Do not end the run: append `content` as a corrective user turn and run
/// another round. At [`RoundBoundary::AssistantMessageCommitted`] this prevents
/// the run from returning. Any carried [`RoundAdjustment`] applies to that round.
Inject {
/// Corrective content appended to the transcript as a user message.
content: Vec<ContentBlock>,
/// Model/reasoning override applied before the injected round.
adjust: RoundAdjustment,
},
/// End the run gracefully at this boundary, committing the transcript exactly
/// as [`RunOptions::stop`](crate::runtime::RunOptions) does. A stop request
/// asserts nothing about whether the run produced a valid answer.
Stop,
}
impl RoundDecision {
/// Continue with no model/reasoning change.
pub fn proceed() -> Self {
RoundDecision::Continue(RoundAdjustment::default())
}
/// Inject corrective context and run another round, with no adjustment.
pub fn inject(content: impl Into<Vec<ContentBlock>>) -> Self {
RoundDecision::Inject {
content: content.into(),
adjust: RoundAdjustment::default(),
}
}
/// Request a graceful stop.
pub fn stop() -> Self {
RoundDecision::Stop
}
}
/// Read-only view of a round boundary handed to a [`RoundStrategy`].
pub struct RoundContext<'a> {
boundary: RoundBoundary,
assistant_message: Option<&'a Message>,
tool_results: &'a [RoundToolResult],
rounds_completed: usize,
model_requests: usize,
transport_retries: usize,
}
impl<'a> RoundContext<'a> {
pub(crate) fn new(
boundary: RoundBoundary,
assistant_message: Option<&'a Message>,
tool_results: &'a [RoundToolResult],
rounds_completed: usize,
model_requests: usize,
transport_retries: usize,
) -> Self {
Self {
boundary,
assistant_message,
tool_results,
rounds_completed,
model_requests,
transport_retries,
}
}
/// Which boundary invoked the strategy.
pub fn boundary(&self) -> RoundBoundary {
self.boundary
}
/// The assistant message just committed. Present only at
/// [`RoundBoundary::AssistantMessageCommitted`], and absent even there if the
/// terminal turn carried no content.
pub fn assistant_message(&self) -> Option<&Message> {
self.assistant_message
}
/// Summaries of the tool results just committed. Present only at
/// [`RoundBoundary::ToolResultsCommitted`]; empty otherwise.
pub fn tool_results(&self) -> &[RoundToolResult] {
self.tool_results
}
/// The number of rounds the run has entered so far, including this one.
pub fn rounds_completed(&self) -> usize {
self.rounds_completed
}
/// The number of provider requests the run has issued so far, including
/// transient transport retries. This mirrors mentra's request counter, which
/// is not a pure logical-round counter.
pub fn model_requests(&self) -> usize {
self.model_requests
}
/// The number of transient transport-connection retries the run has made so
/// far — the subset of [`model_requests`](Self::model_requests) that were
/// *not* the round's successful attempt. Kept distinct from
/// [`rounds_completed`](Self::rounds_completed), which counts only completed
/// logical rounds: a round that needed retries before its connection opened
/// still counts as exactly one completed round.
pub fn transport_retries(&self) -> usize {
self.transport_retries
}
}
/// A host-supplied policy invoked at each round boundary of a single
/// [`Agent::run`](crate::Agent::run) invocation.
///
/// The strategy is carried on [`RunOptions`](crate::runtime::RunOptions) and so is
/// bound to exactly one run: its state lives and dies with that run and can never
/// leak through a pooled [`Runtime`](crate::Runtime) into another run. Its absence
/// — the `None` default — reproduces mentra's built-in round loop exactly.
///
/// At each boundary the strategy may [continue](RoundDecision::Continue),
/// [inject](RoundDecision::Inject) corrective context, switch the next round's
/// model or reasoning via a [`RoundAdjustment`], or request a graceful
/// [stop](RoundDecision::Stop). Every decision still passes through the run's
/// existing budget, cancellation, and deadline checks; an injected round is a
/// normal round in every respect.
#[async_trait]
pub trait RoundStrategy: Send + Sync {
/// Decide how the run should proceed at the given boundary.
async fn on_round(&self, ctx: RoundContext<'_>) -> RoundDecision;
}

782
vendor/mentra/src/agent/runner.rs vendored Normal file
View File

@@ -0,0 +1,782 @@
use std::{borrow::Cow, collections::HashMap, sync::Arc, time::Duration};
use crate::{
ContentBlock, Message, Role,
background::BackgroundNotification,
error::RuntimeError,
memory::journal::PendingTurnState,
memory::{MemorySearchMode, MemorySearchRequest, build_search_query, recalled_memory_message},
provider::Request,
runtime::{EarlyEnd, RunOptions, RuntimeHookEvent, control::is_transient_provider_error},
team::format_inbox,
tool::{ToolCall, ToolRuntime},
transcript::{DelegationArtifact, DelegationKind, DelegationStatus},
};
use super::{
Agent, AgentEvent, AgentStatus, PendingAssistantTurn,
pending::InvalidToolUse,
round_strategy::{
ReasoningChange, RoundAdjustment, RoundBoundary, RoundContext, RoundDecision,
RoundStrategy, RoundToolResult,
},
};
/// How the round loop should proceed after a [`RoundStrategy`] decision.
enum RoundFlow {
/// Advance to the next round (or, at the assistant boundary, return).
Continue,
/// A corrective turn was injected; run another round.
Inject,
/// End the run gracefully at this boundary.
Stop,
}
const MEMORY_SEARCH_TIMEOUT: Duration = Duration::from_millis(250);
pub(super) struct TurnRunner<'a> {
agent: &'a mut Agent,
options: RunOptions,
model_requests: usize,
/// Transient transport-connection retries made so far, counted separately
/// from `model_requests` (which includes them) and from the loop's `rounds`
/// counter (which counts only completed logical rounds). See
/// [`RoundContext::transport_retries`](crate::agent::RoundContext::transport_retries).
transport_retries: usize,
tool_runtime: ToolRuntime,
}
struct StreamedTurn {
attempt: usize,
pending: PendingAssistantTurn,
}
impl<'a> TurnRunner<'a> {
pub(super) fn new(agent: &'a mut Agent, options: RunOptions) -> Self {
let tool_runtime = ToolRuntime::new(agent);
Self {
agent,
options,
model_requests: 0,
transport_retries: 0,
tool_runtime,
}
}
pub(super) async fn run(&mut self) -> Result<(), RuntimeError> {
let mut rounds = 0usize;
loop {
self.options.check_limits()?;
// Graceful stop: end the run successfully at this round boundary (where
// the transcript is consistent), keeping the committed work, rather than
// failing and rolling back the way `cancellation` does. Lets a caller
// stop gathering once enough is done while preserving the context for a
// follow-up turn on the same agent. Recorded on the way out because a
// successful return says nothing about which of these two boundary
// checks produced it.
if self.options.stop_requested() {
self.options.record_early_end(EarlyEnd::StopRequested);
return Ok(());
}
// Soft aggregate token bound: usage is only known once a round has
// completed, so this never preempts a round in progress — it only
// refuses to start another one once the last round's reported usage
// pushed the cumulative total to or past the bound. The transcript
// through the last completed round stays committed, exactly like
// `stop` above. Reached only when no stop was requested, which is
// the precedence `EarlyEnd::StopRequested` documents.
if self.options.token_budget_exceeded() {
self.options.record_early_end(EarlyEnd::TokenBudget);
return Ok(());
}
if let Some(limit) = self.agent.max_rounds()
&& rounds >= limit
{
return Err(RuntimeError::MaxRoundsExceeded(limit));
}
rounds += 1;
self.agent.update_run_state("awaiting_model", None)?;
let streamed = self.stream_turn().await?;
let invalid_tool_uses = streamed.pending.invalid_tool_uses().to_vec();
if let Err(error) = self.commit_assistant_message(&streamed.pending) {
self.emit_model_response_finished(
streamed.attempt,
false,
Some(error.to_string()),
None,
None,
)?;
return Err(error);
}
let usage = streamed.pending.usage().cloned();
self.emit_model_response_finished(
streamed.attempt,
true,
None,
streamed.pending.stop_reason().map(str::to_string),
usage.clone(),
)?;
// Emit token usage report if available.
if let Some(ref u) = usage {
self.agent.emit_event(AgentEvent::UsageReport {
input_tokens: u.input_tokens.unwrap_or(0),
output_tokens: u.output_tokens.unwrap_or(0),
cache_read_tokens: u.cache_read_input_tokens.unwrap_or(0),
cache_creation_tokens: u.cache_creation_input_tokens.unwrap_or(0),
});
self.options
.record_tokens(u.input_tokens.unwrap_or(0) + u.output_tokens.unwrap_or(0));
}
if !invalid_tool_uses.is_empty() {
self.append_invalid_tool_input_feedback(&invalid_tool_uses)?;
self.agent.note_round_without_task();
self.agent.persist_agent_record()?;
continue;
}
let tool_calls = streamed.pending.ready_tool_calls()?;
if tool_calls.is_empty() {
if self.agent.has_pending_steer() {
if !self.next_request_is_available(rounds)? {
self.agent.note_round_without_task();
return Ok(());
}
if let Some(content) = self.agent.drain_steer() {
self.inject_round_context(content)?;
self.agent.note_round_without_task();
self.agent.persist_agent_record()?;
continue;
}
}
if self.agent.has_pending_follow_up() {
if !self.next_request_is_available(rounds)? {
self.agent.note_round_without_task();
return Ok(());
}
if let Some(content) = self.agent.drain_follow_up() {
self.inject_round_context(content)?;
self.agent.note_round_without_task();
self.agent.persist_agent_record()?;
continue;
}
}
if let Some(strategy) = self.options.round_strategy.clone() {
let assistant_message = self
.agent
.last_message()
.filter(|message| message.role == Role::Assistant)
.cloned();
let flow = self
.run_round_strategy(
&strategy,
RoundBoundary::AssistantMessageCommitted,
assistant_message.as_ref(),
&[],
rounds,
)
.await?;
match flow {
RoundFlow::Inject => {
self.agent.note_round_without_task();
self.agent.persist_agent_record()?;
continue;
}
RoundFlow::Continue | RoundFlow::Stop => {
self.agent.note_round_without_task();
return Ok(());
}
}
}
self.agent.note_round_without_task();
return Ok(());
}
let round_strategy = self.options.round_strategy.clone();
let call_names = round_strategy
.as_ref()
.map(|_| collect_tool_call_names(&tool_calls));
let execution = self
.tool_runtime
.execute_calls(self.agent, &self.options, tool_calls)
.await?;
let tool_results = call_names
.map(|names| summarize_tool_results(&execution.results, &names))
.unwrap_or_default();
let tool_result_message = Message {
role: Role::User,
content: execution.results,
};
if execution.details.is_empty() {
self.agent.memory.append_message(tool_result_message)?;
} else {
self.agent
.memory
.append_message_with_details(tool_result_message, execution.details)?;
}
self.agent.sync_memory_snapshot();
if execution.successful_task {
self.agent.record_task_activity();
} else {
self.agent.note_round_without_task();
}
self.agent.persist_agent_record()?;
if execution.end_turn {
return Ok(());
}
if self.agent.has_pending_steer() {
if !self.next_request_is_available(rounds)? {
return Ok(());
}
if let Some(content) = self.agent.drain_steer() {
self.inject_round_context(content)?;
continue;
}
}
if let Some(strategy) = round_strategy {
let flow = self
.run_round_strategy(
&strategy,
RoundBoundary::ToolResultsCommitted,
None,
&tool_results,
rounds,
)
.await?;
if matches!(flow, RoundFlow::Stop) {
return Ok(());
}
}
}
}
/// Returns whether a queued injection can be followed by a provider
/// request. This check happens before draining so a graceful stop or a
/// hard run limit cannot consume queue entries that no request will see.
///
/// The two graceful bounds are asked separately rather than as one `||` so
/// that the run ends reported as well as ended: this is a round boundary
/// like the one at the top of [`run`](Self::run), it ends the turn the same
/// way, and it therefore answers "why" the same way and in the same order.
fn next_request_is_available(&self, rounds: usize) -> Result<bool, RuntimeError> {
self.options.check_limits()?;
if self.options.stop_requested() {
self.options.record_early_end(EarlyEnd::StopRequested);
return Ok(false);
}
if self.options.token_budget_exceeded() {
self.options.record_early_end(EarlyEnd::TokenBudget);
return Ok(false);
}
if let Some(limit) = self.agent.max_rounds()
&& rounds >= limit
{
return Err(RuntimeError::MaxRoundsExceeded(limit));
}
if self.model_requests >= self.options.model_budget() {
return Err(RuntimeError::ModelBudgetExceeded(
self.options.model_budget(),
));
}
Ok(true)
}
/// Invokes the per-run [`RoundStrategy`] at a round boundary and applies its
/// decision: any model/reasoning switch, then any corrective injection. The
/// returned [`RoundFlow`] tells the loop whether to continue, treat the round
/// as injected, or stop gracefully.
async fn run_round_strategy(
&mut self,
strategy: &Arc<dyn RoundStrategy>,
boundary: RoundBoundary,
assistant_message: Option<&Message>,
tool_results: &[RoundToolResult],
rounds: usize,
) -> Result<RoundFlow, RuntimeError> {
let context = RoundContext::new(
boundary,
assistant_message,
tool_results,
rounds,
self.model_requests,
self.transport_retries,
);
match strategy.on_round(context).await {
RoundDecision::Continue(adjustment) => {
self.apply_round_adjustment(adjustment)?;
Ok(RoundFlow::Continue)
}
RoundDecision::Inject { content, adjust } => {
self.apply_round_adjustment(adjust)?;
self.inject_round_context(content)?;
Ok(RoundFlow::Inject)
}
RoundDecision::Stop => Ok(RoundFlow::Stop),
}
}
/// Applies a strategy-requested model/reasoning switch to subsequent rounds,
/// reusing the live-config mechanics of [`Agent::set_model`] and
/// [`Agent::set_reasoning`] (which `stream_turn` reads live on the next round).
fn apply_round_adjustment(&mut self, adjustment: RoundAdjustment) -> Result<(), RuntimeError> {
if let Some(model) = adjustment.model {
self.agent.set_model(model)?;
}
match adjustment.reasoning {
Some(ReasoningChange::Set(options)) => self.agent.set_reasoning(Some(options))?,
Some(ReasoningChange::Clear) => self.agent.set_reasoning(None)?,
None => {}
}
Ok(())
}
/// Appends strategy-supplied corrective context as a committed user turn,
/// mirroring [`append_invalid_tool_input_feedback`](Self::append_invalid_tool_input_feedback)
/// so the injection is part of the replayable transcript.
fn inject_round_context(&mut self, content: Vec<ContentBlock>) -> Result<(), RuntimeError> {
self.agent.memory.append_message(Message {
role: Role::User,
content,
})?;
self.agent.sync_memory_snapshot();
Ok(())
}
async fn stream_turn(&mut self) -> Result<StreamedTurn, RuntimeError> {
if self.model_requests >= self.options.model_budget() {
return Err(RuntimeError::ModelBudgetExceeded(
self.options.model_budget(),
));
}
self.agent.inject_team_inbox()?;
self.agent.inject_background_notifications()?;
self.agent.set_status(AgentStatus::AwaitingModel);
self.agent.refresh_tasks_from_disk()?;
self.agent.auto_compact_if_needed().await?;
let provider = self.agent.provider.clone();
let tools = self.agent.tools();
let mut request_history = self.agent.micro_compacted_history();
if let Some(recalled) = self.recalled_memory_message(&request_history).await {
request_history.push(recalled);
}
self.agent.inject_teammate_identity(&mut request_history);
let mut provider_request_options = self.agent.config.provider_request_options.clone();
// Settled once, before the first attempt: a transport the provider
// cannot serve is a configuration error, and retrying it would only
// reach the same refusal five more times.
crate::provider::select_responses_transport(
provider.as_ref(),
self.agent.runtime.responses_transport(),
&mut provider_request_options,
)?;
let request = Request {
model: self.agent.model.as_str().into(),
system: self.agent.effective_system_prompt(),
messages: request_history.into(),
tools: tools.as_ref().into(),
tool_choice: self.agent.tool_choice(),
temperature: self.agent.config.temperature,
max_output_tokens: self.agent.config.max_output_tokens,
metadata: Cow::Borrowed(&self.agent.config.metadata),
provider_request_options,
};
let mut attempt = 0usize;
let mut stream = loop {
self.options.check_limits()?;
attempt += 1;
self.model_requests += 1;
self.agent
.runtime
.emit_hook(RuntimeHookEvent::ModelRequestStarted {
agent_id: self.agent.id().to_string(),
model: self.agent.model().to_string(),
attempt,
})?;
match provider.stream(request.clone()).await {
Ok(stream) => {
self.agent
.runtime
.emit_hook(RuntimeHookEvent::ModelRequestFinished {
agent_id: self.agent.id().to_string(),
model: self.agent.model().to_string(),
attempt,
success: true,
error: None,
})?;
break stream;
}
Err(error)
if attempt <= self.options.retry_budget
&& is_transient_provider_error(&error) =>
{
self.transport_retries += 1;
self.agent
.runtime
.emit_hook(RuntimeHookEvent::ModelRequestFinished {
agent_id: self.agent.id().to_string(),
model: self.agent.model().to_string(),
attempt,
success: false,
error: Some(error.to_string()),
})?;
if self.model_requests >= self.options.model_budget() {
return Err(RuntimeError::ModelBudgetExceeded(
self.options.model_budget(),
));
}
// The provider's own answer, when it gave one, beats a
// schedule that cannot know how long the window is. See
// `ProviderRetry::delay_for` for which of the two wins.
let delay = self
.options
.provider_retry
.delay_for(attempt, error.retry_after());
self.agent.emit_event(AgentEvent::RetryAttempt {
agent_id: self.agent.id().to_string(),
error_message: error.to_string(),
attempt: attempt as u32,
max_attempts: self.options.retry_budget as u32,
next_delay_ms: delay.as_millis() as u64,
});
tokio::time::sleep(delay).await;
continue;
}
Err(error) => {
self.agent
.runtime
.emit_hook(RuntimeHookEvent::ModelRequestFinished {
agent_id: self.agent.id().to_string(),
model: self.agent.model().to_string(),
attempt,
success: false,
error: Some(error.to_string()),
})?;
return Err(RuntimeError::FailedToStreamResponse(error));
}
}
};
let mut pending = PendingAssistantTurn::default();
self.agent.set_status(AgentStatus::Streaming);
self.agent
.memory
.update_pending_turn(Self::pending_state(&pending))?;
self.agent.sync_memory_snapshot();
while let Some(event) = stream.recv().await {
if let Err(error) = self.options.check_limits() {
self.emit_model_response_finished(
attempt,
false,
Some(error.to_string()),
None,
None,
)?;
return Err(error);
}
let event = match event {
Ok(event) => event,
Err(error) => {
let runtime_error = RuntimeError::FailedToStreamResponse(error);
let error_message = runtime_error.to_string();
self.emit_model_response_finished(
attempt,
false,
Some(error_message),
None,
None,
)?;
return Err(runtime_error);
}
};
let derived_events = match pending.apply(event) {
Ok(derived_events) => derived_events,
Err(error) => {
self.emit_model_response_finished(
attempt,
false,
Some(error.to_string()),
None,
None,
)?;
return Err(error);
}
};
self.agent
.memory
.update_pending_turn(Self::pending_state(&pending))?;
self.agent.sync_memory_snapshot();
for event in derived_events {
self.agent.emit_event(event);
}
}
Ok(StreamedTurn { attempt, pending })
}
fn commit_assistant_message(
&mut self,
pending: &PendingAssistantTurn,
) -> Result<(), RuntimeError> {
let assistant_message = pending.to_message()?;
if assistant_message.content.is_empty() {
self.agent.memory.clear_pending_turn()?;
self.agent.sync_memory_snapshot();
return Ok(());
}
self.agent
.memory
.commit_assistant_message(assistant_message.clone())?;
self.agent.sync_memory_snapshot();
self.agent
.emit_event(AgentEvent::AssistantMessageCommitted {
message: assistant_message,
});
Ok(())
}
fn append_invalid_tool_input_feedback(
&mut self,
invalid_tool_uses: &[InvalidToolUse],
) -> Result<(), RuntimeError> {
self.agent
.memory
.append_message(Message::user(ContentBlock::text(
format_invalid_tool_input_feedback(invalid_tool_uses),
)))?;
self.agent.sync_memory_snapshot();
Ok(())
}
fn pending_state(pending: &PendingAssistantTurn) -> PendingTurnState {
PendingTurnState::new(
pending.current_text().to_string(),
pending.pending_tool_use_summaries(),
)
}
fn emit_model_response_finished(
&self,
attempt: usize,
success: bool,
error: Option<String>,
stop_reason: Option<String>,
usage: Option<crate::provider::TokenUsage>,
) -> Result<(), RuntimeError> {
self.agent
.runtime
.emit_hook(RuntimeHookEvent::ModelResponseFinished {
agent_id: self.agent.id().to_string(),
model: self.agent.model().to_string(),
attempt,
success,
error,
stop_reason,
usage,
})
}
async fn recalled_memory_message(&self, request_history: &[Message]) -> Option<Message> {
if !self.agent.config().memory.auto_recall_enabled {
return None;
}
let query = build_search_query(request_history, self.agent.tasks());
if query.trim().is_empty() {
return None;
}
let memory = self.agent.runtime.memory_engine();
let search = memory.search(MemorySearchRequest {
agent_id: self.agent.id().to_string(),
query,
limit: self.agent.config().memory.auto_recall_limit,
char_budget: Some(self.agent.config().memory.auto_recall_char_budget),
mode: MemorySearchMode::Automatic,
filter: crate::memory::MemoryListFilter::default(),
});
let hits = match tokio::time::timeout(MEMORY_SEARCH_TIMEOUT, search).await {
Ok(Ok(hits)) => hits,
Ok(Err(_error)) => return None,
Err(_) => {
let _ = self
.agent
.runtime
.emit_hook(RuntimeHookEvent::MemorySearchFinished {
agent_id: self.agent.id().to_string(),
success: false,
result_count: 0,
error: Some("memory search timed out".to_string()),
});
return None;
}
};
recalled_memory_message(&hits, self.agent.config().memory.auto_recall_char_budget)
}
}
/// Builds a `tool_use_id -> tool_name` map from the round's tool calls so a
/// committed tool result (which carries only `tool_use_id`) can be summarized
/// with its originating tool name for a [`RoundStrategy`].
fn collect_tool_call_names(calls: &[ToolCall]) -> HashMap<String, String> {
calls
.iter()
.map(|call| (call.id.clone(), call.name.clone()))
.collect()
}
/// Summarizes committed tool-result blocks into provider-neutral
/// [`RoundToolResult`]s for a [`RoundStrategy`], correlating each result with its
/// originating tool name via `names`.
fn summarize_tool_results(
results: &[ContentBlock],
names: &HashMap<String, String>,
) -> Vec<RoundToolResult> {
results
.iter()
.filter_map(|block| match block {
ContentBlock::ToolResult {
tool_use_id,
is_error,
..
} => Some(RoundToolResult {
tool_use_id: tool_use_id.clone(),
tool_name: names.get(tool_use_id).cloned().unwrap_or_default(),
is_error: *is_error,
}),
_ => None,
})
.collect()
}
fn format_invalid_tool_input_feedback(invalid_tool_uses: &[InvalidToolUse]) -> String {
let mut feedback = String::from(
"One or more tool calls could not be executed because their JSON arguments were invalid. \
Please retry with valid JSON that matches the tool schema exactly.\n\n",
);
for invalid in invalid_tool_uses {
feedback.push_str(&format!(
"Tool '{}' ({}) failed to parse: {}.\nRaw arguments (truncated): {}\n\n",
invalid.name,
invalid.id,
invalid.error,
truncate_tool_input(&invalid.input_json, 240)
));
}
feedback.truncate(feedback.trim_end().len());
feedback
}
fn truncate_tool_input(input: &str, max_chars: usize) -> String {
let mut truncated = input.chars().take(max_chars).collect::<String>();
if input.chars().count() > max_chars {
truncated.push_str("...");
}
truncated
}
impl Agent {
pub(super) fn inject_team_inbox(&mut self) -> Result<(), RuntimeError> {
let messages = self
.runtime
.read_team_inbox(self.config.team.team_dir.as_path(), &self.name)?;
if messages.is_empty() {
return Ok(());
}
self.inflight_team_messages.extend(messages.iter().cloned());
for message in &messages {
let content = format_inbox(std::slice::from_ref(message));
self.record_delegation_request(
content,
DelegationArtifact {
kind: DelegationKind::Teammate,
agent_id: message.sender.clone(),
agent_name: message.sender.clone(),
role: Some("teammate".to_string()),
status: DelegationStatus::Requested,
task_summary: message.content.clone(),
result_summary: None,
artifacts: Vec::new(),
},
None,
)?;
}
self.sync_memory_snapshot();
Ok(())
}
pub(super) fn clear_inflight_team_messages(&mut self) {
let _ = self
.runtime
.acknowledge_team_messages(self.config.team.team_dir.as_path(), &self.name);
self.inflight_team_messages.clear();
}
pub(super) fn requeue_inflight_team_messages(&mut self) -> Result<(), RuntimeError> {
let messages = std::mem::take(&mut self.inflight_team_messages);
self.runtime.requeue_team_messages(
self.config.team.team_dir.as_path(),
&self.name,
messages,
)
}
pub(super) fn inject_background_notifications(&mut self) -> Result<(), RuntimeError> {
let notifications = self.runtime.drain_background_notifications(&self.id);
if notifications.is_empty() {
return Ok(());
}
self.inflight_background_notifications
.extend(notifications.iter().cloned());
self.record_canonical_context(format_background_results(&notifications))?;
self.sync_memory_snapshot();
Ok(())
}
pub(super) fn clear_inflight_background_notifications(&mut self) {
self.runtime.acknowledge_background_notifications(&self.id);
self.inflight_background_notifications.clear();
}
pub(super) fn requeue_inflight_background_notifications(&mut self) {
let notifications = std::mem::take(&mut self.inflight_background_notifications);
self.runtime
.requeue_background_notifications(&self.id, notifications);
}
}
fn format_background_results(notifications: &[BackgroundNotification]) -> String {
let lines = notifications
.iter()
.map(|notification| {
format!(
"[bg:{}] status={} command=\"{}\" output=\"{}\"",
notification.task_id,
notification.status,
escape_background_field(&notification.command),
escape_background_field(&notification.output_preview),
)
})
.collect::<Vec<_>>()
.join("\n");
format!("<background-results>\n{lines}\n</background-results>")
}
fn escape_background_field(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}

114
vendor/mentra/src/agent/snapshot.rs vendored Normal file
View File

@@ -0,0 +1,114 @@
use crate::{agent::AgentEvent, runtime::PersistedAgentRecord};
use super::{Agent, AgentEventTapGuard, AgentStatus};
impl Agent {
pub(crate) fn emit_event(&self, event: AgentEvent) {
self.event_bus.send(event);
}
pub(crate) fn event_sender(&self) -> super::AgentEventBus {
self.event_bus.clone()
}
pub(crate) fn register_event_tap(
&self,
tap: impl Fn(&AgentEvent) + Send + Sync + 'static,
) -> AgentEventTapGuard {
self.event_bus.register_tap(tap)
}
pub(crate) fn set_status(&mut self, status: AgentStatus) {
self.mutate_snapshot(|snapshot| {
snapshot.status = status;
});
}
pub(super) fn publish_snapshot(&self) {
let snapshot = self
.snapshot
.lock()
.expect("agent snapshot poisoned")
.clone();
self.snapshot_tx.send_replace(snapshot);
}
pub(super) fn mutate_snapshot(&self, update: impl FnOnce(&mut crate::agent::AgentSnapshot)) {
{
let mut snapshot = self.snapshot.lock().expect("agent snapshot poisoned");
update(&mut snapshot);
}
self.publish_snapshot();
}
pub(crate) fn sync_memory_snapshot(&self) {
let memory_view = self.memory.snapshot_view();
self.mutate_snapshot(|snapshot| {
snapshot.history_len = memory_view.history_len;
snapshot.current_text = memory_view.current_text;
snapshot.pending_tool_uses = memory_view.pending_tool_uses;
});
}
pub(crate) fn persisted_record(&self) -> PersistedAgentRecord {
PersistedAgentRecord {
id: self.id.clone(),
runtime_identifier: self.runtime.persisted_runtime_identifier().to_string(),
name: self.name.clone(),
model: self.model.clone(),
provider_id: self.provider_id.clone(),
config: self.config.clone(),
hidden_tools: self.hidden_tools.clone(),
max_rounds: self.max_rounds,
teammate_identity: self.teammate_identity.clone(),
rounds_since_task: self.rounds_since_task,
idle_requested: self.idle_requested,
status: self.watch_snapshot().borrow().status.clone(),
subagents: self.watch_snapshot().borrow().subagents.clone(),
}
}
pub(crate) fn persist_agent_record(&self) -> Result<(), crate::error::RuntimeError> {
self.runtime
.store()
.save_agent_record(&self.persisted_record())
}
pub(crate) fn start_run_checkpoint(&mut self) -> Result<String, crate::runtime::RuntimeError> {
let run_id = self.runtime.store().start_run(&self.id)?;
self.current_run_id = Some(run_id.clone());
Ok(run_id)
}
pub(crate) fn update_run_state(
&self,
state: &str,
error: Option<&str>,
) -> Result<(), crate::runtime::RuntimeError> {
if let Some(run_id) = &self.current_run_id {
self.runtime
.store()
.update_run_state(run_id, state, error)?;
}
Ok(())
}
pub(crate) fn finish_run_checkpoint(&mut self) -> Result<(), crate::runtime::RuntimeError> {
if let Some(run_id) = self.current_run_id.as_deref() {
self.runtime.store().finish_run(run_id)?;
self.current_run_id = None;
}
Ok(())
}
pub(crate) fn fail_run_checkpoint(
&mut self,
error: &str,
) -> Result<(), crate::runtime::RuntimeError> {
if let Some(run_id) = self.current_run_id.as_deref() {
self.runtime.store().fail_run(run_id, error)?;
self.current_run_id = None;
}
Ok(())
}
}

217
vendor/mentra/src/agent/steering.rs vendored Normal file
View File

@@ -0,0 +1,217 @@
use std::{
collections::VecDeque,
sync::{Arc, Mutex, MutexGuard},
};
use crate::{ContentBlock, Message, error::RuntimeError, runtime::RunOptions};
use super::Agent;
/// Controls how many queued entries are injected at one eligible boundary.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum QueueMode {
/// Drain every currently queued entry into one model round.
All,
/// Drain exactly one entry per eligible boundary.
#[default]
OneAtATime,
}
#[derive(Default)]
struct SteeringQueues {
steer: VecDeque<Vec<ContentBlock>>,
follow_up: VecDeque<Vec<ContentBlock>>,
steer_mode: QueueMode,
follow_up_mode: QueueMode,
}
/// Cloneable, agent-scoped handle for live steering and deferred follow-ups.
///
/// Obtain this handle before calling [`Agent::run`](crate::Agent::run). `steer`
/// entries are eligible at either committed round boundary; `follow_up`
/// entries are eligible only when a tool-free assistant response would
/// otherwise stop the run. Queues are in-memory and survive sequential runs of
/// this agent, but are never shared with another agent on the same runtime.
#[derive(Clone, Default)]
pub struct SteeringHandle {
queues: Arc<Mutex<SteeringQueues>>,
}
impl SteeringHandle {
pub(crate) fn new() -> Self {
Self::default()
}
/// Enqueues context for the next eligible round boundary.
pub fn steer(&self, content: impl Into<Vec<ContentBlock>>) {
let content = content.into();
if !content.is_empty() {
self.lock().steer.push_back(content);
}
}
/// Enqueues context used only when a run would otherwise stop.
pub fn follow_up(&self, content: impl Into<Vec<ContentBlock>>) {
let content = content.into();
if !content.is_empty() {
self.lock().follow_up.push_back(content);
}
}
/// Removes steering entries that have not yet been injected.
pub fn clear_steer(&self) {
self.lock().steer.clear();
}
/// Removes follow-up entries that have not yet been injected.
pub fn clear_follow_up(&self) {
self.lock().follow_up.clear();
}
/// Returns whether either queue contains an entry awaiting injection.
pub fn has_pending(&self) -> bool {
let queues = self.lock();
!queues.steer.is_empty() || !queues.follow_up.is_empty()
}
/// Sets the steering drain mode for subsequent boundaries.
pub fn set_steer_mode(&self, mode: QueueMode) {
self.lock().steer_mode = mode;
}
/// Sets the follow-up drain mode for subsequent would-stop boundaries.
pub fn set_follow_up_mode(&self, mode: QueueMode) {
self.lock().follow_up_mode = mode;
}
pub(crate) fn has_steer(&self) -> bool {
!self.lock().steer.is_empty()
}
pub(crate) fn has_follow_up(&self) -> bool {
!self.lock().follow_up.is_empty()
}
fn drain_steer(&self) -> Vec<Vec<ContentBlock>> {
let mut queues = self.lock();
let mode = queues.steer_mode;
drain(&mut queues.steer, mode)
}
fn drain_follow_up(&self) -> Vec<Vec<ContentBlock>> {
let mut queues = self.lock();
let mode = queues.follow_up_mode;
drain(&mut queues.follow_up, mode)
}
fn prepend_steer(&self, entries: Vec<Vec<ContentBlock>>) {
prepend(&mut self.lock().steer, entries);
}
fn prepend_follow_up(&self, entries: Vec<Vec<ContentBlock>>) {
prepend(&mut self.lock().follow_up, entries);
}
fn lock(&self) -> MutexGuard<'_, SteeringQueues> {
self.queues
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
}
impl Agent {
/// Returns an agent-scoped handle suitable for use while `run(&mut self)`
/// holds the mutable agent borrow.
pub fn steering_handle(&self) -> SteeringHandle {
self.steering.clone()
}
/// Idle convenience for enqueueing a steer on this agent.
pub fn steer(&self, content: impl Into<Vec<ContentBlock>>) {
self.steering.steer(content);
}
/// Idle convenience for enqueueing a would-stop follow-up on this agent.
pub fn follow_up(&self, content: impl Into<Vec<ContentBlock>>) {
self.steering.follow_up(content);
}
/// Starts an idle run from the next queued steer.
///
/// This is the only automatic consumption point for a steer while no run is
/// active. Follow-ups remain reserved for a running turn's would-stop
/// boundary. A failed run prepends the consumed entry back onto the queue.
pub async fn run_queued(&mut self, options: RunOptions) -> Result<Message, RuntimeError> {
let Some(content) = self.drain_steer() else {
return Err(RuntimeError::OperationDenied(
"no queued steering input is available".to_string(),
));
};
let result = self.run(content, options).await;
if result.is_err() {
// `run` normally requeues on its rollback path. This second call is
// intentionally idempotent and covers errors raised before the run
// checkpoint is established.
self.requeue_inflight_steering();
}
result
}
pub(super) fn has_pending_steer(&self) -> bool {
self.steering.has_steer()
}
pub(super) fn has_pending_follow_up(&self) -> bool {
self.steering.has_follow_up()
}
pub(super) fn drain_steer(&mut self) -> Option<Vec<ContentBlock>> {
let entries = self.steering.drain_steer();
if entries.is_empty() {
return None;
}
let content = flatten(&entries);
self.inflight_steer.extend(entries);
Some(content)
}
pub(super) fn drain_follow_up(&mut self) -> Option<Vec<ContentBlock>> {
let entries = self.steering.drain_follow_up();
if entries.is_empty() {
return None;
}
let content = flatten(&entries);
self.inflight_follow_up.extend(entries);
Some(content)
}
pub(super) fn clear_inflight_steering(&mut self) {
self.inflight_steer.clear();
self.inflight_follow_up.clear();
}
pub(super) fn requeue_inflight_steering(&mut self) {
self.steering
.prepend_steer(std::mem::take(&mut self.inflight_steer));
self.steering
.prepend_follow_up(std::mem::take(&mut self.inflight_follow_up));
}
}
fn drain(queue: &mut VecDeque<Vec<ContentBlock>>, mode: QueueMode) -> Vec<Vec<ContentBlock>> {
match mode {
QueueMode::All => queue.drain(..).collect(),
QueueMode::OneAtATime => queue.pop_front().into_iter().collect(),
}
}
fn prepend(queue: &mut VecDeque<Vec<ContentBlock>>, entries: Vec<Vec<ContentBlock>>) {
for entry in entries.into_iter().rev() {
queue.push_front(entry);
}
}
fn flatten(entries: &[Vec<ContentBlock>]) -> Vec<ContentBlock> {
entries.iter().flatten().cloned().collect()
}

128
vendor/mentra/src/agent/subagent.rs vendored Normal file
View File

@@ -0,0 +1,128 @@
use std::{borrow::Cow, collections::HashSet, sync::Arc};
use crate::{
Role,
error::RuntimeError,
provider::Provider,
runtime::{RuntimeIntrinsicTool, handle::RuntimeHandle},
};
use super::{
Agent, AgentConfig, AgentSpawnOptions, SpawnedAgentStatus, SpawnedAgentSummary,
TeammateIdentity,
};
const SUBAGENT_MAX_ROUNDS: usize = 30;
const SUBAGENT_SYSTEM_PROMPT: &str = "You are a subagent working for another agent. Solve the delegated task, use tools when helpful, and finish with a concise final answer for the parent agent.";
#[derive(Clone)]
pub(crate) struct DisposableSubagentTemplate {
runtime: RuntimeHandle,
model: String,
parent_name: String,
config: AgentConfig,
provider: Arc<dyn Provider>,
hidden_tools: HashSet<String>,
teammate_identity: Option<TeammateIdentity>,
}
impl DisposableSubagentTemplate {
pub(crate) fn from_agent(agent: &Agent) -> Self {
Self {
runtime: agent.runtime.clone(),
model: agent.model.clone(),
parent_name: agent.name.clone(),
config: agent.config.clone(),
provider: Arc::clone(&agent.provider),
hidden_tools: agent.hidden_tools.clone(),
teammate_identity: agent.teammate_identity.clone(),
}
}
pub(crate) fn spawn(&self) -> Result<Agent, RuntimeError> {
let mut hidden_tools = self.hidden_tools.clone();
hidden_tools.insert(RuntimeIntrinsicTool::Task.to_string());
let mut config = self.config.clone();
config.system = Some(build_subagent_system_prompt(
self.config.system.as_deref().map(Cow::Borrowed),
));
Agent::new(
self.runtime.clone(),
self.model.clone(),
format!("{}::task", self.parent_name),
config,
Arc::clone(&self.provider),
AgentSpawnOptions {
hidden_tools,
max_rounds: Some(SUBAGENT_MAX_ROUNDS),
teammate_identity: self.teammate_identity.clone(),
},
)
}
}
impl Agent {
pub(crate) fn spawn_subagent(&self) -> Result<Self, RuntimeError> {
self.disposable_subagent_template().spawn()
}
pub(crate) fn disposable_subagent_template(&self) -> DisposableSubagentTemplate {
DisposableSubagentTemplate::from_agent(self)
}
pub(crate) fn register_subagent(&mut self, agent: &Agent) -> SpawnedAgentSummary {
let summary = SpawnedAgentSummary {
id: agent.id.clone(),
name: agent.name.clone(),
model: agent.model.clone(),
status: SpawnedAgentStatus::Running,
};
let summary_for_snapshot = summary.clone();
self.mutate_snapshot(|snapshot| {
snapshot.subagents.push(summary_for_snapshot);
});
summary
}
pub(crate) fn finish_subagent(
&mut self,
id: &str,
status: SpawnedAgentStatus,
) -> Option<SpawnedAgentSummary> {
let mut finished = None;
self.mutate_snapshot(|snapshot| {
if let Some(summary) = snapshot.subagents.iter_mut().find(|agent| agent.id == id) {
summary.status = status;
finished = Some(summary.clone());
}
});
finished
}
pub(crate) fn final_text_summary(&self) -> String {
let Some(message) = self.last_message() else {
return "(no summary)".to_string();
};
if message.role != Role::Assistant {
return "(no summary)".to_string();
}
let text = message.text();
if text.is_empty() {
"(no summary)".to_string()
} else {
text
}
}
}
pub(super) fn build_subagent_system_prompt(base: Option<Cow<'_, str>>) -> String {
match base {
Some(system) => format!("{system}\n\n{SUBAGENT_SYSTEM_PROMPT}"),
None => SUBAGENT_SYSTEM_PROMPT.to_string(),
}
}

143
vendor/mentra/src/agent/task_state.rs vendored Normal file
View File

@@ -0,0 +1,143 @@
use std::borrow::Cow;
use crate::error::RuntimeError;
use crate::runtime::{
TaskBoard, TaskStateSnapshot,
task::{TASK_REMINDER_TEXT, TaskAccess, TaskIntrinsicTool, has_unfinished_tasks},
};
use super::Agent;
impl Agent {
/// Returns a task-board view with this agent's own access identity.
///
/// Lead agents retain lead privileges. Teammates remain constrained to
/// their own tasks and cannot edit dependency edges. Task-board mutations
/// update the shared store immediately; this agent's cached snapshot is
/// refreshed at the next normal task refresh/run boundary.
pub fn task_board(&self) -> TaskBoard {
TaskBoard::agent(
self.runtime.clone(),
self.config.task.tasks_dir.clone(),
self.name.clone(),
self.teammate_identity.is_some(),
)
}
pub(crate) fn effective_system_prompt(&self) -> Option<Cow<'_, str>> {
let mut sections = Vec::new();
if self.rounds_since_task >= self.config.task.reminder_threshold
&& has_unfinished_tasks(&self.tasks)
{
sections.push(TASK_REMINDER_TEXT.to_string());
}
if let Some(system) = &self.config.system {
sections.push(system.clone());
}
if let Some(skills) = self.runtime.skill_descriptions() {
sections.push(skills);
}
if sections.is_empty() {
None
} else {
Some(Cow::Owned(sections.join("\n\n")))
}
}
pub(crate) fn note_round_without_task(&mut self) {
if has_unfinished_tasks(&self.tasks) {
self.rounds_since_task += 1;
}
}
pub(crate) fn record_task_activity(&mut self) {
self.rounds_since_task = 0;
}
pub(crate) fn refresh_tasks_from_disk(&mut self) -> Result<(), RuntimeError> {
let tasks = self
.runtime
.store()
.load_tasks(self.config.task.tasks_dir.as_path())?;
self.tasks = tasks;
let tasks = self.tasks.clone();
self.mutate_snapshot(|snapshot| {
snapshot.tasks = tasks;
});
Ok(())
}
pub(crate) fn task_access(&self) -> TaskAccess<'_> {
match &self.teammate_identity {
Some(_) => TaskAccess::Teammate(self.name.as_str()),
None => TaskAccess::Lead,
}
}
pub(crate) fn try_claim_ready_task(
&mut self,
) -> Result<Option<crate::runtime::TaskItem>, RuntimeError> {
self.refresh_tasks_from_disk()?;
if self.owns_unfinished_tasks() {
return Ok(None);
}
match self.execute_task_mutation(&TaskIntrinsicTool::Claim, serde_json::json!({})) {
Ok(content) => {
self.refresh_tasks_from_disk()?;
serde_json::from_str::<crate::runtime::TaskItem>(&content)
.map(Some)
.map_err(RuntimeError::FailedToSerializeTasks)
}
Err(error) if error == "No ready unowned tasks are available to claim" => Ok(None),
Err(error) => Err(RuntimeError::InvalidTask(error)),
}
}
pub(crate) fn execute_task_mutation(
&self,
tool: &TaskIntrinsicTool,
input: serde_json::Value,
) -> Result<String, String> {
self.runtime.execute_task_mutation(
tool,
input,
self.config.task.tasks_dir.as_path(),
self.task_access(),
)
}
pub(super) fn capture_task_disk_state(&self) -> Result<TaskStateSnapshot, RuntimeError> {
self.runtime
.store()
.capture_tasks(self.config.task.tasks_dir.as_path())
}
fn owns_unfinished_tasks(&self) -> bool {
self.tasks.iter().any(|task| {
task.owner == self.name && !matches!(task.status, crate::runtime::TaskStatus::Completed)
})
}
pub(super) fn restore_task_state(
&mut self,
tasks: Vec<crate::runtime::TaskItem>,
rounds_since_task: usize,
disk_state: &TaskStateSnapshot,
) -> Result<(), RuntimeError> {
self.runtime
.store()
.restore_tasks(self.config.task.tasks_dir.as_path(), disk_state)?;
self.tasks = tasks;
self.rounds_since_task = rounds_since_task;
let tasks = self.tasks.clone();
self.mutate_snapshot(|snapshot| {
snapshot.tasks = tasks;
});
Ok(())
}
}

197
vendor/mentra/src/agent/team.rs vendored Normal file
View File

@@ -0,0 +1,197 @@
use std::{borrow::Cow, sync::Arc};
use serde::Deserialize;
use serde_json::Value;
use tokio::sync::Mutex as AsyncMutex;
use crate::error::RuntimeError;
use crate::runtime::task::TaskIntrinsicTool;
use crate::team::{
TEAMMATE_MAX_ROUNDS, TeamDispatch, TeamIntrinsicTool, TeamMemberStatus, TeamMemberSummary,
TeamMessage, TeamProtocolRequestSummary, build_teammate_system_prompt,
};
use super::{Agent, AgentSpawnOptions, TeammateIdentity};
impl Agent {
pub async fn spawn_teammate(
&mut self,
name: impl Into<String>,
role: impl Into<String>,
prompt: Option<String>,
) -> Result<TeamMemberSummary, RuntimeError> {
let name = name.into();
let role = role.into();
if name.trim().is_empty() {
return Err(RuntimeError::InvalidTeam(
"Teammate name must not be empty".to_string(),
));
}
if role.trim().is_empty() {
return Err(RuntimeError::InvalidTeam(
"Teammate role must not be empty".to_string(),
));
}
if name == self.name {
return Err(RuntimeError::InvalidTeam(
"Teammate name must differ from the current agent".to_string(),
));
}
let mut hidden_tools = self.hidden_tools.clone();
hidden_tools.extend(teammate_hidden_tools());
let mut config = self.config.clone();
config.system = Some(build_teammate_system_prompt(
self.config.system.as_deref().map(Cow::Borrowed),
&name,
&role,
&self.name,
));
let teammate = Self::new(
self.runtime.clone(),
self.model.clone(),
name.clone(),
config,
Arc::clone(&self.provider),
AgentSpawnOptions {
hidden_tools,
max_rounds: Some(TEAMMATE_MAX_ROUNDS),
teammate_identity: Some(TeammateIdentity {
role: role.clone(),
lead: self.name.clone(),
}),
},
)?;
let summary = TeamMemberSummary {
id: teammate.id().to_string(),
name: name.clone(),
role,
model: teammate.model().to_string(),
status: TeamMemberStatus::Idle,
};
let team_dir = self.config.team.team_dir.clone();
let actor = Arc::new(AsyncMutex::new(teammate));
let actor_handle = self.runtime.spawn_teammate_actor(&team_dir, &name, actor)?;
let summary = self
.runtime
.register_teammate(&team_dir, summary, actor_handle)?;
if let Some(prompt) = prompt.filter(|prompt| !prompt.trim().is_empty()) {
self.send_team_message(&name, prompt)?;
}
Ok(summary)
}
pub(crate) fn revive_teammate_actor(self) -> Result<(), RuntimeError> {
let Some(identity) = self.teammate_identity.clone() else {
return Err(RuntimeError::InvalidTeam(
"Only teammate agents can be revived as teammate actors".to_string(),
));
};
let summary = TeamMemberSummary {
id: self.id().to_string(),
name: self.name.clone(),
role: identity.role,
model: self.model.clone(),
status: TeamMemberStatus::Idle,
};
let runtime = self.runtime.clone();
let team_dir = self.config.team.team_dir.clone();
let actor = Arc::new(AsyncMutex::new(self));
let actor_handle = runtime.spawn_teammate_actor(&team_dir, &summary.name, actor)?;
runtime.register_teammate(&team_dir, summary.clone(), actor_handle)?;
runtime.wake_teammate(&team_dir, &summary.name)?;
Ok(())
}
pub fn send_team_message(
&self,
to: &str,
content: impl Into<String>,
) -> Result<TeamDispatch, RuntimeError> {
self.runtime.send_team_message(
self.config.team.team_dir.as_path(),
&self.name,
to,
content.into(),
)
}
pub fn broadcast_team_message(
&self,
content: impl Into<String>,
) -> Result<Vec<TeamDispatch>, RuntimeError> {
self.runtime.broadcast_team_message(
self.config.team.team_dir.as_path(),
&self.name,
content.into(),
)
}
pub fn read_team_inbox(&self) -> Result<Vec<TeamMessage>, RuntimeError> {
self.runtime
.read_team_inbox(self.config.team.team_dir.as_path(), &self.name)
}
pub fn request_team_protocol(
&self,
to: &str,
protocol: impl Into<String>,
content: impl Into<String>,
) -> Result<TeamProtocolRequestSummary, RuntimeError> {
self.runtime.create_team_request(
self.config.team.team_dir.as_path(),
&self.name,
to,
protocol.into(),
content.into(),
)
}
pub fn respond_team_protocol(
&self,
request_id: &str,
approve: bool,
reason: Option<String>,
) -> Result<TeamProtocolRequestSummary, RuntimeError> {
self.runtime.resolve_team_request(
self.config.team.team_dir.as_path(),
&self.name,
request_id,
approve,
reason,
)
}
}
#[derive(Debug, Deserialize)]
struct TaskInput {
prompt: String,
}
fn teammate_hidden_tools() -> [String; 3] {
[
TeamIntrinsicTool::Spawn.to_string(),
TeamIntrinsicTool::Broadcast.to_string(),
TaskIntrinsicTool::Create.to_string(),
]
}
pub(crate) fn parse_task_input(input: Value) -> Result<String, String> {
let parsed = serde_json::from_value::<TaskInput>(input)
.map_err(|error| format!("Invalid task input: {error}"))?;
if parsed.prompt.trim().is_empty() {
return Err("Task prompt must not be empty".to_string());
}
Ok(parsed.prompt)
}

View File

@@ -0,0 +1,319 @@
use std::{
collections::HashSet,
sync::{
Arc, Mutex,
atomic::{AtomicU64, Ordering},
},
time::{SystemTime, UNIX_EPOCH},
};
use async_trait::async_trait;
use serde::de::DeserializeOwned;
use serde_json::Value;
use crate::{
ContentBlock, Message, Role,
error::RuntimeError,
runtime::{RunOptions, RuntimeHandle},
tool::{
ToolContext, ToolDefinition, ToolDurability, ToolExecutor, ToolOutput, ToolSideEffectLevel,
ToolSpec,
},
};
use super::Agent;
static NEXT_TERMINAL_TOOL_ID: AtomicU64 = AtomicU64::new(1);
/// Provider-facing definition of a typed terminal tool.
#[derive(Debug, Clone)]
pub struct TerminalOutputSpec {
pub tool_name: String,
pub description: String,
pub schema: Value,
/// Whether the run keeps its ordinary tools while it answers.
///
/// `false` — what [`new`](Self::new) gives you — is a *shaping* turn: the
/// generated terminal tool is the only tool the run holds, so it can only
/// put a shape on what the conversation already contains. `true` — see
/// [`with_tools`](Self::with_tools) — is a *working* turn: the run keeps
/// the agent's whole toolset and ends by calling the terminal tool.
/// [`Agent::run_to_output`] describes what each costs.
pub keeps_tools: bool,
}
impl TerminalOutputSpec {
pub fn new(
tool_name: impl Into<String>,
description: impl Into<String>,
schema: Value,
) -> Self {
Self {
tool_name: tool_name.into(),
description: description.into(),
schema,
keeps_tools: false,
}
}
/// Lets the run work before it answers, instead of only shaping what it
/// already has.
///
/// A shaping turn cannot read a file, run a command, or reach an MCP
/// server, so asking one for anything it has not already been told
/// produces a well-formed answer from a model that looked at nothing —
/// and reports it as a success. The way out has been to spend two turns
/// on every read-then-answer workflow: one to gather, one to shape. This
/// spends one. The run holds its ordinary tools alongside the terminal
/// tool, works as many rounds as it needs, and ends the turn by calling
/// the terminal tool with the answer.
///
/// The cost is that nothing forces the ending: see
/// [`Agent::run_to_output`] for what a run that never calls the tool
/// returns instead.
pub fn with_tools(mut self) -> Self {
self.keeps_tools = true;
self
}
}
/// What an in-flight [`Agent::run_to_output`] tells the rest of the agent
/// about the turn it is running: which generated tool ends it, and whether
/// the ordinary toolset is on the request beside that tool.
///
/// Read on every round by [`Agent::tools`] and [`Agent::tool_choice`], which
/// is why it holds the mode rather than the name alone — the two answers have
/// to agree about which turn this is, and a name cannot say.
#[derive(Debug, Clone)]
pub(super) struct TerminalToolGate {
pub(super) tool_name: String,
pub(super) keeps_tools: bool,
}
/// Typed value and committed tool-result message produced by [`Agent::run_to_output`].
#[derive(Debug, Clone)]
pub struct FinalOutput<T> {
pub value: T,
pub message: Message,
}
impl Agent {
/// Runs until a generated, agent-scoped terminal tool returns a typed value.
///
/// The helper does not use provider-level `response_format`. It registers
/// one tool whose input schema *is* the requested shape, preserves the
/// tool input as transcript `details`, and extracts it by the exact
/// `tool_use_id` from the newly committed final transcript item.
///
/// What the run may do on its way to that call is
/// [`TerminalOutputSpec::keeps_tools`]:
///
/// - **Shaping**, the default. The terminal tool is the only tool on the
/// request and the provider is told to call it. The turn cannot read a
/// file, run a command, or reach an MCP server, so the only thing left
/// to decide is the shape of what the conversation already holds, and
/// one round decides it.
/// - **Working**, [`TerminalOutputSpec::with_tools`]. The agent's ordinary
/// toolset is on the request beside the terminal tool and no choice is
/// forced — forcing one would preclude the very rounds that are the
/// point. The run gathers for as many rounds as it needs and ends the
/// turn by calling the terminal tool.
///
/// Either way the terminal call ends the round it appears in: calls
/// scheduled after it in that same round are never executed, and each is
/// given an explicit `is_error` result saying so. Where the model emits
/// two terminal calls in one round, the first is the answer and the second
/// is one of those skipped calls.
///
/// Only that call produces a value. A working run that ends any other way
/// — on prose, or at the round boundary where [`RunOptions::stop`] or
/// [`RunOptions::token_budget`] refuses another round — has nothing to
/// return and fails with `MalformedProviderEvent("run completed without
/// invoking the expected terminal tool")`, while keeping everything it
/// gathered in the transcript. [`RunOptions::ended_early`] says which
/// bound, when one was the reason.
///
/// A run that ends on a terminal call ends on a user-role tool result, so
/// `Agent::run` reports [`RuntimeError::EmptyAssistantResponse`] for the
/// missing assistant message. That is bookkeeping about the wrong
/// question here, and this helper answers the right one instead: with the
/// expected new detail present the run succeeded, and without it the run
/// is reported as the missing terminal call it was.
pub async fn run_to_output<T: DeserializeOwned>(
&mut self,
content: impl Into<Vec<ContentBlock>>,
options: RunOptions,
spec: TerminalOutputSpec,
) -> Result<FinalOutput<T>, RuntimeError> {
let tool_name = unique_tool_name(&spec.tool_name);
let keeps_tools = spec.keeps_tools;
let terminal_tool = TerminalOutputTool {
name: tool_name.clone(),
description: spec.description,
schema: spec.schema,
agent_id: self.id.clone(),
};
self.runtime.register_scoped_tool(&self.id, terminal_tool);
*self
.terminal_tool_gate
.lock()
.expect("terminal tool gate poisoned") = Some(TerminalToolGate {
tool_name: tool_name.clone(),
keeps_tools,
});
let _guard = TerminalToolGuard {
runtime: self.runtime.clone(),
agent_id: self.id.clone(),
tool_name: tool_name.clone(),
gate: Arc::clone(&self.terminal_tool_gate),
};
let run_result = self.run(content, options).await;
let terminal_result = self.terminal_result(&tool_name);
match (run_result, terminal_result) {
(Ok(_), Some((details, message)))
| (Err(RuntimeError::EmptyAssistantResponse), Some((details, message))) => {
let value = serde_json::from_value(details).map_err(|error| {
RuntimeError::MalformedProviderEvent(format!(
"terminal output did not match the requested type: {error}"
))
})?;
Ok(FinalOutput { value, message })
}
(Ok(_) | Err(RuntimeError::EmptyAssistantResponse), None) => {
Err(RuntimeError::MalformedProviderEvent(
"run completed without invoking the expected terminal tool".to_string(),
))
}
(Err(error), _) => Err(error),
}
}
fn terminal_result(&self, tool_name: &str) -> Option<(Value, Message)> {
// Generated names include a per-call timestamp and counter, so scanning
// the whole transcript remains stale-safe even if auto-compaction
// replaced earlier items and changed every numeric index during the run.
let items = self.transcript().items();
let expected_ids = items
.iter()
.filter_map(|item| item.message.as_ref())
.filter(|message| message.role == Role::Assistant)
.flat_map(|message| message.content.iter())
.filter_map(|block| match block {
ContentBlock::ToolUse { id, name, .. } if name == tool_name => Some(id.clone()),
_ => None,
})
.collect::<HashSet<_>>();
let last = items.last()?;
let message = last.message.clone()?;
let result_ids = message.content.iter().filter_map(|block| match block {
ContentBlock::ToolResult { tool_use_id, .. } => Some(tool_use_id),
_ => None,
});
for tool_use_id in result_ids {
if expected_ids.contains(tool_use_id)
&& let Some(details) = last.detail(tool_use_id)
{
return Some((details.clone(), message));
}
}
None
}
}
struct TerminalOutputTool {
name: String,
description: String,
schema: Value,
agent_id: String,
}
impl ToolDefinition for TerminalOutputTool {
fn descriptor(&self) -> ToolSpec {
ToolSpec::builder(self.name.clone())
.description(self.description.clone())
.input_schema(self.schema.clone())
.side_effect_level(ToolSideEffectLevel::None)
.durability(ToolDurability::ReplaySafe)
.terminal()
.build()
}
}
#[async_trait]
impl ToolExecutor for TerminalOutputTool {
async fn execute_mut_output(
&self,
ctx: ToolContext<'_>,
input: Value,
) -> Result<ToolOutput, String> {
if ctx.agent_id != self.agent_id {
return Err("terminal tool belongs to a different agent".to_string());
}
Ok(ToolOutput::structured(input.clone())
.with_details(input)
.terminating())
}
}
struct TerminalToolGuard {
runtime: RuntimeHandle,
agent_id: String,
tool_name: String,
gate: Arc<Mutex<Option<TerminalToolGate>>>,
}
impl Drop for TerminalToolGuard {
fn drop(&mut self) {
let mut gate = self.gate.lock().expect("terminal tool gate poisoned");
if gate
.as_ref()
.is_some_and(|open| open.tool_name == self.tool_name)
{
*gate = None;
}
drop(gate);
self.runtime
.unregister_scoped_tool(&self.agent_id, &self.tool_name);
}
}
fn unique_tool_name(base: &str) -> String {
let mut base = base
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || character == '_' {
character
} else {
'_'
}
})
.take(14)
.collect::<String>();
if base.is_empty() {
base = "output".to_string();
}
let id = NEXT_TERMINAL_TOOL_ID.fetch_add(1, Ordering::Relaxed);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
format!("mentra_terminal_{base}_{timestamp:016x}_{id:016x}")
}
#[cfg(test)]
mod tests {
use super::unique_tool_name;
#[test]
fn generated_tool_names_fit_common_provider_limits() {
let name = unique_tool_name("a name with punctuation and far too many characters");
assert!(name.len() <= 64);
assert!(
name.chars()
.all(|character| character.is_ascii_alphanumeric() || character == '_')
);
}
}

16
vendor/mentra/src/agent/tests.rs vendored Normal file
View File

@@ -0,0 +1,16 @@
mod budgets;
mod pending;
mod round_strategy;
mod runtime;
mod runtime_compact;
mod runtime_memory;
mod runtime_resume;
mod runtime_snapshot;
mod runtime_tasks;
mod runtime_tools;
mod runtime_volatile_store;
mod steering;
mod support;
mod terminal_output;
mod tool_output;
mod tool_paging;

776
vendor/mentra/src/agent/tests/budgets.rs vendored Normal file
View File

@@ -0,0 +1,776 @@
use crate::{
BuiltinProvider, ContentBlock, Role, Runtime, TokenUsage,
agent::AgentEvent,
error::RuntimeError,
provider::{ContentBlockDelta, ContentBlockStart, ProviderEvent},
runtime::{CancellationToken, EarlyEnd, RunOptions},
};
use super::support::{
ScriptedProvider, StaticTool, StopTrippingTool, StreamScript, model_info, ok_stream,
};
/// Builds a [`TokenUsage`] reporting only `input_tokens`/`output_tokens`, the two
/// fields [`RunOptions::token_budget`] is evaluated against.
fn usage(input_tokens: u64, output_tokens: u64) -> TokenUsage {
TokenUsage {
input_tokens: Some(input_tokens),
output_tokens: Some(output_tokens),
..Default::default()
}
}
/// Like `support::tool_use_stream`, but also reports `usage` via `MessageDelta`
/// so a round-boundary [`RunOptions::token_budget`] check has something to
/// evaluate.
fn tool_use_stream_with_usage(
model: &str,
id: &str,
name: &str,
input_json: &str,
usage: TokenUsage,
) -> StreamScript {
ok_stream(vec![
ProviderEvent::MessageStarted {
id: format!("msg-{id}"),
model: model.to_string(),
role: Role::Assistant,
},
ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::ToolUse {
id: id.to_string(),
name: name.to_string(),
},
},
ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::ToolUseInputJson(input_json.to_string()),
},
ProviderEvent::ContentBlockStopped { index: 0 },
ProviderEvent::MessageDelta {
stop_reason: None,
usage: Some(usage),
},
ProviderEvent::MessageStopped,
])
}
/// Like `support::text_stream`, but also reports `usage` via `MessageDelta`.
fn text_stream_with_usage(model: &str, text: &str, usage: TokenUsage) -> StreamScript {
ok_stream(vec![
ProviderEvent::MessageStarted {
id: format!("msg-{text}"),
model: model.to_string(),
role: Role::Assistant,
},
ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::Text,
},
ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::Text(text.to_string()),
},
ProviderEvent::ContentBlockStopped { index: 0 },
ProviderEvent::MessageDelta {
stop_reason: None,
usage: Some(usage),
},
ProviderEvent::MessageStopped,
])
}
#[tokio::test]
async fn token_budget_stops_gracefully_after_the_round_that_crosses_it() {
// Round 1 reports usage that reaches the budget exactly; round 2 (a text
// response) must never be requested. Because the run stops before a final
// assistant message, `Agent::run` reports `EmptyAssistantResponse` — the same
// honest "stopped before a final answer" outcome `RunOptions::stop` produces
// at the identical boundary — while the gathered tool round stays committed
// rather than rolled back.
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream_with_usage(
&model.id,
"call-1",
"probe_tool",
r#"{"value":"hi"}"#,
usage(60, 40),
),
// Must never be requested: the budget trips before round 2 starts.
text_stream_with_usage(&model.id, "must not run", usage(1, 1)),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("probe_tool", "ok"))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let result = agent
.run(
vec![ContentBlock::text("go")],
RunOptions {
token_budget: Some(100),
..Default::default()
},
)
.await;
assert!(matches!(result, Err(RuntimeError::EmptyAssistantResponse)));
assert_eq!(
agent.history().len(),
3,
"the round that crossed the budget stays committed, not rolled back"
);
assert_eq!(
provider_handle.recorded_requests().await.len(),
1,
"the budget halted the run before a second model request"
);
}
#[tokio::test]
async fn absent_token_budget_ignores_reported_usage() {
// With `token_budget: None` (the default), no amount of reported usage stops
// the run early — the seam is inert, reproducing today's behavior exactly.
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream_with_usage(
&model.id,
"call-1",
"probe_tool",
r#"{"value":"hi"}"#,
usage(10_000, 10_000),
),
text_stream_with_usage(&model.id, "done", usage(10_000, 10_000)),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("probe_tool", "ok"))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let message = agent
.run(vec![ContentBlock::text("go")], RunOptions::default())
.await
.expect("run completes normally despite large reported usage");
assert_eq!(message.text(), "done");
assert_eq!(provider_handle.recorded_requests().await.len(), 2);
assert_eq!(agent.history().len(), 4);
}
#[tokio::test]
async fn a_crossed_budget_reports_why_the_turn_ended() {
// The defect this signal exists for: the run above ends correctly — the
// round that crossed the bound stays committed, nothing is rolled back —
// and says nothing about *why* it ended, since a run the model finished
// returns exactly the same way. `ended_early` is the runner's own answer,
// recorded at the boundary it refused to start another round at, and read
// back through a clone of the options the run was given.
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream_with_usage(
&model.id,
"call-1",
"probe_tool",
r#"{"value":"hi"}"#,
usage(60, 40),
),
text_stream_with_usage(&model.id, "must not run", usage(1, 1)),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("probe_tool", "ok"))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let options = RunOptions {
token_budget: Some(100),
..Default::default()
};
let result = agent
.run(vec![ContentBlock::text("go")], options.clone())
.await;
assert_eq!(
options.ended_early(),
Some(EarlyEnd::TokenBudget),
"the run must report the bound that ended it, not leave it to be inferred"
);
// The behavior around the report is unchanged, and pinned here beside it so
// that adding the signal cannot quietly turn a graceful end into a rollback.
assert!(matches!(result, Err(RuntimeError::EmptyAssistantResponse)));
assert_eq!(
agent.history().len(),
3,
"the round that crossed the budget stays committed, not rolled back"
);
assert_eq!(provider_handle.recorded_requests().await.len(), 1);
}
#[tokio::test]
async fn a_requested_stop_is_not_reported_as_a_crossed_budget() {
// Both graceful bounds end a turn at the same boundary in the same way, so
// the signal is only worth anything if it tells them apart. The budget here
// is set and nowhere near crossed: what ends the turn is the tool tripping
// the stop token, and that is what must be reported.
let model = model_info("model", BuiltinProvider::Anthropic);
let stop = CancellationToken::default();
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream_with_usage(
&model.id,
"call-1",
"stop_probe",
r#"{"value":"enough"}"#,
usage(10, 10),
),
text_stream_with_usage(&model.id, "must not run", usage(1, 1)),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StopTrippingTool::new("stop_probe", stop.clone()))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let options = RunOptions {
stop: Some(stop),
token_budget: Some(10_000),
..Default::default()
};
let result = agent
.run(vec![ContentBlock::text("go")], options.clone())
.await;
assert_eq!(options.ended_early(), Some(EarlyEnd::StopRequested));
assert!(matches!(result, Err(RuntimeError::EmptyAssistantResponse)));
assert_eq!(
options.reported_tokens(),
20,
"the bound was never near: a caller recomputing it would have found nothing"
);
assert_eq!(provider_handle.recorded_requests().await.len(), 1);
}
#[tokio::test]
async fn a_stop_and_a_crossed_budget_together_report_the_stop() {
// The one round both spends the whole bound and trips the stop, so both
// conditions hold at the boundary that ends the turn. The stop wins: it is
// an instruction the caller issued, and the turn would have ended there
// with no budget set at all. Reporting the budget would tell a caller its
// allowance ran out when what happened is that it asked to stop.
let model = model_info("model", BuiltinProvider::Anthropic);
let stop = CancellationToken::default();
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream_with_usage(
&model.id,
"call-1",
"stop_probe",
r#"{"value":"enough"}"#,
usage(60, 60),
),
text_stream_with_usage(&model.id, "must not run", usage(1, 1)),
],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StopTrippingTool::new("stop_probe", stop.clone()))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let options = RunOptions {
stop: Some(stop),
token_budget: Some(100),
..Default::default()
};
let _ = agent
.run(vec![ContentBlock::text("go")], options.clone())
.await;
assert!(
options.reported_tokens() >= 100,
"the budget really is crossed, so the precedence is what decides the report"
);
assert_eq!(options.ended_early(), Some(EarlyEnd::StopRequested));
}
#[tokio::test]
async fn a_turn_that_runs_to_completion_reports_nothing() {
// The default that keeps the signal honest: a bound that was set but never
// reached leaves nothing behind, so `Some(..)` always means the runner
// ended the turn rather than the model finishing it.
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![text_stream_with_usage(&model.id, "done", usage(10, 10))],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let options = RunOptions {
stop: Some(CancellationToken::default()),
token_budget: Some(10_000),
..Default::default()
};
let message = agent
.run(vec![ContentBlock::text("go")], options.clone())
.await
.expect("the run completes under both bounds");
assert_eq!(message.text(), "done");
assert_eq!(options.ended_early(), None);
}
#[tokio::test]
async fn a_turn_that_ends_on_the_budget_and_still_answers_reports_why() {
// The case that makes the signal load-bearing rather than convenient. The
// model finished its message *and* a steer was queued behind it, so the
// runner checks whether another request is available, finds the bound
// crossed, and returns the message it has. The turn is an ordinary `Ok`
// carrying an ordinary final answer — indistinguishable from a turn that
// ran to completion except through what the runner recorded, and the still
// pending steer is the work that got left behind.
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
text_stream_with_usage(&model.id, "answered first", usage(60, 40)),
text_stream_with_usage(&model.id, "must not run", usage(1, 1)),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let steering = agent.steering_handle();
steering.steer(vec![ContentBlock::text("and then this")]);
let options = RunOptions {
token_budget: Some(100),
..Default::default()
};
let message = agent
.run(vec![ContentBlock::text("go")], options.clone())
.await
.expect("a turn that ends on the budget after a committed message succeeds");
assert_eq!(message.text(), "answered first");
assert_eq!(
options.ended_early(),
Some(EarlyEnd::TokenBudget),
"a successful turn is exactly where an unreported bound is invisible"
);
assert!(
steering.has_pending(),
"the steer no request could carry is kept, not consumed"
);
assert_eq!(provider_handle.recorded_requests().await.len(), 1);
}
#[tokio::test]
async fn child_run_shares_cancellation_with_parent() {
// `RunOptions::child` carries the parent's `cancellation` token forward, so a
// parent cancel stops a child run threaded with the derived options — even
// though the two runs are on different agents and never call into each other.
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![text_stream_with_usage(
&model.id,
"should not complete",
usage(1, 1),
)],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut child_agent = runtime.spawn("child", model).expect("spawn child agent");
let cancellation = CancellationToken::default();
let parent_options = RunOptions {
cancellation: Some(cancellation.clone()),
..Default::default()
};
let child_options = parent_options.child();
cancellation.cancel();
let error = child_agent
.run(vec![ContentBlock::text("go")], child_options)
.await
.expect_err("a cancelled parent token must stop the derived child run");
assert!(matches!(error, RuntimeError::Cancelled));
}
#[tokio::test]
async fn child_usage_counts_toward_shared_token_budget() {
// Parent and child share one token-accounting handle via `RunOptions::child`:
// neither run's own usage alone crosses the budget, but their combined total
// does, so the child's run stops gracefully at the shared bound.
let model = model_info("model", BuiltinProvider::Anthropic);
let parent_provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![text_stream_with_usage(
&model.id,
"parent done",
usage(40, 20),
)],
);
let parent_runtime = Runtime::empty_builder()
.with_provider_instance(parent_provider)
.build()
.expect("build runtime");
let mut parent_agent = parent_runtime
.spawn("parent", model.clone())
.expect("spawn parent");
let parent_options = RunOptions {
token_budget: Some(100),
..Default::default()
};
parent_agent
.run(vec![ContentBlock::text("go")], parent_options.clone())
.await
.expect("parent run completes under budget");
assert_eq!(
parent_options.reported_tokens(),
60,
"parent alone stays under the shared bound"
);
let child_options = parent_options.child();
assert_eq!(
child_options.reported_tokens(),
60,
"the derived child starts from the parent's already-reported usage"
);
let child_provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
// parent(60) + child(50) = 110, crossing the shared bound of 100.
tool_use_stream_with_usage(
&model.id,
"call-1",
"probe_tool",
r#"{"value":"hi"}"#,
usage(30, 20),
),
text_stream_with_usage(&model.id, "must not run", usage(1, 1)),
],
);
let child_provider_handle = child_provider.clone();
let child_runtime = Runtime::empty_builder()
.with_provider_instance(child_provider)
.with_tool(StaticTool::success("probe_tool", "ok"))
.build()
.expect("build runtime");
let mut child_agent = child_runtime.spawn("child", model).expect("spawn child");
let result = child_agent
.run(vec![ContentBlock::text("go")], child_options)
.await;
assert!(
matches!(result, Err(RuntimeError::EmptyAssistantResponse)),
"the child stops gracefully once the combined parent+child usage crosses the bound"
);
assert_eq!(
child_provider_handle.recorded_requests().await.len(),
1,
"the shared bound halted the child before its second round"
);
}
/// Drains the events an agent emitted during a finished run.
fn collect_events(receiver: &mut tokio::sync::broadcast::Receiver<AgentEvent>) -> Vec<AgentEvent> {
let mut events = Vec::new();
while let Ok(event) = receiver.try_recv() {
events.push(event);
}
events
}
/// The `input + output` totals of every [`AgentEvent::UsageReport`] in `events`,
/// in the order they were emitted.
fn reported_usage_totals(events: &[AgentEvent]) -> Vec<u64> {
events
.iter()
.filter_map(|event| match event {
AgentEvent::UsageReport {
input_tokens,
output_tokens,
..
} => Some(input_tokens + output_tokens),
_ => None,
})
.collect()
}
#[tokio::test]
async fn delegated_subagent_usage_counts_against_the_parent_token_budget() {
// The `task` intrinsic is the one child run mentra drives itself: the model
// asks for it from inside the parent's run. Running it on the parent's
// `RunOptions::child` is what stops a model from delegating its way past the
// budget its own run was given — the child reports into the parent's
// accounting handle, and the parent ends at the next round boundary once the
// combined total crosses the bound.
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
// Parent round 1 delegates, reporting 60 of the 100-token bound.
tool_use_stream_with_usage(
&model.id,
"parent-task",
"task",
r#"{"prompt":"delegate"}"#,
usage(40, 20),
),
// The delegated run spends 50 more, taking the shared total to 110.
text_stream_with_usage(&model.id, "child summary", usage(30, 20)),
// Parent round 2 must never be requested.
text_stream_with_usage(&model.id, "parent done", usage(1, 1)),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let options = RunOptions {
token_budget: Some(100),
..Default::default()
};
let result = agent
.run(vec![ContentBlock::text("delegate that")], options.clone())
.await;
assert_eq!(
options.reported_tokens(),
110,
"the delegated run must report into the parent's accounting handle, not a fresh one"
);
assert!(
matches!(result, Err(RuntimeError::EmptyAssistantResponse)),
"the parent stops gracefully at the boundary after delegated spend crossed the bound, \
reporting the same 'stopped before a final answer' outcome any tripped bound does"
);
assert_eq!(
provider_handle.recorded_requests().await.len(),
2,
"one parent round and one delegated round: the parent never got a second round"
);
}
#[tokio::test]
async fn parent_cancellation_reaches_the_delegated_subagent() {
// The delegated run shares the parent's cancellation token, so it ends at
// its own next round boundary rather than running on unreachable while the
// parent is torn down. `cancel_probe` trips the very token the parent's
// options hold, so the child honoring it can only mean it inherited that
// token rather than a default `None`.
let model = model_info("model", BuiltinProvider::Anthropic);
let cancellation = CancellationToken::default();
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream_with_usage(
&model.id,
"parent-task",
"task",
r#"{"prompt":"delegate"}"#,
usage(1, 1),
),
tool_use_stream_with_usage(
&model.id,
"child-tool",
"cancel_probe",
r#"{"value":"trip it"}"#,
usage(1, 1),
),
// Never requested: the child checks the shared token before its
// second round.
text_stream_with_usage(&model.id, "child must not continue", usage(1, 1)),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::builder()
.with_provider_instance(provider)
.with_tool(StopTrippingTool::new("cancel_probe", cancellation.clone()))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let error = agent
.run(
vec![ContentBlock::text("delegate that")],
RunOptions {
cancellation: Some(cancellation),
..Default::default()
},
)
.await
.expect_err("a cancelled run must fail rather than finish");
assert!(matches!(error, RuntimeError::Cancelled));
assert_eq!(
provider_handle.recorded_requests().await.len(),
2,
"the child stopped at its own round boundary; without the shared token it would \
have run a second round before the parent ever saw the cancellation"
);
}
#[tokio::test]
async fn delegated_usage_reports_reach_the_parent_event_stream() {
// The accounting fix alone would leave a parent's observer blind to
// delegated spend, since a subagent has its own event bus. Relaying the
// child's `UsageReport` keeps a stream that sums usage agreeing with the
// shared handle the budget is checked against.
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream_with_usage(
&model.id,
"parent-task",
"task",
r#"{"prompt":"delegate"}"#,
usage(40, 20),
),
text_stream_with_usage(&model.id, "child summary", usage(30, 20)),
text_stream_with_usage(&model.id, "parent done", usage(5, 5)),
],
);
let runtime = Runtime::builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let mut events = agent.subscribe_events();
let options = RunOptions::default();
let message = agent
.run(vec![ContentBlock::text("delegate that")], options.clone())
.await
.expect("the run completes with no bound set");
assert_eq!(message.text(), "parent done");
let totals = reported_usage_totals(&collect_events(&mut events));
assert_eq!(
totals,
vec![60, 50, 10],
"the parent's stream carries the delegated round's usage between its own two rounds"
);
assert_eq!(
totals.iter().sum::<u64>(),
options.reported_tokens(),
"what an observer sums from the stream matches what the budget is checked against"
);
}
#[tokio::test]
async fn delegating_with_the_budget_already_spent_fails_the_delegation() {
// A round is always allowed to finish, so the round that crosses the bound
// can still be the one asking to delegate. The child then inherits an
// already-exceeded budget and does zero rounds. That surfaces as a failed
// delegation the parent can see, not as a silent empty success — and the
// provider is never called on the child's behalf.
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
// This single round spends the whole 100-token bound and delegates.
tool_use_stream_with_usage(
&model.id,
"parent-task",
"task",
r#"{"prompt":"delegate"}"#,
usage(60, 60),
),
// Neither the child nor a second parent round may be requested.
text_stream_with_usage(&model.id, "must not run", usage(1, 1)),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let result = agent
.run(
vec![ContentBlock::text("delegate that")],
RunOptions {
token_budget: Some(100),
..Default::default()
},
)
.await;
assert!(matches!(result, Err(RuntimeError::EmptyAssistantResponse)));
assert_eq!(
provider_handle.recorded_requests().await.len(),
1,
"the delegated run stopped at its first boundary without a model request"
);
let subagents = agent.watch_snapshot().borrow().subagents.clone();
assert_eq!(subagents.len(), 1);
assert!(
matches!(
&subagents[0].status,
crate::agent::SpawnedAgentStatus::Failed(message)
if message == "run completed without a final assistant message"
),
"the exhausted delegation is recorded as failed, not finished: {:?}",
subagents[0].status
);
}

304
vendor/mentra/src/agent/tests/pending.rs vendored Normal file
View File

@@ -0,0 +1,304 @@
use serde_json::json;
use crate::{
ContentBlock, Message, Role,
agent::{AgentEvent, PendingAssistantTurn},
provider::{ContentBlockDelta, ContentBlockStart, ProviderEvent, TokenUsage},
tool::ToolCall,
};
#[test]
fn text_turn_commits_after_message_stop() {
let mut pending = PendingAssistantTurn::default();
assert!(
pending
.apply(ProviderEvent::MessageStarted {
id: "msg-1".to_string(),
model: "model".to_string(),
role: Role::Assistant,
})
.unwrap()
.is_empty()
);
assert!(
pending
.apply(ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::Text,
})
.unwrap()
.is_empty()
);
let derived = pending
.apply(ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::Text("Hello".to_string()),
})
.unwrap();
assert_eq!(
derived,
vec![AgentEvent::TextDelta {
delta: "Hello".to_string(),
full_text: "Hello".to_string(),
}]
);
pending
.apply(ProviderEvent::ContentBlockStopped { index: 0 })
.unwrap();
pending.apply(ProviderEvent::MessageStopped).unwrap();
assert_eq!(
pending.to_message().unwrap(),
Message::assistant(ContentBlock::text("Hello"))
);
}
#[test]
fn thinking_turn_emits_text_only_deltas_and_commits_signature_at_block_close() {
let provenance = crate::ReasoningProvenance {
provider: crate::ProviderId::new("anthropic-edge"),
model: "claude-test".to_string(),
format: crate::ReasoningFormat::AnthropicSigned,
};
let mut pending = PendingAssistantTurn::default();
pending
.apply(ProviderEvent::MessageStarted {
id: "msg-thinking".to_string(),
model: "claude-test".to_string(),
role: Role::Assistant,
})
.unwrap();
pending
.apply(ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::Thinking {
encrypted_content: None,
id: None,
provenance: Some(provenance.clone()),
redacted: false,
},
})
.unwrap();
assert_eq!(
pending
.apply(ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::ThinkingText("private ".to_string()),
})
.unwrap(),
vec![AgentEvent::ReasoningDelta {
delta: "private ".to_string(),
full_text: "private ".to_string(),
}]
);
assert_eq!(
pending
.apply(ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::ThinkingText("chain".to_string()),
})
.unwrap(),
vec![AgentEvent::ReasoningDelta {
delta: "chain".to_string(),
full_text: "private chain".to_string(),
}]
);
assert!(
pending
.apply(ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::ThinkingSignature("opaque-signature".to_string()),
})
.unwrap()
.is_empty()
);
pending
.apply(ProviderEvent::ContentBlockStopped { index: 0 })
.unwrap();
pending.apply(ProviderEvent::MessageStopped).unwrap();
assert_eq!(
pending.to_message().unwrap(),
Message::assistant(ContentBlock::Thinking {
thinking: "private chain".to_string(),
signature: Some("opaque-signature".to_string()),
encrypted_content: None,
id: None,
provenance: Some(provenance),
redacted: false,
})
);
}
#[test]
fn tool_use_turn_emits_ready_event_and_parses_call() {
let mut pending = PendingAssistantTurn::default();
pending
.apply(ProviderEvent::MessageStarted {
id: "msg-1".to_string(),
model: "model".to_string(),
role: Role::Assistant,
})
.unwrap();
pending
.apply(ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::ToolUse {
id: "tool-1".to_string(),
name: "echo_tool".to_string(),
},
})
.unwrap();
pending
.apply(ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::ToolUseInputJson(r#"{"value":"hi"}"#.to_string()),
})
.unwrap();
let derived = pending
.apply(ProviderEvent::ContentBlockStopped { index: 0 })
.unwrap();
assert_eq!(
derived,
vec![AgentEvent::ToolUseReady {
index: 0,
call: ToolCall {
id: "tool-1".to_string(),
name: "echo_tool".to_string(),
input: json!({ "value": "hi" }),
},
}]
);
pending.apply(ProviderEvent::MessageStopped).unwrap();
assert_eq!(pending.ready_tool_calls().unwrap().len(), 1);
}
#[test]
fn pending_turn_rejects_missing_stop_and_recovers_from_malformed_tool_json() {
let mut text_pending = PendingAssistantTurn::default();
text_pending
.apply(ProviderEvent::MessageStarted {
id: "msg-1".to_string(),
model: "model".to_string(),
role: Role::Assistant,
})
.unwrap();
text_pending
.apply(ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::Text,
})
.unwrap();
text_pending
.apply(ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::Text("Hello".to_string()),
})
.unwrap();
text_pending
.apply(ProviderEvent::ContentBlockStopped { index: 0 })
.unwrap();
assert!(text_pending.to_message().is_err());
let mut tool_pending = PendingAssistantTurn::default();
tool_pending
.apply(ProviderEvent::MessageStarted {
id: "msg-2".to_string(),
model: "model".to_string(),
role: Role::Assistant,
})
.unwrap();
tool_pending
.apply(ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::ToolUse {
id: "tool-1".to_string(),
name: "broken_tool".to_string(),
},
})
.unwrap();
tool_pending
.apply(ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::ToolUseInputJson("{".to_string()),
})
.unwrap();
assert!(
tool_pending
.apply(ProviderEvent::ContentBlockStopped { index: 0 })
.unwrap()
.is_empty()
);
tool_pending.apply(ProviderEvent::MessageStopped).unwrap();
assert!(tool_pending.ready_tool_calls().unwrap().is_empty());
assert_eq!(tool_pending.invalid_tool_uses().len(), 1);
assert_eq!(
tool_pending.to_message().unwrap(),
Message {
role: Role::Assistant,
content: Vec::new(),
}
);
}
#[test]
fn pending_turn_tracks_latest_usage_without_affecting_message() {
let mut pending = PendingAssistantTurn::default();
pending
.apply(ProviderEvent::MessageStarted {
id: "msg-1".to_string(),
model: "model".to_string(),
role: Role::Assistant,
})
.unwrap();
pending
.apply(ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::Text,
})
.unwrap();
pending
.apply(ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::Text("Hello".to_string()),
})
.unwrap();
pending
.apply(ProviderEvent::ContentBlockStopped { index: 0 })
.unwrap();
pending
.apply(ProviderEvent::MessageDelta {
stop_reason: Some("stop".to_string()),
usage: Some(TokenUsage {
input_tokens: Some(12),
output_tokens: Some(3),
total_tokens: Some(15),
..TokenUsage::default()
}),
})
.unwrap();
pending.apply(ProviderEvent::MessageStopped).unwrap();
assert_eq!(
pending.usage(),
Some(&TokenUsage {
input_tokens: Some(12),
output_tokens: Some(3),
total_tokens: Some(15),
..TokenUsage::default()
})
);
assert_eq!(pending.stop_reason(), Some("stop"));
assert_eq!(
pending.to_message().unwrap(),
Message::assistant(ContentBlock::text("Hello"))
);
}

View File

@@ -0,0 +1,500 @@
use std::{
collections::VecDeque,
sync::{Arc, Mutex},
};
use async_trait::async_trait;
use crate::{
BuiltinProvider, ContentBlock, Message, ReasoningEffort, ReasoningOptions, Role, Runtime,
agent::{
ReasoningChange, RoundAdjustment, RoundBoundary, RoundContext, RoundDecision,
RoundStrategy, RoundToolResult,
},
error::RuntimeError,
runtime::RunOptions,
};
use super::support::{ScriptedProvider, StaticTool, model_info, text_stream, tool_use_stream};
/// One boundary observation recorded by [`ScriptedStrategy`].
#[derive(Clone)]
struct Observation {
boundary: RoundBoundary,
rounds_completed: usize,
model_requests: usize,
assistant_text: Option<String>,
tool_results: Vec<RoundToolResult>,
}
/// A scripted decision returned at a boundary. An exhausted script yields
/// [`RoundDecision::proceed`].
enum DecisionScript {
Inject(String),
Stop,
Switch(RoundAdjustment),
}
/// A [`RoundStrategy`] that records every boundary it observes and replays a
/// scripted sequence of decisions.
struct ScriptedStrategy {
log: Mutex<Vec<Observation>>,
decisions: Mutex<VecDeque<DecisionScript>>,
}
impl ScriptedStrategy {
fn new(decisions: Vec<DecisionScript>) -> Arc<Self> {
Arc::new(Self {
log: Mutex::new(Vec::new()),
decisions: Mutex::new(decisions.into()),
})
}
fn observations(&self) -> Vec<Observation> {
self.log.lock().expect("strategy log poisoned").clone()
}
fn invocation_count(&self) -> usize {
self.log.lock().expect("strategy log poisoned").len()
}
}
#[async_trait]
impl RoundStrategy for ScriptedStrategy {
async fn on_round(&self, ctx: RoundContext<'_>) -> RoundDecision {
self.log
.lock()
.expect("strategy log poisoned")
.push(Observation {
boundary: ctx.boundary(),
rounds_completed: ctx.rounds_completed(),
model_requests: ctx.model_requests(),
assistant_text: ctx.assistant_message().map(Message::text),
tool_results: ctx.tool_results().to_vec(),
});
match self
.decisions
.lock()
.expect("strategy decisions poisoned")
.pop_front()
{
None => RoundDecision::proceed(),
Some(DecisionScript::Inject(text)) => {
RoundDecision::inject(vec![ContentBlock::text(text)])
}
Some(DecisionScript::Stop) => RoundDecision::stop(),
Some(DecisionScript::Switch(adjust)) => RoundDecision::Continue(adjust),
}
}
}
/// Captured outcome of a two-round probe session (tool round then text round).
struct SessionCapture {
history: Vec<Message>,
request_models: Vec<String>,
request_messages: Vec<Vec<Message>>,
}
/// Runs the canonical two-round probe session (one tool round, one terminal text
/// round), optionally attaching a proceed-everywhere strategy built from
/// `decisions`, and captures the transcript and recorded requests.
async fn run_probe_session(decisions: Option<Vec<DecisionScript>>) -> SessionCapture {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(&model.id, "call-1", "probe_tool", r#"{"value":"hi"}"#),
text_stream(&model.id, "done"),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("probe_tool", "ok"))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let options = match decisions {
Some(decisions) => {
RunOptions::default().with_round_strategy(ScriptedStrategy::new(decisions))
}
None => RunOptions::default(),
};
agent
.run(vec![ContentBlock::text("hi")], options)
.await
.expect("run succeeds");
let requests = provider_handle.recorded_requests().await;
SessionCapture {
history: agent.history().to_vec(),
request_models: requests.iter().map(|r| r.model.to_string()).collect(),
request_messages: requests.iter().map(|r| r.messages.to_vec()).collect(),
}
}
#[tokio::test]
async fn strategy_observes_both_round_boundaries_in_order() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(&model.id, "call-1", "probe_tool", r#"{"value":"hi"}"#),
text_stream(&model.id, "done"),
],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("probe_tool", "ok"))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let strategy = ScriptedStrategy::new(vec![]);
agent
.run(
vec![ContentBlock::text("hi")],
RunOptions::default().with_round_strategy(strategy.clone()),
)
.await
.expect("run succeeds");
let observations = strategy.observations();
assert_eq!(observations.len(), 2);
// Boundary (a): fired after the committed tool round, before the next round.
assert_eq!(
observations[0].boundary,
RoundBoundary::ToolResultsCommitted
);
assert_eq!(observations[0].rounds_completed, 1);
assert_eq!(observations[0].model_requests, 1);
assert!(observations[0].assistant_text.is_none());
assert_eq!(observations[0].tool_results.len(), 1);
assert_eq!(observations[0].tool_results[0].tool_use_id, "call-1");
assert_eq!(observations[0].tool_results[0].tool_name, "probe_tool");
assert!(!observations[0].tool_results[0].is_error);
// Boundary (b): fired after the committed tool-free assistant message.
assert_eq!(
observations[1].boundary,
RoundBoundary::AssistantMessageCommitted
);
assert_eq!(observations[1].rounds_completed, 2);
assert_eq!(observations[1].model_requests, 2);
assert_eq!(observations[1].assistant_text.as_deref(), Some("done"));
assert!(observations[1].tool_results.is_empty());
}
#[tokio::test]
async fn none_strategy_matches_continue_strategy_byte_identical() {
// The `None` default and a proceed-everywhere strategy must produce an
// identical transcript and identical recorded requests: the seam is inert.
let baseline = run_probe_session(None).await;
let with_strategy = run_probe_session(Some(vec![])).await;
assert_eq!(baseline.request_models.len(), 2, "two rounds ran");
assert_eq!(baseline.history, with_strategy.history);
assert_eq!(baseline.request_models, with_strategy.request_models);
assert_eq!(baseline.request_messages, with_strategy.request_messages);
}
#[tokio::test]
async fn injected_context_reaches_next_provider_request() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
text_stream(&model.id, "first"),
text_stream(&model.id, "second"),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let strategy = ScriptedStrategy::new(vec![DecisionScript::Inject(
"please call finish_investigation".to_string(),
)]);
let message = agent
.run(
vec![ContentBlock::text("hi")],
RunOptions::default().with_round_strategy(strategy),
)
.await
.expect("run succeeds");
assert_eq!(message.text(), "second");
let requests = provider_handle.recorded_requests().await;
assert_eq!(requests.len(), 2, "the injection forced a second round");
let second_round_has_injection = requests[1]
.messages
.iter()
.any(|m| m.role == Role::User && m.text().contains("please call finish_investigation"));
assert!(
second_round_has_injection,
"injected corrective context must reach the next provider request"
);
}
#[tokio::test]
async fn model_and_reasoning_switch_applies_to_next_round() {
let model_a = model_info("model-a", BuiltinProvider::Anthropic);
let model_b = model_info("model-b", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model_a.clone(), model_b.clone()],
vec![
tool_use_stream(&model_a.id, "call-1", "probe_tool", r#"{"value":"hi"}"#),
text_stream(&model_b.id, "done"),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("probe_tool", "ok"))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model_a).expect("spawn agent");
let adjust = RoundAdjustment::new()
.with_model(model_b)
.with_reasoning(ReasoningChange::Set(ReasoningOptions {
effort: Some(ReasoningEffort::High),
summary: None,
}));
let strategy = ScriptedStrategy::new(vec![DecisionScript::Switch(adjust)]);
agent
.run(
vec![ContentBlock::text("hi")],
RunOptions::default().with_round_strategy(strategy),
)
.await
.expect("run succeeds");
let requests = provider_handle.recorded_requests().await;
assert_eq!(requests.len(), 2);
// Round 1 (before the switch) uses the original model and no reasoning.
assert_eq!(requests[0].model.as_ref(), "model-a");
assert_eq!(requests[0].provider_request_options.reasoning, None);
// Round 2 (after the switch) uses the switched model and reasoning.
assert_eq!(requests[1].model.as_ref(), "model-b");
assert_eq!(
requests[1].provider_request_options.reasoning,
Some(ReasoningOptions {
effort: Some(ReasoningEffort::High),
summary: None,
})
);
}
#[tokio::test]
async fn stop_after_tool_round_commits_transcript_and_halts() {
// A Stop returned at the tool-round boundary matches `RunOptions::stop`: the
// gathered transcript is committed (not rolled back) and no further model
// request is made. Because the last committed message is a tool result rather
// than an assistant message, `Agent::run` surfaces `EmptyAssistantResponse` —
// the honest "stopped before a final answer" outcome, identical to
// `RunOptions::stop` firing at the same boundary.
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(&model.id, "call-1", "probe_tool", r#"{"value":"hi"}"#),
text_stream(&model.id, "must not run"),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("probe_tool", "ok"))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let strategy = ScriptedStrategy::new(vec![DecisionScript::Stop]);
let result = agent
.run(
vec![ContentBlock::text("go")],
RunOptions::default().with_round_strategy(strategy),
)
.await;
assert!(matches!(result, Err(RuntimeError::EmptyAssistantResponse)));
assert_eq!(
agent.history().len(),
3,
"the gathered tool round is committed, not rolled back"
);
assert_eq!(
provider_handle.recorded_requests().await.len(),
1,
"the stop halted the run before a second model request"
);
}
#[tokio::test]
async fn stop_at_assistant_boundary_returns_committed_message() {
// At the assistant boundary a Stop commits the transcript and returns Ok with
// the terminal message (the finish_run path, not rollback).
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![text_stream(&model.id, "final answer")],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let strategy = ScriptedStrategy::new(vec![DecisionScript::Stop]);
let message = agent
.run(
vec![ContentBlock::text("hi")],
RunOptions::default().with_round_strategy(strategy),
)
.await
.expect("a stop at the assistant boundary returns Ok");
assert_eq!(message.text(), "final answer");
assert_eq!(
agent.history().len(),
2,
"user + committed assistant message"
);
}
#[tokio::test]
async fn strategy_state_does_not_outlive_run() {
// Two sequential runs on one runtime/agent, each with its own strategy
// instance, share nothing: each strategy observes only its own run's boundary.
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
text_stream(&model.id, "first"),
text_stream(&model.id, "second"),
],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let strategy_one = ScriptedStrategy::new(vec![]);
agent
.run(
vec![ContentBlock::text("run one")],
RunOptions::default().with_round_strategy(strategy_one.clone()),
)
.await
.expect("first run succeeds");
let strategy_two = ScriptedStrategy::new(vec![]);
agent
.run(
vec![ContentBlock::text("run two")],
RunOptions::default().with_round_strategy(strategy_two.clone()),
)
.await
.expect("second run succeeds");
assert_eq!(
strategy_one.invocation_count(),
1,
"the first strategy saw only its own run"
);
assert_eq!(
strategy_two.invocation_count(),
1,
"the second strategy saw only its own run"
);
}
#[tokio::test]
async fn assistant_boundary_continue_returns_inject_runs_another_round() {
// Continue at the assistant boundary accepts the terminal message and returns.
{
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![text_stream(&model.id, "solo")],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let strategy = ScriptedStrategy::new(vec![]);
let message = agent
.run(
vec![ContentBlock::text("hi")],
RunOptions::default().with_round_strategy(strategy),
)
.await
.expect("run succeeds");
assert_eq!(message.text(), "solo");
assert_eq!(
provider_handle.recorded_requests().await.len(),
1,
"continue returns without another round"
);
}
// Inject at the assistant boundary prevents returning and runs another round.
{
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
text_stream(&model.id, "first"),
text_stream(&model.id, "second"),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let strategy =
ScriptedStrategy::new(vec![DecisionScript::Inject("keep going".to_string())]);
let message = agent
.run(
vec![ContentBlock::text("hi")],
RunOptions::default().with_round_strategy(strategy),
)
.await
.expect("run succeeds");
assert_eq!(
message.text(),
"second",
"the injected round produced the terminal message"
);
assert_eq!(
provider_handle.recorded_requests().await.len(),
2,
"inject forced another model round"
);
}
}

999
vendor/mentra/src/agent/tests/runtime.rs vendored Normal file
View File

@@ -0,0 +1,999 @@
use std::{
sync::{Arc, Mutex},
time::Duration,
};
use async_trait::async_trait;
use serde_json::{Value, json};
use tokio::time::sleep;
use crate::{
BuiltinProvider, ContentBlock, Role,
provider::{ContentBlockDelta, ContentBlockStart, ProviderError, ProviderEvent, TokenUsage},
runtime::{
RunOptions, Runtime, RuntimeHook, RuntimeHookEvent, RuntimePolicy, ShellValidationMode,
is_transient_runtime_error,
},
tool::{
ToolAuthorizationDecision, ToolAuthorizationOutcome, ToolAuthorizationRequest,
ToolAuthorizer, ToolContext, ToolDefinition, ToolExecutor, ToolResult, ToolSpec,
},
};
use super::support::{ScriptedProvider, StaticTool, erroring_stream, model_info, ok_stream};
#[tokio::test]
async fn run_respects_tool_budget() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![tool_use_stream(
&model.id,
"tool-1",
"test_tool",
r#"{"value":"hi"}"#,
)],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("test_tool", "ok"))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).unwrap();
let error = agent
.run(
vec![ContentBlock::Text {
text: "hi".to_string(),
}],
RunOptions {
tool_budget: Some(0),
..RunOptions::default()
},
)
.await
.expect_err("tool budget should abort run");
assert!(matches!(
error,
crate::runtime::RuntimeError::ToolBudgetExceeded(0)
));
}
#[tokio::test]
async fn workspace_bounded_policy_keeps_local_shell_disabled() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(&model.id, "pwd", "shell", r#"{"command":"pwd"}"#),
text_stream("done"),
],
);
let runtime = Runtime::builder()
.with_provider_instance(provider)
.with_policy(RuntimePolicy::workspace_bounded(
std::env::current_dir().expect("current directory"),
))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).unwrap();
agent
.send(vec![ContentBlock::Text {
text: "run pwd".to_string(),
}])
.await
.expect("send");
assert!(matches!(
&agent.history()[2].content[0],
ContentBlock::ToolResult { content, is_error: true, .. }
if content.contains("disabled by the runtime policy")
));
}
#[tokio::test]
async fn enforced_shell_validation_blocks_destructive_command_and_emits_hook() {
let model = model_info("model", BuiltinProvider::Anthropic);
let sentinel = std::env::temp_dir().join(format!(
"mentra-shell-validation-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time")
.as_nanos()
));
std::fs::create_dir_all(&sentinel).expect("create sentinel directory");
let input = json!({ "command": format!("rm -rf {}", sentinel.display()) }).to_string();
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(&model.id, "rm", "shell", &input),
text_stream("done"),
],
);
let recorded = Arc::new(Mutex::new(Vec::new()));
let runtime = Runtime::builder()
.with_provider_instance(provider)
.with_policy(
RuntimePolicy::read_only(std::env::current_dir().expect("current directory"))
// This test exercises validation before execution. The
// destructive command is expected to stop at that boundary.
.allow_shell_commands(true)
.shell_validation(ShellValidationMode::Enforce),
)
.with_hook(RecordingHook {
events: recorded.clone(),
})
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).unwrap();
agent
.send(vec![ContentBlock::Text {
text: "delete everything".to_string(),
}])
.await
.expect("send");
assert!(matches!(
&agent.history()[2].content[0],
ContentBlock::ToolResult { content, is_error: true, .. }
if content.contains("not allowed in read-only mode")
));
assert!(
recorded
.lock()
.expect("hook events poisoned")
.iter()
.any(|event| matches!(
event,
RuntimeHookEvent::AuthorizationDenied { action, detail, .. }
if action == "shell_validation" && detail.contains("not allowed")
))
);
assert!(
sentinel.exists(),
"blocked command must not reach the executor"
);
std::fs::remove_dir_all(sentinel).expect("remove sentinel directory");
}
#[tokio::test]
async fn shell_authorization_preview_carries_validation_intent() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(
&model.id,
"rm",
"shell",
r#"{"command":"rm -rf /tmp/mentra-preview-never-execute"}"#,
),
text_stream("done"),
],
);
let requests = Arc::new(Mutex::new(Vec::new()));
let runtime = Runtime::builder()
.with_provider_instance(provider)
.with_policy(RuntimePolicy::workspace_bounded(
std::env::current_dir().expect("current directory"),
))
.with_tool_authorizer(RecordingAuthorizer::deny(
"destructive command denied",
requests.clone(),
))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).unwrap();
agent
.send(vec![ContentBlock::Text {
text: "classify this command".to_string(),
}])
.await
.expect("send");
let requests = requests.lock().expect("requests poisoned");
let validation = &requests[0].preview.structured_input["validation"];
assert_eq!(validation["mode"], "off");
assert_eq!(validation["intent"], "destructive");
assert_eq!(validation["outcome"], "prompt");
assert!(validation["reason"].as_str().is_some());
}
#[tokio::test]
async fn tool_authorizer_allows_tool_execution_and_captures_preview() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(&model.id, "tool-1", "test_tool", r#"{"value":"hi"}"#),
text_stream("done"),
],
);
let requests = Arc::new(Mutex::new(Vec::new()));
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("test_tool", "ok"))
.with_tool_authorizer(RecordingAuthorizer::allow(requests.clone()))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).unwrap();
agent
.send(vec![ContentBlock::Text {
text: "hi".to_string(),
}])
.await
.expect("send");
assert!(matches!(
&agent.history()[2].content[0],
ContentBlock::ToolResult { content, is_error: false, .. }
if content.to_display_string() == "ok"
));
let requests = requests.lock().expect("requests poisoned");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].tool_name, "test_tool");
assert_eq!(
requests[0].preview.structured_input,
json!({ "value": "hi" })
);
}
#[tokio::test]
async fn tool_authorizer_can_prompt_shell_tool_and_emit_hooks() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(
&model.id,
"tool-shell",
"shell",
r#"{"command":"python -c 'print(1)'","justification":"needed for validation"}"#,
),
text_stream("done"),
],
);
let requests = Arc::new(Mutex::new(Vec::new()));
let recorded = Arc::new(Mutex::new(Vec::new()));
let runtime = Runtime::builder()
.with_provider_instance(provider)
.with_policy(RuntimePolicy::default().allow_shell_commands(true))
.with_tool_authorizer(RecordingAuthorizer::prompt(
"needs manual review",
requests.clone(),
))
.with_hook(RecordingHook {
events: recorded.clone(),
})
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).unwrap();
agent
.send(vec![ContentBlock::Text {
text: "run python".to_string(),
}])
.await
.expect("send");
assert!(matches!(
&agent.history()[2].content[0],
ContentBlock::ToolResult { content, is_error: true, .. }
if content.contains("Tool execution requires approval: needs manual review")
));
let requests = requests.lock().expect("requests poisoned");
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].tool_name, "shell");
assert_eq!(
requests[0].preview.structured_input["kind"].as_str(),
Some("shell")
);
let events = recorded.lock().expect("hook events poisoned").clone();
assert!(events.iter().any(|event| matches!(
event,
RuntimeHookEvent::ToolAuthorizationStarted { tool_name, .. } if tool_name == "shell"
)));
assert!(events.iter().any(|event| matches!(
event,
RuntimeHookEvent::ToolAuthorizationFinished { tool_name, outcome, .. }
if tool_name == "shell" && *outcome == ToolAuthorizationOutcome::Prompt
)));
assert!(events.iter().any(|event| matches!(
event,
RuntimeHookEvent::ToolAuthorizationBlocked { tool_name, outcome, .. }
if tool_name == "shell" && *outcome == ToolAuthorizationOutcome::Prompt
)));
assert!(!events.iter().any(|event| matches!(
event,
RuntimeHookEvent::ToolExecutionStarted { tool_name, .. } if tool_name == "shell"
)));
}
#[tokio::test]
async fn tool_authorizer_can_deny_background_run() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(
&model.id,
"tool-bg",
"background_run",
r#"{"command":"python -c 'print(1)'","justification":"background probe"}"#,
),
text_stream("done"),
],
);
let requests = Arc::new(Mutex::new(Vec::new()));
let runtime = Runtime::builder()
.with_provider_instance(provider)
.with_policy(
RuntimePolicy::default()
.allow_shell_commands(true)
.allow_background_commands(true),
)
.with_tool_authorizer(RecordingAuthorizer::deny(
"background tasks require review",
requests.clone(),
))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).unwrap();
agent
.send(vec![ContentBlock::Text {
text: "run background work".to_string(),
}])
.await
.expect("send");
assert!(matches!(
&agent.history()[2].content[0],
ContentBlock::ToolResult { content, is_error: true, .. }
if content.contains("Tool execution denied: background tasks require review")
));
let requests = requests.lock().expect("requests poisoned");
assert_eq!(
requests[0].preview.structured_input["kind"].as_str(),
Some("background_run")
);
assert_eq!(
requests[0].preview.structured_input["background"].as_bool(),
Some(true)
);
}
#[tokio::test]
async fn tool_authorizer_errors_block_execution() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(&model.id, "tool-1", "test_tool", r#"{"value":"hi"}"#),
text_stream("done"),
],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("test_tool", "ok"))
.with_tool_authorizer(RecordingAuthorizer::error("authorizer unavailable"))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).unwrap();
agent
.send(vec![ContentBlock::Text {
text: "hi".to_string(),
}])
.await
.expect("send");
assert!(matches!(
&agent.history()[2].content[0],
ContentBlock::ToolResult { content, is_error: true, .. }
if content.contains("authorizer unavailable")
));
}
#[tokio::test]
async fn tool_authorizer_timeout_blocks_execution() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(&model.id, "tool-1", "test_tool", r#"{"value":"hi"}"#),
text_stream("done"),
],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("test_tool", "ok"))
.with_tool_authorizer(RecordingAuthorizer::delayed_allow(
Duration::from_millis(50),
Duration::from_millis(10),
))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).unwrap();
agent
.send(vec![ContentBlock::Text {
text: "hi".to_string(),
}])
.await
.expect("send");
assert!(matches!(
&agent.history()[2].content[0],
ContentBlock::ToolResult { content, is_error: true, .. }
if content.contains("authorizer timed out")
));
}
#[tokio::test]
async fn files_tool_authorization_preview_exposes_resolved_paths() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(
&model.id,
"files-read",
"files",
r#"{"operations":[{"op":"read","path":"README.md","offset":1,"limit":1}]}"#,
),
text_stream("done"),
],
);
let requests = Arc::new(Mutex::new(Vec::new()));
let runtime = Runtime::builder()
.with_provider_instance(provider)
.with_tool_authorizer(RecordingAuthorizer::allow(requests.clone()))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).unwrap();
agent
.send(vec![ContentBlock::Text {
text: "read the readme".to_string(),
}])
.await
.expect("send");
let requests = requests.lock().expect("requests poisoned");
assert_eq!(requests.len(), 1);
let operations = requests[0].preview.structured_input["operations"]
.as_array()
.expect("operations array");
assert_eq!(operations[0]["op"].as_str(), Some("read"));
assert!(
operations[0]["resolved_path"]
.as_str()
.expect("resolved path")
.ends_with("README.md")
);
assert!(operations[0].get("content").is_none());
}
#[tokio::test]
async fn custom_hooks_observe_model_and_tool_execution() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(&model.id, "tool-1", "test_tool", r#"{"value":"hi"}"#),
text_stream("done"),
],
);
let recorded = Arc::new(Mutex::new(Vec::new()));
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("test_tool", "ok"))
.with_hook(RecordingHook {
events: recorded.clone(),
})
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).unwrap();
agent
.send(vec![ContentBlock::Text {
text: "hi".to_string(),
}])
.await
.expect("send");
let events = recorded.lock().expect("hook events poisoned").clone();
assert!(
events
.iter()
.any(|event| matches!(event, RuntimeHookEvent::ModelRequestStarted { .. }))
);
assert!(events.iter().any(|event| matches!(
event,
RuntimeHookEvent::ModelResponseFinished {
success: true,
stop_reason: Some(reason),
usage: None,
..
} if reason == "tool_use"
)));
assert!(events.iter().any(|event| matches!(
event,
RuntimeHookEvent::ToolExecutionStarted { tool_name, .. } if tool_name == "test_tool"
)));
assert!(events.iter().any(|event| matches!(
event,
RuntimeHookEvent::ToolExecutionFinished { tool_name, is_error: false, .. }
if tool_name == "test_tool"
)));
}
#[tokio::test]
async fn tools_can_read_registered_app_context() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(&model.id, "tool-ctx", "app_context_tool", r#"{}"#),
text_stream("done"),
],
);
let app_state = Arc::new(TestAppState {
label: "configured",
});
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_context(app_state.clone())
.with_tool(AppContextTool)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).unwrap();
assert_eq!(
runtime
.app_context::<TestAppState>()
.expect("app context should be registered")
.label,
"configured"
);
agent
.send(vec![ContentBlock::Text {
text: "use the app_context_tool".to_string(),
}])
.await
.expect("send");
assert!(matches!(
&agent.history()[2].content[0],
ContentBlock::ToolResult { content, is_error: false, .. }
if content.to_display_string() == "configured"
));
}
#[tokio::test]
async fn tool_execution_timeout_returns_tool_error() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(&model.id, "tool-slow", "slow_tool", r#"{}"#),
text_stream("done"),
],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(SlowTool)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).unwrap();
agent
.send(vec![ContentBlock::Text {
text: "run the slow tool".to_string(),
}])
.await
.expect("send");
assert!(matches!(
&agent.history()[2].content[0],
ContentBlock::ToolResult { content, is_error: true, .. }
if content.contains("timed out after 20ms")
));
}
#[tokio::test]
async fn model_response_finished_hook_reports_usage_after_successful_commit() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![ok_stream(vec![
ProviderEvent::MessageStarted {
id: "msg-usage".to_string(),
model: model.id.clone(),
role: Role::Assistant,
},
ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::Text,
},
ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::Text("done".to_string()),
},
ProviderEvent::ContentBlockStopped { index: 0 },
ProviderEvent::MessageDelta {
stop_reason: Some("end_turn".to_string()),
usage: Some(TokenUsage {
input_tokens: Some(12),
output_tokens: Some(5),
total_tokens: Some(17),
..TokenUsage::default()
}),
},
ProviderEvent::MessageStopped,
])],
);
let recorded = Arc::new(Mutex::new(Vec::new()));
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_hook(RecordingHook {
events: recorded.clone(),
})
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).unwrap();
agent
.send(vec![ContentBlock::Text {
text: "hi".to_string(),
}])
.await
.expect("send");
let events = recorded.lock().expect("hook events poisoned").clone();
assert!(events.iter().any(|event| matches!(
event,
RuntimeHookEvent::ModelResponseFinished {
success: true,
stop_reason: Some(reason),
usage: Some(TokenUsage {
input_tokens: Some(12),
output_tokens: Some(5),
total_tokens: Some(17),
..
}),
..
} if reason == "end_turn"
)));
}
#[tokio::test]
async fn model_response_finished_hook_reports_stream_failures_without_usage() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![erroring_stream(
vec![
ProviderEvent::MessageStarted {
id: "msg-fail".to_string(),
model: model.id.clone(),
role: Role::Assistant,
},
ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::Text,
},
ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::Text("par".to_string()),
},
],
ProviderError::MalformedStream("boom".to_string()),
)],
);
let recorded = Arc::new(Mutex::new(Vec::new()));
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_hook(RecordingHook {
events: recorded.clone(),
})
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).unwrap();
let error = agent
.send(vec![ContentBlock::Text {
text: "hi".to_string(),
}])
.await
.expect_err("send should fail");
assert!(matches!(
error,
crate::runtime::RuntimeError::FailedToStreamResponse(ProviderError::MalformedStream(_))
));
let events = recorded.lock().expect("hook events poisoned").clone();
assert!(events.iter().any(|event| matches!(
event,
RuntimeHookEvent::ModelResponseFinished {
success: false,
usage: None,
error: Some(message),
..
} if message.contains("malformed provider stream: boom")
)));
}
#[test]
fn transient_runtime_error_helper_matches_provider_retry_policy() {
let transient = crate::runtime::RuntimeError::FailedToStreamResponse(ProviderError::Http {
status: reqwest::StatusCode::TOO_MANY_REQUESTS,
body: String::new(),
retry_after: None,
});
let permanent = crate::runtime::RuntimeError::FailedToStreamResponse(
ProviderError::InvalidRequest("bad request".to_string()),
);
assert!(is_transient_runtime_error(&transient));
assert!(!is_transient_runtime_error(&permanent));
assert!(!is_transient_runtime_error(
&crate::runtime::RuntimeError::EmptyAssistantResponse
));
}
#[derive(Debug)]
struct TestAppState {
label: &'static str,
}
struct AppContextTool;
#[async_trait]
impl ToolDefinition for AppContextTool {
fn descriptor(&self) -> ToolSpec {
ToolSpec::builder("app_context_tool")
.description("Return a value from the runtime app context.")
.input_schema(json!({
"type": "object",
"properties": {}
}))
.build()
}
}
#[async_trait]
impl ToolExecutor for AppContextTool {
async fn execute_mut(&self, ctx: ToolContext<'_>, _input: Value) -> ToolResult {
Ok(ctx.app_context::<TestAppState>()?.label.to_string())
}
}
struct SlowTool;
#[async_trait]
impl ToolDefinition for SlowTool {
fn descriptor(&self) -> ToolSpec {
ToolSpec::builder("slow_tool")
.description("Sleep long enough to trigger a timeout.")
.input_schema(json!({
"type": "object",
"properties": {}
}))
.execution_timeout(Duration::from_millis(20))
.build()
}
}
#[async_trait]
impl ToolExecutor for SlowTool {
async fn execute_mut(&self, _ctx: ToolContext<'_>, _input: Value) -> ToolResult {
sleep(Duration::from_millis(60)).await;
Ok("finished".to_string())
}
}
#[derive(Clone)]
struct RecordingHook {
events: Arc<Mutex<Vec<RuntimeHookEvent>>>,
}
impl RuntimeHook for RecordingHook {
fn on_event(
&self,
_store: &dyn crate::runtime::AuditStore,
event: &RuntimeHookEvent,
) -> Result<(), crate::runtime::RuntimeError> {
self.events
.lock()
.expect("hook events poisoned")
.push(event.clone());
Ok(())
}
}
enum AuthorizerBehavior {
Allow,
Prompt(String),
Deny(String),
Error(String),
DelayedAllow(Duration),
}
struct RecordingAuthorizer {
behavior: AuthorizerBehavior,
requests: Arc<Mutex<Vec<ToolAuthorizationRequest>>>,
timeout: Option<Duration>,
}
impl RecordingAuthorizer {
fn allow(requests: Arc<Mutex<Vec<ToolAuthorizationRequest>>>) -> Self {
Self {
behavior: AuthorizerBehavior::Allow,
requests,
timeout: None,
}
}
fn prompt(reason: &str, requests: Arc<Mutex<Vec<ToolAuthorizationRequest>>>) -> Self {
Self {
behavior: AuthorizerBehavior::Prompt(reason.to_string()),
requests,
timeout: None,
}
}
fn error(reason: &str) -> Self {
Self {
behavior: AuthorizerBehavior::Error(reason.to_string()),
requests: Arc::new(Mutex::new(Vec::new())),
timeout: None,
}
}
fn deny(reason: &str, requests: Arc<Mutex<Vec<ToolAuthorizationRequest>>>) -> Self {
Self {
behavior: AuthorizerBehavior::Deny(reason.to_string()),
requests,
timeout: None,
}
}
fn delayed_allow(delay: Duration, timeout: Duration) -> Self {
Self {
behavior: AuthorizerBehavior::DelayedAllow(delay),
requests: Arc::new(Mutex::new(Vec::new())),
timeout: Some(timeout),
}
}
}
#[async_trait]
impl ToolAuthorizer for RecordingAuthorizer {
async fn authorize(
&self,
request: &ToolAuthorizationRequest,
) -> Result<ToolAuthorizationDecision, crate::runtime::RuntimeError> {
self.requests
.lock()
.expect("requests poisoned")
.push(request.clone());
match &self.behavior {
AuthorizerBehavior::Allow => Ok(ToolAuthorizationDecision::allow()),
AuthorizerBehavior::Prompt(reason) => {
Ok(ToolAuthorizationDecision::prompt(reason.clone()))
}
AuthorizerBehavior::Deny(reason) => Ok(ToolAuthorizationDecision::deny(reason.clone())),
AuthorizerBehavior::Error(reason) => {
Err(crate::runtime::RuntimeError::Store(reason.clone()))
}
AuthorizerBehavior::DelayedAllow(delay) => {
sleep(*delay).await;
Ok(ToolAuthorizationDecision::allow())
}
}
}
fn timeout(&self) -> Option<Duration> {
self.timeout
}
}
fn text_stream(text: &str) -> super::support::StreamScript {
ok_stream(vec![
ProviderEvent::MessageStarted {
id: "msg-text".to_string(),
model: "model".to_string(),
role: Role::Assistant,
},
ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::Text,
},
ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::Text(text.to_string()),
},
ProviderEvent::ContentBlockStopped { index: 0 },
ProviderEvent::MessageDelta {
stop_reason: Some("end_turn".to_string()),
usage: None,
},
ProviderEvent::MessageStopped,
])
}
fn tool_use_stream(
model: &str,
id: &str,
name: &str,
input_json: &str,
) -> super::support::StreamScript {
ok_stream(vec![
ProviderEvent::MessageStarted {
id: format!("msg-{id}"),
model: model.to_string(),
role: Role::Assistant,
},
ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::ToolUse {
id: id.to_string(),
name: name.to_string(),
},
},
ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::ToolUseInputJson(input_json.to_string()),
},
ProviderEvent::ContentBlockStopped { index: 0 },
ProviderEvent::MessageDelta {
stop_reason: Some("tool_use".to_string()),
usage: None,
},
ProviderEvent::MessageStopped,
])
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,483 @@
use std::{path::PathBuf, time::Duration};
use crate::{
BuiltinProvider, ContentBlock, Message, Role,
agent::{AgentConfig, CompactionConfig, MemoryConfig},
memory::{MemoryRecord, MemoryRecordKind, MemoryStore},
provider::{ContentBlockDelta, ContentBlockStart, ProviderEvent},
runtime::{HybridRuntimeStore, Runtime, SqliteRuntimeStore},
};
use super::support::{ScriptedProvider, StreamScript, model_info, ok_stream};
#[tokio::test]
async fn automatic_memory_search_injects_recalled_context_without_persisting_it() {
let store = test_store("recalled-memory");
store
.upsert_records(&[MemoryRecord {
record_id: "summary:agent:1".to_string(),
agent_id: "agent-1".to_string(),
kind: MemoryRecordKind::Summary,
content: "The user prefers keeping memory automatic and bounded.".to_string(),
source_revision: 1,
created_at: 1,
metadata_json: "{}".to_string(),
source: Some("seed".to_string()),
pinned: false,
score: None,
}])
.expect("seed records");
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![text_stream(&model.id, "done")],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_store(store.clone())
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let agent_id = agent.id().to_string();
store
.upsert_records(&[MemoryRecord {
record_id: format!("summary:{agent_id}:1"),
agent_id: agent_id.clone(),
kind: MemoryRecordKind::Summary,
content: "The user prefers keeping memory automatic and bounded.".to_string(),
source_revision: 1,
created_at: 1,
metadata_json: "{}".to_string(),
source: Some("seed".to_string()),
pinned: false,
score: None,
}])
.expect("seed agent record");
agent
.send(vec![ContentBlock::Text {
text: "Help me design memory".to_string(),
}])
.await
.expect("run");
let requests = provider_handle.recorded_requests().await;
assert_eq!(requests.len(), 1);
assert!(requests[0].messages.iter().any(|message| {
message_text(message).contains("<recalled-memory>")
&& message_text(message).contains("memory automatic and bounded")
}));
assert!(
agent
.history()
.iter()
.all(|message| { !message_text(message).contains("<recalled-memory>") })
);
}
#[tokio::test]
async fn successful_runs_are_ingested_and_searchable() {
let store = test_store("memory-ingest");
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![text_stream(&model.id, "finished task")],
);
let runtime = Runtime::empty_builder()
.with_store(store.clone())
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let agent_id = agent.id().to_string();
agent
.send(vec![ContentBlock::Text {
text: "remember this plan".to_string(),
}])
.await
.expect("run");
let records = wait_for_records(&store, &agent_id, "remember", 1).await;
assert_eq!(records.len(), 1);
assert_eq!(records[0].kind, MemoryRecordKind::Episode);
assert!(records[0].content.contains("remember this plan"));
assert!(records[0].content.contains("finished task"));
}
#[tokio::test]
async fn sqlite_memory_search_is_namespaced_per_agent() {
let store = test_store("memory-isolation");
store
.upsert_records(&[
MemoryRecord {
record_id: "episode:a:1".to_string(),
agent_id: "agent-a".to_string(),
kind: MemoryRecordKind::Episode,
content: "shared phrase alpha".to_string(),
source_revision: 1,
created_at: 1,
metadata_json: "{}".to_string(),
source: Some("seed".to_string()),
pinned: false,
score: None,
},
MemoryRecord {
record_id: "episode:b:1".to_string(),
agent_id: "agent-b".to_string(),
kind: MemoryRecordKind::Episode,
content: "shared phrase alpha".to_string(),
source_revision: 1,
created_at: 1,
metadata_json: "{}".to_string(),
source: Some("seed".to_string()),
pinned: false,
score: None,
},
])
.expect("seed records");
let agent_a = store
.search_records("agent-a", "shared alpha", 10)
.expect("search agent a");
let agent_b = store
.search_records("agent-b", "shared alpha", 10)
.expect("search agent b");
assert_eq!(agent_a.len(), 1);
assert_eq!(agent_b.len(), 1);
assert_eq!(agent_a[0].agent_id, "agent-a");
assert_eq!(agent_b[0].agent_id, "agent-b");
}
#[tokio::test]
async fn compacted_summaries_are_searchable() {
let store = test_store("memory-compact");
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(&model.id, "compact-1", "compact", "{}"),
text_stream(&model.id, "summary about architecture"),
text_stream(&model.id, "after compact"),
],
);
let runtime = Runtime::builder()
.with_store(store.clone())
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime
.spawn_with_config(
"agent",
model,
AgentConfig {
compaction: CompactionConfig {
auto_compact_threshold_tokens: None,
transcript_dir: temp_dir("searchable-compact"),
..Default::default()
},
..Default::default()
},
)
.expect("spawn agent");
let agent_id = agent.id().to_string();
agent
.send(vec![ContentBlock::Text {
text: "please compact".to_string(),
}])
.await
.expect("run");
let records = store
.search_records(&agent_id, "architecture", 10)
.expect("search summaries");
assert!(records.iter().any(|record| {
record.kind == MemoryRecordKind::Summary
&& record.content.contains("summary about architecture")
}));
}
#[tokio::test]
async fn hybrid_memory_recall_includes_provenance_in_hidden_context() {
let store = hybrid_store("recalled-memory-provenance");
let model = model_info("model", BuiltinProvider::Anthropic);
store
.upsert_records(&[MemoryRecord {
record_id: "fact:agent:1".to_string(),
agent_id: "agent-1".to_string(),
kind: MemoryRecordKind::Fact,
content: "The user prefers concise memory summaries.".to_string(),
source_revision: 1,
created_at: 1,
metadata_json: "{}".to_string(),
source: Some("manual_pin".to_string()),
pinned: true,
score: None,
}])
.expect("seed records");
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![text_stream(&model.id, "done")],
);
let provider_handle = provider.clone();
let runtime = Runtime::builder()
.with_store(store.clone())
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let agent_id = agent.id().to_string();
store
.upsert_records(&[MemoryRecord {
record_id: format!("fact:{agent_id}:1"),
agent_id: agent_id.clone(),
kind: MemoryRecordKind::Fact,
content: "The user prefers concise memory summaries.".to_string(),
source_revision: 1,
created_at: 1,
metadata_json: "{}".to_string(),
source: Some("manual_pin".to_string()),
pinned: true,
score: None,
}])
.expect("seed agent record");
agent
.send(vec![ContentBlock::Text {
text: "Design the memory flow".to_string(),
}])
.await
.expect("run");
let requests = provider_handle.recorded_requests().await;
let injected = requests[0]
.messages
.iter()
.find_map(|message| {
let text = message_text(message);
text.contains("<recalled-memory>")
.then_some(text.to_string())
})
.expect("recalled memory");
assert!(injected.contains("source=manual_pin"));
assert!(injected.contains("why="));
}
#[tokio::test]
async fn memory_pin_tool_creates_searchable_hybrid_memory() {
let store = hybrid_store("memory-pin-tool");
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(
&model.id,
"memory-pin-1",
"memory_pin",
r#"{"content":"Remember that the user likes short answers."}"#,
),
text_stream(&model.id, "pinned"),
],
);
let runtime = Runtime::builder()
.with_store(store.clone())
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime
.spawn_with_config(
"agent",
model,
AgentConfig {
memory: MemoryConfig {
write_tools_enabled: true,
..Default::default()
},
..Default::default()
},
)
.expect("spawn agent");
let agent_id = agent.id().to_string();
agent
.send(vec![ContentBlock::Text {
text: "Please remember this.".to_string(),
}])
.await
.expect("run");
let records = wait_for_records(&store, &agent_id, "short answers", 1).await;
assert!(records.iter().any(|record| {
record.kind == MemoryRecordKind::Fact
&& record.pinned
&& record.source.as_deref() == Some("manual_pin")
}));
}
#[tokio::test]
async fn memory_forget_tool_hides_pinned_hybrid_memory() {
let store = hybrid_store("memory-forget-tool");
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(
&model.id,
"memory-forget-1",
"memory_forget",
r#"{"record_id":"fact:forget:1"}"#,
),
text_stream(&model.id, "forgot"),
],
);
let runtime = Runtime::builder()
.with_store(store.clone())
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime
.spawn_with_config(
"agent",
model,
AgentConfig {
memory: MemoryConfig {
write_tools_enabled: true,
..Default::default()
},
..Default::default()
},
)
.expect("spawn agent");
let agent_id = agent.id().to_string();
store
.upsert_records(&[MemoryRecord {
record_id: "fact:forget:1".to_string(),
agent_id: agent_id.clone(),
kind: MemoryRecordKind::Fact,
content: "The user likes short answers.".to_string(),
source_revision: 1,
created_at: 1,
metadata_json: "{}".to_string(),
source: Some("manual_pin".to_string()),
pinned: true,
score: None,
}])
.expect("seed records");
agent
.send(vec![ContentBlock::Text {
text: "Forget that note.".to_string(),
}])
.await
.expect("run");
let records = store
.search_records(&agent_id, "short answers", 10)
.expect("search records");
assert!(records.is_empty());
}
async fn wait_for_records(
store: &impl MemoryStore,
agent_id: &str,
query: &str,
expected: usize,
) -> Vec<MemoryRecord> {
for _ in 0..50 {
let records = store
.search_records(agent_id, query, 10)
.expect("search records");
if records.len() >= expected {
return records;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
store
.search_records(agent_id, query, 10)
.expect("final search")
}
fn test_store(prefix: &str) -> SqliteRuntimeStore {
SqliteRuntimeStore::new(temp_dir(prefix).join("runtime.sqlite"))
}
fn hybrid_store(prefix: &str) -> HybridRuntimeStore {
let dir = temp_dir(prefix);
HybridRuntimeStore::with_memory_path(dir.join("runtime.sqlite"), dir.join("memory.sqlite"))
}
fn temp_dir(prefix: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time")
.as_nanos();
std::env::temp_dir().join(format!("mentra-{prefix}-{nanos}"))
}
fn text_stream(model: &str, text: &str) -> StreamScript {
ok_stream(vec![
ProviderEvent::MessageStarted {
id: format!("msg-{text}"),
model: model.to_string(),
role: Role::Assistant,
},
ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::Text,
},
ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::Text(text.to_string()),
},
ProviderEvent::ContentBlockStopped { index: 0 },
ProviderEvent::MessageStopped,
])
}
fn tool_use_stream(model: &str, id: &str, name: &str, input_json: &str) -> StreamScript {
ok_stream(vec![
ProviderEvent::MessageStarted {
id: format!("msg-{id}"),
model: model.to_string(),
role: Role::Assistant,
},
ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::ToolUse {
id: id.to_string(),
name: name.to_string(),
},
},
ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::ToolUseInputJson(input_json.to_string()),
},
ProviderEvent::ContentBlockStopped { index: 0 },
ProviderEvent::MessageStopped,
])
}
fn message_text(message: &Message) -> &str {
message
.content
.iter()
.find_map(|block| match block {
ContentBlock::Text { text } => Some(text.as_str()),
_ => None,
})
.unwrap_or("")
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,385 @@
use std::{
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
use tokio::{
sync::watch,
time::{Duration, timeout},
};
use crate::{
AgentConfig, BackgroundTaskStatus, BuiltinProvider, ContentBlock, Role,
agent::{AgentSnapshot, AgentStatus, TeamAutonomyConfig, TeamConfig},
provider::{ContentBlockDelta, ContentBlockStart, ProviderEvent},
runtime::{Runtime, RuntimePolicy, SqliteRuntimeStore},
};
use super::support::{
ScriptedProvider, background_success_command, command_input_json, controlled_stream,
model_info, ok_stream, text_stream,
};
#[tokio::test]
async fn owned_waits_coexist_with_mutable_runs_and_track_run_generation() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
text_stream(&model.id, "first"),
text_stream(&model.id, "second"),
],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let first_idle = agent.wait_until_idle();
let first_finished = agent.wait_for_snapshot(|snapshot| {
snapshot.run_generation == 1 && snapshot.status == AgentStatus::Finished
});
let (first_snapshot, predicate_snapshot, first_result) = tokio::join!(
first_idle,
first_finished,
agent.send(vec![ContentBlock::text("first run")])
);
assert_eq!(first_result.expect("first run").text(), "first");
assert_eq!(first_snapshot.run_generation, 1);
assert_eq!(predicate_snapshot.run_generation, 1);
assert_eq!(first_snapshot.status, AgentStatus::Finished);
// Constructed while the previous generation is terminal: this must wait
// for generation 2 rather than immediately returning generation 1.
let second_idle = agent.wait_until_idle();
let (second_snapshot, second_result) = tokio::join!(
second_idle,
agent.send(vec![ContentBlock::text("second run")])
);
assert_eq!(second_result.expect("second run").text(), "second");
assert_eq!(second_snapshot.run_generation, 2);
assert_eq!(second_snapshot.status, AgentStatus::Finished);
}
#[tokio::test]
async fn wait_handle_targets_the_generation_active_when_the_future_is_created() {
let model = model_info("model", BuiltinProvider::Anthropic);
let (script, tx) = controlled_stream();
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![script],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let agent = runtime.spawn("agent", model.clone()).expect("spawn agent");
let waits = agent.wait_handle();
let mut snapshots = agent.watch_snapshot();
let send_task = tokio::spawn(async move {
let mut agent = agent;
agent.send(vec![ContentBlock::text("start")]).await
});
wait_for_status(&mut snapshots, AgentStatus::Streaming).await;
assert_eq!(snapshots.borrow().run_generation, 1);
let idle = waits.wait_until_idle();
let finished = waits.wait_for_snapshot(|snapshot| {
snapshot.run_generation == 1 && snapshot.status == AgentStatus::Finished
});
for event in [
ProviderEvent::MessageStarted {
id: "msg-active-wait".to_string(),
model: model.id,
role: Role::Assistant,
},
ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::Text,
},
ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::Text("done".to_string()),
},
ProviderEvent::ContentBlockStopped { index: 0 },
ProviderEvent::MessageStopped,
] {
tx.send(Ok(event)).expect("stream receiver remains alive");
}
drop(tx);
let (idle, finished, result) = tokio::join!(
timeout(Duration::from_secs(5), idle),
timeout(Duration::from_secs(5), finished),
send_task,
);
let idle = idle.expect("idle wait timed out");
let finished = finished.expect("snapshot wait timed out");
assert_eq!(
result
.expect("send task joins")
.expect("run succeeds")
.text(),
"done"
);
assert_eq!(idle.run_generation, 1);
assert_eq!(idle.status, AgentStatus::Finished);
assert_eq!(finished.run_generation, 1);
assert_eq!(finished.status, AgentStatus::Finished);
}
#[tokio::test]
async fn teammate_reply_wait_consumes_the_snapshot_signaled_inbox() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(BuiltinProvider::Anthropic, vec![model.clone()], vec![]);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let team_dir = std::env::temp_dir().join(format!(
"mentra-wait-team-{}-{}",
std::process::id(),
NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed)
));
let config = AgentConfig {
team: TeamConfig {
team_dir,
autonomy: TeamAutonomyConfig::default(),
},
..AgentConfig::default()
};
let alice = runtime
.spawn_with_config("alice", model.clone(), config.clone())
.expect("spawn alice");
let bob = runtime
.spawn_with_config("bob", model, config)
.expect("spawn bob");
let waits = bob.wait_handle();
let reply = bob.wait_for_teammate_reply();
alice
.send_team_message("bob", "the review is ready")
.expect("send reply");
let messages = timeout(Duration::from_secs(5), reply)
.await
.expect("reply wait timed out")
.expect("read reply");
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].sender, "alice");
assert_eq!(messages[0].content, "the review is ready");
assert_eq!(bob.watch_snapshot().borrow().pending_team_messages, 0);
let reply = waits.wait_for_teammate_reply();
alice
.send_team_message("bob", "the follow-up review is ready")
.expect("send second reply");
let messages = timeout(Duration::from_secs(5), reply)
.await
.expect("handle reply wait timed out")
.expect("read second reply");
assert_eq!(messages.len(), 1);
assert_eq!(messages[0].sender, "alice");
assert_eq!(messages[0].content, "the follow-up review is ready");
assert_eq!(bob.watch_snapshot().borrow().pending_team_messages, 0);
}
#[tokio::test]
async fn snapshot_progresses_during_streaming() {
let model = model_info("model", BuiltinProvider::Anthropic);
let (script, tx) = controlled_stream();
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![script],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let agent = runtime.spawn("agent", model.clone()).unwrap();
let mut snapshot = agent.watch_snapshot();
let send_task = tokio::spawn(async move {
let mut agent = agent;
let result = agent
.send(vec![ContentBlock::Text {
text: "hello".to_string(),
}])
.await;
(agent, result)
});
wait_for_status(&mut snapshot, AgentStatus::Streaming).await;
tx.send(Ok(ProviderEvent::MessageStarted {
id: "msg-1".to_string(),
model: model.id,
role: Role::Assistant,
}))
.unwrap();
snapshot.changed().await.unwrap();
tx.send(Ok(ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::Text,
}))
.unwrap();
snapshot.changed().await.unwrap();
tx.send(Ok(ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::Text("Hel".to_string()),
}))
.unwrap();
snapshot.changed().await.unwrap();
assert_eq!(snapshot.borrow().current_text, "Hel");
tx.send(Ok(ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::Text("lo".to_string()),
}))
.unwrap();
snapshot.changed().await.unwrap();
assert_eq!(snapshot.borrow().current_text, "Hello");
tx.send(Ok(ProviderEvent::ContentBlockStopped { index: 0 }))
.unwrap();
tx.send(Ok(ProviderEvent::MessageStopped)).unwrap();
drop(tx);
let (agent, result) = send_task.await.unwrap();
result.unwrap();
let snapshot = agent.watch_snapshot();
assert_eq!(snapshot.borrow().status, AgentStatus::Finished);
assert!(snapshot.borrow().current_text.is_empty());
assert!(snapshot.borrow().pending_tool_uses.is_empty());
}
#[tokio::test]
async fn snapshot_updates_when_background_task_finishes() {
let command = background_success_command("bg-done", 50);
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
ok_stream(vec![
ProviderEvent::MessageStarted {
id: "msg-bg".to_string(),
model: model.id.clone(),
role: Role::Assistant,
},
ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::ToolUse {
id: "tool-bg".to_string(),
name: "background_run".to_string(),
},
},
ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::ToolUseInputJson(command_input_json(&command)),
},
ProviderEvent::ContentBlockStopped { index: 0 },
ProviderEvent::MessageStopped,
]),
ok_stream(vec![
ProviderEvent::MessageStarted {
id: "msg-follow".to_string(),
model: model.id.clone(),
role: Role::Assistant,
},
ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::Text,
},
ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::Text("continued".to_string()),
},
ProviderEvent::ContentBlockStopped { index: 0 },
ProviderEvent::MessageStopped,
]),
],
);
let runtime = Runtime::builder()
.with_store(temp_store("snapshot-background-finish"))
.with_policy(RuntimePolicy::permissive())
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).unwrap();
let mut snapshot = agent.watch_snapshot();
agent
.send(vec![ContentBlock::Text {
text: "run background command".to_string(),
}])
.await
.unwrap();
wait_for_background_status(&mut snapshot, BackgroundTaskStatus::Finished).await;
assert_eq!(snapshot.borrow().background_tasks.len(), 1);
assert!(
snapshot.borrow().background_tasks[0]
.output_preview
.as_deref()
.is_some_and(|preview| preview.contains("bg-done"))
);
}
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(1);
fn temp_store(label: &str) -> SqliteRuntimeStore {
let unique = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
SqliteRuntimeStore::new(std::env::temp_dir().join(format!(
"mentra-runtime-store-{label}-{timestamp}-{unique}.sqlite"
)))
}
async fn wait_for_status(receiver: &mut watch::Receiver<AgentSnapshot>, status: AgentStatus) {
timeout(Duration::from_secs(90), async {
loop {
if receiver.borrow().status == status {
return;
}
receiver.changed().await.unwrap();
}
})
.await
.unwrap_or_else(|_| panic!("timed out waiting for agent status {status:?}"));
}
async fn wait_for_background_status(
receiver: &mut watch::Receiver<AgentSnapshot>,
status: BackgroundTaskStatus,
) {
timeout(Duration::from_secs(90), async {
loop {
if receiver
.borrow()
.background_tasks
.iter()
.any(|task| task.status == status)
{
return;
}
receiver.changed().await.unwrap();
}
})
.await
.unwrap_or_else(|_| panic!("timed out waiting for background status {status:?}"));
}

View File

@@ -0,0 +1,502 @@
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
time::{SystemTime, UNIX_EPOCH},
};
use crate::{
BuiltinProvider, ContentBlock, Message, Role,
agent::{AgentConfig, CompactionConfig, TaskConfig},
provider::{ContentBlockDelta, ContentBlockStart, ProviderError, ProviderEvent},
runtime::{
NewTask, Runtime, SqliteRuntimeStore, TaskItem, TaskPatch, TaskStatus, TaskStore,
task::TASK_REMINDER_TEXT,
},
};
use super::super::TeammateIdentity;
use super::support::{ScriptedProvider, erroring_stream, model_info, ok_stream};
#[test]
fn task_board_exposes_typed_lead_operations_and_agent_namespace() {
let model = model_info("model", BuiltinProvider::Anthropic);
let tasks_dir = temp_tasks_dir("typed-board");
let runtime = Runtime::builder()
.with_provider_instance(ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![],
))
.build()
.expect("build runtime");
let board = runtime.task_board(&tasks_dir);
let first = board
.create(NewTask {
owner: "alice".to_string(),
..NewTask::new("design")
})
.expect("create first task");
let second = board
.create(NewTask {
working_directory: Some("workspace".to_string()),
..NewTask::new("implement")
})
.expect("create second task");
assert_eq!((first.id, second.id), (1, 2));
let dependent = board
.add_dependency(first.id, second.id)
.expect("add dependency");
assert_eq!(dependent.blocked_by, vec![first.id]);
assert_eq!(
board.get(first.id).expect("get first").blocks,
vec![second.id]
);
assert_eq!(board.list().expect("list tasks").len(), 2);
let unblocked = board
.remove_dependency(first.id, second.id)
.expect("remove dependency");
assert!(unblocked.blocked_by.is_empty());
let updated = board
.update(
second.id,
TaskPatch {
status: Some(TaskStatus::InProgress),
working_directory: Some(None),
..TaskPatch::default()
},
)
.expect("update task");
assert_eq!(updated.status, TaskStatus::InProgress);
assert_eq!(updated.working_directory, None);
let deserialized_patch: TaskPatch = serde_json::from_value(serde_json::json!({
"workingDirectory": null
}))
.expect("deserialize explicit task working-directory clear");
assert_eq!(deserialized_patch.working_directory, Some(None));
let agent = runtime
.spawn_with_config("lead", model, task_config(tasks_dir))
.expect("spawn agent");
assert_eq!(
agent.task_board().list().expect("agent board list").len(),
2
);
}
#[test]
fn task_board_preserves_teammate_access_and_explicit_lead_claimant() {
let model = model_info("model", BuiltinProvider::Anthropic);
let tasks_dir = temp_tasks_dir("typed-board-access");
let runtime = Runtime::builder()
.with_provider_instance(ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![],
))
.build()
.expect("build runtime");
let lead_board = runtime.task_board(&tasks_dir);
let alice_task = lead_board
.create(NewTask {
owner: "alice".to_string(),
..NewTask::new("alice-owned")
})
.expect("create alice task");
let bob_task = lead_board
.create(NewTask {
owner: "bob".to_string(),
..NewTask::new("bob-owned")
})
.expect("create bob task");
let unowned = lead_board
.create(NewTask::new("claimable"))
.expect("create claimable task");
let mut alice = runtime
.spawn_with_config("alice", model, task_config(tasks_dir))
.expect("spawn alice");
alice.teammate_identity = Some(TeammateIdentity {
role: "worker".to_string(),
lead: "lead".to_string(),
});
let alice_board = alice.task_board();
alice_board
.update(
alice_task.id,
TaskPatch {
description: Some("mine".to_string()),
..TaskPatch::default()
},
)
.expect("update own task");
assert!(
alice_board
.update(
bob_task.id,
TaskPatch {
description: Some("not mine".to_string()),
..TaskPatch::default()
},
)
.expect_err("cannot update another teammate task")
.to_string()
.contains("cannot update")
);
assert!(
alice_board
.add_dependency(alice_task.id, bob_task.id)
.expect_err("teammate cannot edit dependencies")
.to_string()
.contains("cannot update")
);
assert!(
alice_board
.claim(Some(unowned.id), "bob")
.expect_err("teammate cannot pose as bob")
.to_string()
.contains("cannot claim a task for")
);
let claimed = lead_board
.claim(Some(unowned.id), "carol")
.expect("lead board uses explicit claimant");
assert_eq!(claimed.owner, "carol");
}
#[tokio::test]
async fn task_updates_snapshot_and_persists_for_new_agents() {
let model = model_info("model", BuiltinProvider::Anthropic);
let tasks_dir = temp_tasks_dir("persist");
let store = temp_store("persist");
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
task_tool_stream(
"tool-1",
"task_create",
r#"{"subject":"Plan work","owner":"agent-a"}"#,
),
text_stream("created"),
],
);
let runtime = Runtime::builder()
.with_store(store.clone())
.with_provider_instance(provider)
.build()
.expect("build runtime");
let config = task_config(tasks_dir.clone());
let mut agent = runtime
.spawn_with_config("agent", model.clone(), config.clone())
.expect("spawn agent");
agent
.send(vec![ContentBlock::Text {
text: "start".to_string(),
}])
.await
.expect("send");
assert_eq!(
agent.watch_snapshot().borrow().tasks,
vec![TaskItem {
id: 1,
subject: "Plan work".to_string(),
description: String::new(),
status: TaskStatus::Pending,
blocked_by: Vec::new(),
blocks: Vec::new(),
owner: "agent-a".to_string(),
working_directory: None,
}]
);
assert_eq!(
store
.load_tasks(tasks_dir.as_path())
.expect("load persisted tasks")
.len(),
1
);
let other_provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![text_stream("ok")],
);
let other_runtime = Runtime::builder()
.with_store(store)
.with_provider_instance(other_provider)
.build()
.expect("build runtime");
let other_agent = other_runtime
.spawn_with_config("other", model, config)
.expect("spawn other agent");
assert_eq!(other_agent.watch_snapshot().borrow().tasks.len(), 1);
}
#[tokio::test]
async fn task_reminder_is_injected_after_three_rounds_without_task_tools() {
let model = model_info("model", BuiltinProvider::Anthropic);
let tasks_dir = temp_tasks_dir("reminder");
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
task_tool_stream("tool-1", "task_create", r#"{"subject":"Plan work"}"#),
text_stream("created"),
text_stream("round 1"),
text_stream("round 2"),
text_stream("round 3"),
text_stream("round 4"),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime
.spawn_with_config(
"agent",
model,
AgentConfig {
system: Some("Base system prompt".to_string()),
task: TaskConfig {
tasks_dir,
reminder_threshold: 3,
},
..Default::default()
},
)
.expect("spawn agent");
agent
.send(vec![ContentBlock::Text {
text: "set task".to_string(),
}])
.await
.expect("create task");
for round in 1..=4 {
agent
.send(vec![ContentBlock::Text {
text: format!("round {round}"),
}])
.await
.expect("send round");
}
let requests = provider_handle.recorded_requests().await;
assert_eq!(requests.len(), 6);
assert_eq!(requests[0].system.as_deref(), Some("Base system prompt"));
assert_eq!(requests[3].system.as_deref(), Some("Base system prompt"));
let expected_system = format!("{TASK_REMINDER_TEXT}\n\nBase system prompt");
assert_eq!(
requests[4].system.as_deref(),
Some(expected_system.as_str())
);
assert_eq!(
requests[5].system.as_deref(),
Some(expected_system.as_str())
);
}
#[tokio::test]
async fn task_state_rolls_back_when_run_fails() {
let model = model_info("model", BuiltinProvider::Anthropic);
let tasks_dir = temp_tasks_dir("rollback");
let store = temp_store("rollback");
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
task_tool_stream("tool-1", "task_create", r#"{"subject":"Plan work"}"#),
erroring_stream(
vec![ProviderEvent::MessageStarted {
id: "msg-fail".to_string(),
model: model.id.clone(),
role: Role::Assistant,
}],
ProviderError::MalformedStream("boom".to_string()),
),
],
);
let runtime = Runtime::builder()
.with_store(store.clone())
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime
.spawn_with_config("agent", model, task_config(tasks_dir.clone()))
.expect("spawn agent");
let result = agent
.send(vec![ContentBlock::Text {
text: "create task".to_string(),
}])
.await;
assert!(result.is_err());
assert!(agent.history().is_empty());
assert!(agent.watch_snapshot().borrow().tasks.is_empty());
assert!(
store
.load_tasks(tasks_dir.as_path())
.expect("load rolled-back tasks")
.is_empty()
);
}
#[tokio::test]
async fn task_survives_auto_compaction() {
let model = model_info("model", BuiltinProvider::Anthropic);
let tasks_dir = temp_tasks_dir("compact");
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
task_tool_stream("tool-1", "task_create", r#"{"subject":"Plan work"}"#),
text_stream("created"),
text_stream("summary"),
text_stream("after compact"),
],
);
let runtime = Runtime::builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime
.spawn_with_config(
"agent",
model,
AgentConfig {
task: TaskConfig {
tasks_dir,
reminder_threshold: 3,
},
compaction: CompactionConfig {
auto_compact_threshold_tokens: Some(500),
..CompactionConfig::default()
},
..Default::default()
},
)
.expect("spawn agent");
agent
.send(vec![ContentBlock::Text {
text: "create task".to_string(),
}])
.await
.expect("create task");
agent
.send(vec![ContentBlock::Text {
text: "trigger compact ".repeat(100),
}])
.await
.expect("trigger compact");
assert_eq!(agent.watch_snapshot().borrow().tasks.len(), 1);
assert!(agent.history().iter().any(|message| {
matches!(
message,
Message {
role: Role::User,
content,
} if matches!(content.first(), Some(ContentBlock::Text { text }) if text.contains("[Compaction summary]"))
)
}));
}
fn task_config(tasks_dir: PathBuf) -> AgentConfig {
AgentConfig {
task: TaskConfig {
tasks_dir,
reminder_threshold: 3,
},
..Default::default()
}
}
fn task_tool_stream(
tool_id: &str,
tool_name: &str,
input_json: &str,
) -> super::support::StreamScript {
ok_stream(vec![
ProviderEvent::MessageStarted {
id: format!("msg-{tool_id}"),
model: "model".to_string(),
role: Role::Assistant,
},
ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::ToolUse {
id: tool_id.to_string(),
name: tool_name.to_string(),
},
},
ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::ToolUseInputJson(input_json.to_string()),
},
ProviderEvent::ContentBlockStopped { index: 0 },
ProviderEvent::MessageStopped,
])
}
fn text_stream(text: &str) -> super::support::StreamScript {
ok_stream(vec![
ProviderEvent::MessageStarted {
id: format!("msg-{text}"),
model: "model".to_string(),
role: Role::Assistant,
},
ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::Text,
},
ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::Text(text.to_string()),
},
ProviderEvent::ContentBlockStopped { index: 0 },
ProviderEvent::MessageStopped,
])
}
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(1);
fn temp_tasks_dir(label: &str) -> PathBuf {
let unique = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
let path =
std::env::temp_dir().join(format!("mentra-task-runtime-{label}-{timestamp}-{unique}"));
fs::create_dir_all(&path).expect("create temp dir");
path
}
fn temp_store(label: &str) -> SqliteRuntimeStore {
let unique = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
SqliteRuntimeStore::new(std::env::temp_dir().join(format!(
"mentra-task-runtime-store-{label}-{timestamp}-{unique}.sqlite"
)))
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,427 @@
//! Integration coverage for the volatile, no-durable-trace `RuntimeStore`
//! profile (`VolatileRuntimeStore`) against a full `Agent::run` (via
//! `Agent::send`), not just the store's own unit tests.
use std::{
fs,
path::PathBuf,
sync::atomic::{AtomicU64, Ordering},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use crate::{
BuiltinProvider, ContentBlock, Role,
agent::{AgentConfig, CompactionConfig, TaskConfig, TeamConfig},
memory::MemoryStore,
provider::{ContentBlockDelta, ContentBlockStart, ProviderEvent},
runtime::{AgentStore, Runtime, RuntimePolicy, TaskStore, VolatileRuntimeStore},
};
use super::support::{ScriptedProvider, StaticTool, model_info, ok_stream};
#[tokio::test]
async fn volatile_run_leaves_no_durable_trace_on_disk() {
let model = model_info("model", BuiltinProvider::Anthropic);
let tasks_dir = temp_path("volatile-notrace-tasks");
let team_dir = temp_path("volatile-notrace-team");
let transcript_dir = temp_path("volatile-notrace-transcripts");
let store = VolatileRuntimeStore::new();
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
task_tool_stream("tool-1", "task_create", r#"{"subject":"write the report"}"#),
text_stream("report task created"),
],
);
let runtime = Runtime::builder()
.with_store(store.clone())
.with_provider_instance(provider)
.build()
.expect("build runtime");
let config = volatile_config(tasks_dir.clone(), team_dir.clone(), transcript_dir.clone());
let mut agent = runtime
.spawn_with_config("primary", model.clone(), config)
.expect("spawn agent");
agent
.send(vec![ContentBlock::Text {
text: "start".to_string(),
}])
.await
.expect("run completes");
let agent_id = agent.id().to_string();
// Give the detached post-run memory-ingest task a chance to run before
// asserting on the filesystem — it must not create anything either.
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(
!tasks_dir.exists(),
"tasks_dir must never be created by the volatile profile"
);
assert!(
!team_dir.exists(),
"team_dir must never be created by the volatile profile"
);
assert!(
!transcript_dir.exists(),
"transcript_dir must never be created by the volatile profile"
);
// The run's effects are real, just in-memory: the tool call landed in
// the retained store, and ingest wrote the episode into it too.
assert_eq!(
store
.load_tasks(&tasks_dir)
.expect("load tasks")
.into_iter()
.map(|task| task.subject)
.collect::<Vec<_>>(),
vec!["write the report".to_string()]
);
assert!(
!store
.search_records(&agent_id, "report", 10)
.expect("search ingested memory")
.is_empty(),
"the detached memory-ingest task should have written into the volatile store"
);
}
#[tokio::test]
async fn volatile_store_truncates_without_creating_spill_artifacts() {
let model = model_info("model", BuiltinProvider::Anthropic);
let tasks_dir = temp_path("volatile-truncation-tasks");
let team_dir = temp_path("volatile-truncation-team");
let transcript_dir = temp_path("volatile-truncation-transcripts");
let spill_dir = transcript_dir.join("tool-output");
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
task_tool_stream("tool-output", "oversized_output", r#"{}"#),
text_stream("done"),
],
);
let runtime = Runtime::empty_builder()
.with_store(VolatileRuntimeStore::new())
.with_provider_instance(provider)
.with_policy(
RuntimePolicy::default()
.with_max_tool_result_bytes(usize::MAX)
.with_max_tool_result_lines(1),
)
.with_tool(StaticTool::success("oversized_output", "one\ntwo\nthree"))
.build()
.expect("build runtime");
let config = volatile_config(tasks_dir.clone(), team_dir.clone(), transcript_dir.clone());
let mut agent = runtime
.spawn_with_config("primary", model, config)
.expect("spawn agent");
agent
.send(vec![ContentBlock::Text {
text: "run the oversized tool".to_string(),
}])
.await
.expect("run completes");
let content = match agent.history()[2].content.first().expect("tool result") {
ContentBlock::ToolResult {
content, is_error, ..
} => {
assert!(!is_error);
content.to_display_string()
}
other => panic!("unexpected content block: {other:?}"),
};
let tasks_dir_exists = tasks_dir.exists();
let team_dir_exists = team_dir.exists();
let transcript_dir_exists = transcript_dir.exists();
let spill_dir_exists = spill_dir.exists();
for path in [&tasks_dir, &team_dir, &transcript_dir] {
if path.exists() {
fs::remove_dir_all(path).expect("remove unexpected volatile artifact directory");
}
}
assert_eq!(
content,
"one\n[truncated: showing 1 of 3 lines; full output was not saved because the runtime store forbids durable artifacts]"
);
assert!(!tasks_dir_exists, "volatile task artifacts must not exist");
assert!(!team_dir_exists, "volatile team artifacts must not exist");
assert!(
!transcript_dir_exists,
"volatile transcript artifacts must not exist"
);
assert!(!spill_dir_exists, "volatile spill artifacts must not exist");
}
#[tokio::test]
async fn sequential_runs_on_retained_store_do_not_leak_records() {
let model = model_info("model", BuiltinProvider::Anthropic);
let tasks_dir = temp_path("volatile-isolation-tasks");
let team_dir = temp_path("volatile-isolation-team");
let transcript_dir = temp_path("volatile-isolation-transcripts");
let store = VolatileRuntimeStore::new();
let config = volatile_config(tasks_dir.clone(), team_dir.clone(), transcript_dir.clone());
// --- Run 1: same team_dir/tasks_dir/agent name as run 2 below, which is
// exactly the shared-default scenario the volatile profile's isolation
// contract has to defend against on a retained store. ---
let provider_1 = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
task_tool_stream("tool-1", "task_create", r#"{"subject":"first run task"}"#),
text_stream("first run complete"),
],
);
let runtime_1 = Runtime::builder()
.with_store(store.clone())
.with_provider_instance(provider_1)
.build()
.expect("build runtime 1");
let mut agent_1 = runtime_1
.spawn_with_config("primary", model.clone(), config.clone())
.expect("spawn agent 1");
agent_1
.send(vec![ContentBlock::Text {
text: "go".to_string(),
}])
.await
.expect("run 1 completes");
let agent_1_id = agent_1.id().to_string();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(
store.list_agents().expect("list agents after run 1").len(),
1
);
assert_eq!(
store
.load_tasks(&tasks_dir)
.expect("tasks after run 1")
.len(),
1
);
// Explicit isolation seam: reset the retained store between runs.
store.reset();
// --- Run 2: a fresh agent (fresh id, same name/dirs) must observe none
// of run 1's records through the same retained store instance. ---
let provider_2 = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![text_stream("second run complete")],
);
let runtime_2 = Runtime::builder()
.with_store(store.clone())
.with_provider_instance(provider_2)
.build()
.expect("build runtime 2");
let mut agent_2 = runtime_2
.spawn_with_config("primary", model.clone(), config)
.expect("spawn agent 2");
agent_2
.send(vec![ContentBlock::Text {
text: "go".to_string(),
}])
.await
.expect("run 2 completes");
let agent_2_id = agent_2.id().to_string();
tokio::time::sleep(Duration::from_millis(50)).await;
assert_ne!(agent_1_id, agent_2_id, "each spawn gets a fresh agent id");
let agents_after_run_2 = store.list_agents().expect("list agents after run 2");
assert_eq!(
agents_after_run_2.len(),
1,
"run 2 must not see run 1's agent record"
);
assert_eq!(agents_after_run_2[0].record.id, agent_2_id);
assert!(
store
.load_tasks(&tasks_dir)
.expect("tasks after run 2")
.is_empty(),
"run 2 must not see run 1's task, which was written under the same tasks_dir"
);
assert!(
store
.search_records(&agent_1_id, "first run", 10)
.expect("search for agent 1's memory by its own id")
.is_empty(),
"agent 1's own record disappeared with reset(), so nothing can match its id"
);
assert!(
!store
.search_records(&agent_2_id, "second run", 10)
.expect("search for agent 2's memory")
.is_empty(),
"run 2's own ingested memory is still visible to itself"
);
}
#[tokio::test]
async fn retained_store_without_reset_shares_state_across_runs() {
// Companion to `sequential_runs_on_retained_store_do_not_leak_records`:
// demonstrates that `reset()` is doing real work by showing what happens
// without it. A retained `VolatileRuntimeStore` is a shared database —
// exactly like two runs pointed at the same `SqliteRuntimeStore` path —
// when the host does not call `reset()` between runs.
let model = model_info("model", BuiltinProvider::Anthropic);
let tasks_dir = temp_path("volatile-shared-tasks");
let team_dir = temp_path("volatile-shared-team");
let transcript_dir = temp_path("volatile-shared-transcripts");
let store = VolatileRuntimeStore::new();
let config = volatile_config(tasks_dir.clone(), team_dir.clone(), transcript_dir.clone());
let provider_1 = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
task_tool_stream("tool-1", "task_create", r#"{"subject":"first run task"}"#),
text_stream("first run complete"),
],
);
let runtime_1 = Runtime::builder()
.with_store(store.clone())
.with_provider_instance(provider_1)
.build()
.expect("build runtime 1");
let mut agent_1 = runtime_1
.spawn_with_config("primary", model.clone(), config.clone())
.expect("spawn agent 1");
agent_1
.send(vec![ContentBlock::Text {
text: "go".to_string(),
}])
.await
.expect("run 1 completes");
// No reset() here.
let provider_2 = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![text_stream("second run complete")],
);
let runtime_2 = Runtime::builder()
.with_store(store.clone())
.with_provider_instance(provider_2)
.build()
.expect("build runtime 2");
let mut agent_2 = runtime_2
.spawn_with_config("primary", model.clone(), config)
.expect("spawn agent 2");
agent_2
.send(vec![ContentBlock::Text {
text: "go".to_string(),
}])
.await
.expect("run 2 completes");
assert_eq!(
store.list_agents().expect("list agents").len(),
2,
"without reset(), both agents' records remain in the shared store"
);
assert_eq!(
store
.load_tasks(&tasks_dir)
.expect("tasks after both runs")
.len(),
1,
"without reset(), run 1's task is still visible under the shared tasks_dir"
);
}
fn volatile_config(tasks_dir: PathBuf, team_dir: PathBuf, transcript_dir: PathBuf) -> AgentConfig {
AgentConfig {
task: TaskConfig {
tasks_dir,
reminder_threshold: 3,
},
team: TeamConfig {
team_dir,
..Default::default()
},
compaction: CompactionConfig {
transcript_dir,
..Default::default()
},
..Default::default()
}
}
fn task_tool_stream(
tool_id: &str,
tool_name: &str,
input_json: &str,
) -> super::support::StreamScript {
ok_stream(vec![
ProviderEvent::MessageStarted {
id: format!("msg-{tool_id}"),
model: "model".to_string(),
role: Role::Assistant,
},
ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::ToolUse {
id: tool_id.to_string(),
name: tool_name.to_string(),
},
},
ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::ToolUseInputJson(input_json.to_string()),
},
ProviderEvent::ContentBlockStopped { index: 0 },
ProviderEvent::MessageStopped,
])
}
fn text_stream(text: &str) -> super::support::StreamScript {
ok_stream(vec![
ProviderEvent::MessageStarted {
id: format!("msg-{text}"),
model: "model".to_string(),
role: Role::Assistant,
},
ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::Text,
},
ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::Text(text.to_string()),
},
ProviderEvent::ContentBlockStopped { index: 0 },
ProviderEvent::MessageStopped,
])
}
static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(1);
/// Builds a unique path under the system temp directory *without* creating
/// it — the whole point of these tests is to assert the volatile profile
/// never creates it either.
fn temp_path(label: &str) -> PathBuf {
let unique = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time")
.as_nanos();
std::env::temp_dir().join(format!("mentra-{label}-{timestamp}-{unique}"))
}

View File

@@ -0,0 +1,672 @@
use std::sync::{
Arc,
atomic::{AtomicUsize, Ordering},
};
use async_trait::async_trait;
use tokio::sync::mpsc;
use crate::{
BuiltinProvider, ContentBlock, Message, QueueMode, Role, RoundContext, RoundDecision,
RoundStrategy, Runtime, SteeringHandle,
provider::{ContentBlockDelta, ContentBlockStart, ProviderError, ProviderEvent},
runtime::{
CancellationToken, CommandOutput, CommandRequest, RunOptions, RuntimeExecutor,
RuntimePolicy, VolatileRuntimeStore,
},
};
use super::support::{
ScriptedProvider, StaticTool, controlled_stream, erroring_stream, model_info, text_stream,
tool_use_stream,
};
#[tokio::test]
async fn live_steer_is_visible_in_the_next_provider_request() {
let model = model_info("model", BuiltinProvider::Anthropic);
let (first_stream, first_tx) = controlled_stream();
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![first_stream, text_stream(&model.id, "final")],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model.clone()).expect("spawn agent");
let steering = agent.steering_handle();
let drive = async {
wait_for_request_count(&provider_handle, 1).await;
steering.steer(vec![ContentBlock::text("focus on the API contract")]);
send_text_response(&first_tx, &model.id, "draft");
drop(first_tx);
};
let (result, ()) = tokio::join!(
agent.run(vec![ContentBlock::text("start")], RunOptions::default()),
drive
);
assert_eq!(result.expect("run succeeds").text(), "final");
let requests = provider_handle.recorded_requests().await;
assert_eq!(requests.len(), 2);
assert!(request_contains(
&requests[1].messages,
"focus on the API contract"
));
}
#[tokio::test]
async fn follow_up_waits_for_the_would_stop_boundary() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
tool_use_stream(&model.id, "call-1", "probe", r#"{"value":"x"}"#),
text_stream(&model.id, "tool round complete"),
text_stream(&model.id, "follow-up complete"),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("probe", "ok"))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
agent.follow_up(vec![ContentBlock::text("now produce the appendix")]);
let result = agent
.run(vec![ContentBlock::text("start")], RunOptions::default())
.await
.expect("run succeeds");
assert_eq!(result.text(), "follow-up complete");
let requests = provider_handle.recorded_requests().await;
assert_eq!(requests.len(), 3);
assert!(!request_contains(
&requests[1].messages,
"now produce the appendix"
));
assert!(request_contains(
&requests[2].messages,
"now produce the appendix"
));
}
#[tokio::test]
async fn queue_modes_drain_one_or_all_entries_per_boundary() {
let one_at_a_time = run_with_queue_mode(QueueMode::OneAtATime).await;
assert_eq!(one_at_a_time.len(), 3);
assert!(request_contains(&one_at_a_time[1], "first steer"));
assert!(!request_contains(&one_at_a_time[1], "second steer"));
assert!(request_contains(&one_at_a_time[2], "second steer"));
let all = run_with_queue_mode(QueueMode::All).await;
assert_eq!(all.len(), 2);
assert!(request_contains(&all[1], "first steer"));
assert!(request_contains(&all[1], "second steer"));
}
#[test]
fn clear_methods_remove_only_their_pending_queue() {
assert_eq!(QueueMode::default(), QueueMode::OneAtATime);
let steering = SteeringHandle::default();
steering.steer(vec![ContentBlock::text("steer")]);
steering.follow_up(vec![ContentBlock::text("follow-up")]);
steering.clear_steer();
assert!(steering.has_pending(), "the follow-up remains queued");
steering.steer(vec![ContentBlock::text("replacement steer")]);
steering.clear_follow_up();
assert!(
steering.has_pending(),
"the replacement steer remains queued"
);
steering.clear_steer();
assert!(!steering.has_pending());
}
#[tokio::test]
async fn follow_up_all_mode_drains_every_entry_at_the_would_stop_boundary() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
text_stream(&model.id, "draft"),
text_stream(&model.id, "final"),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let steering = agent.steering_handle();
steering.set_follow_up_mode(QueueMode::All);
steering.follow_up(vec![ContentBlock::text("first follow-up")]);
steering.follow_up(vec![ContentBlock::text("second follow-up")]);
let result = agent
.send(vec![ContentBlock::text("start")])
.await
.expect("run succeeds");
assert_eq!(result.text(), "final");
let requests = provider_handle.recorded_requests().await;
assert_eq!(requests.len(), 2);
assert!(request_contains(&requests[1].messages, "first follow-up"));
assert!(request_contains(&requests[1].messages, "second follow-up"));
assert!(!steering.has_pending());
}
#[tokio::test]
async fn failed_run_requeues_steer_and_resume_reinjects_it() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
text_stream(&model.id, "first draft"),
erroring_stream(
Vec::new(),
ProviderError::MalformedStream("failed after steer".to_string()),
),
text_stream(&model.id, "retry draft"),
text_stream(&model.id, "fixed"),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let steering = agent.steering_handle();
steering.steer(vec![ContentBlock::text("repair the draft")]);
agent
.run(vec![ContentBlock::text("start")], RunOptions::default())
.await
.expect_err("second request fails");
assert!(steering.has_pending(), "failed run requeues the steer");
let result = agent.resume().await.expect("resume succeeds");
assert_eq!(result.text(), "fixed");
assert!(!steering.has_pending());
let requests = provider_handle.recorded_requests().await;
assert_eq!(requests.len(), 4);
assert!(request_contains(&requests[1].messages, "repair the draft"));
assert!(request_contains(&requests[3].messages, "repair the draft"));
}
#[tokio::test]
async fn failed_run_requeues_follow_up_and_resume_reinjects_it() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
text_stream(&model.id, "first draft"),
erroring_stream(
Vec::new(),
ProviderError::MalformedStream("failed after follow-up".to_string()),
),
text_stream(&model.id, "retry draft"),
text_stream(&model.id, "fixed"),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let steering = agent.steering_handle();
steering.follow_up(vec![ContentBlock::text("append the required evidence")]);
agent
.run(vec![ContentBlock::text("start")], RunOptions::default())
.await
.expect_err("second request fails");
assert!(steering.has_pending(), "failed run requeues the follow-up");
let result = agent.resume().await.expect("resume succeeds");
assert_eq!(result.text(), "fixed");
assert!(!steering.has_pending());
let requests = provider_handle.recorded_requests().await;
assert_eq!(requests.len(), 4);
assert!(!request_contains(
&requests[0].messages,
"append the required evidence"
));
assert!(request_contains(
&requests[1].messages,
"append the required evidence"
));
assert!(!request_contains(
&requests[2].messages,
"append the required evidence"
));
assert!(request_contains(
&requests[3].messages,
"append the required evidence"
));
assert_eq!(
agent
.history()
.iter()
.filter(|message| message.text().contains("append the required evidence"))
.count(),
1
);
}
#[tokio::test]
async fn finalization_error_requeues_steering_before_returning() {
let model = model_info("model", BuiltinProvider::Anthropic);
let (first_stream, first_tx) = controlled_stream();
let (second_stream, second_tx) = controlled_stream();
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
first_stream,
second_stream,
text_stream(&model.id, "queued steer completed"),
],
);
let provider_handle = provider.clone();
let store = VolatileRuntimeStore::new();
let runtime = Runtime::empty_builder()
.with_store(store.clone())
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model.clone()).expect("spawn agent");
let steering = agent.steering_handle();
steering.steer(vec![ContentBlock::text("preserve this steer")]);
let drive = async {
wait_for_request_count(&provider_handle, 1).await;
send_text_response(&first_tx, &model.id, "draft");
drop(first_tx);
wait_for_request_count(&provider_handle, 2).await;
store.fail_next_agent_record_save();
send_text_response(&second_tx, &model.id, "final");
drop(second_tx);
};
let (result, ()) = tokio::join!(
agent.run(vec![ContentBlock::text("start")], RunOptions::default()),
drive
);
assert!(
result
.expect_err("finalization persistence fails")
.to_string()
.contains("injected agent-record persistence failure")
);
assert!(steering.has_pending(), "finalization error requeues steer");
let recovered = agent
.run_queued(RunOptions::default())
.await
.expect("queued steer remains runnable");
assert_eq!(recovered.text(), "queued steer completed");
assert!(!steering.has_pending());
}
#[tokio::test]
async fn steering_precedes_round_strategy_without_double_injection() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
text_stream(&model.id, "draft"),
text_stream(&model.id, "steered"),
text_stream(&model.id, "strategized"),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
agent.steer(vec![ContentBlock::text("queue correction")]);
let strategy = Arc::new(InjectOnceStrategy::default());
agent
.run(
vec![ContentBlock::text("start")],
RunOptions::default().with_round_strategy(strategy.clone()),
)
.await
.expect("run succeeds");
let requests = provider_handle.recorded_requests().await;
assert_eq!(requests.len(), 3);
assert!(request_contains(&requests[1].messages, "queue correction"));
assert!(!request_contains(
&requests[1].messages,
"strategy correction"
));
assert!(request_contains(
&requests[2].messages,
"strategy correction"
));
assert_eq!(strategy.calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn next_provider_request_orders_steering_team_inbox_then_background() {
let model = model_info("model", BuiltinProvider::Anthropic);
let (first_stream, first_tx) = controlled_stream();
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![first_stream, text_stream(&model.id, "final")],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_executor(ImmediateExecutor)
.with_policy(RuntimePolicy::permissive())
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model.clone()).expect("spawn agent");
let steering = agent.steering_handle();
let runtime_handle = agent.runtime.clone();
let agent_id = agent.id.clone();
let agent_name = agent.name.clone();
let team_dir = agent.config.team.team_dir.clone();
let cwd = agent.config.workspace.base_dir.clone();
let drive = async {
wait_for_request_count(&provider_handle, 1).await;
steering.steer(vec![ContentBlock::text("ordering-steer-marker")]);
runtime_handle
.send_team_message(
&team_dir,
"host",
&agent_name,
"ordering-team-marker".to_string(),
)
.expect("enqueue team message");
runtime_handle
.start_background_task(
&agent_id,
"ordering-background-marker".to_string(),
None,
None,
cwd,
)
.expect("start background task");
while !runtime_handle.has_deliverable_background_notifications(&agent_id) {
tokio::task::yield_now().await;
}
send_text_response(&first_tx, &model.id, "draft");
drop(first_tx);
};
let (result, ()) = tokio::join!(
agent.run(vec![ContentBlock::text("start")], RunOptions::default()),
drive
);
assert_eq!(result.expect("run succeeds").text(), "final");
let requests = provider_handle.recorded_requests().await;
assert_eq!(requests.len(), 2);
let messages = requests[1].messages.as_ref();
let steer = message_index(messages, "ordering-steer-marker");
let team = message_index(messages, "ordering-team-marker");
let background = message_index(messages, "ordering-background-marker");
assert!(
steer < team && team < background,
"expected steering -> team inbox -> background, got {:?}",
messages.iter().map(Message::text).collect::<Vec<_>>()
);
}
#[tokio::test]
async fn run_queued_consumes_idle_steer_as_the_user_turn() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![text_stream(&model.id, "done")],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
agent.steer(vec![ContentBlock::text("queued while idle")]);
let result = agent
.run_queued(RunOptions::default())
.await
.expect("queued run succeeds");
assert_eq!(result.text(), "done");
let requests = provider_handle.recorded_requests().await;
assert_eq!(requests.len(), 1);
assert!(request_contains(&requests[0].messages, "queued while idle"));
}
#[tokio::test]
async fn steering_is_isolated_between_agents_on_one_runtime() {
let model = model_info("model", BuiltinProvider::Anthropic);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
text_stream(&model.id, "agent-b done"),
text_stream(&model.id, "agent-a draft"),
text_stream(&model.id, "agent-a done"),
],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent_a = runtime.spawn("agent-a", model.clone()).expect("spawn a");
let mut agent_b = runtime.spawn("agent-b", model).expect("spawn b");
agent_a.steer(vec![ContentBlock::text("only agent a sees this")]);
agent_b
.send(vec![ContentBlock::text("run b")])
.await
.expect("run b");
agent_a
.send(vec![ContentBlock::text("run a")])
.await
.expect("run a");
let requests = provider_handle.recorded_requests().await;
assert_eq!(requests.len(), 3);
assert!(!request_contains(
&requests[0].messages,
"only agent a sees this"
));
assert!(request_contains(
&requests[2].messages,
"only agent a sees this"
));
}
#[tokio::test]
async fn graceful_stop_does_not_consume_an_unrequestable_steer() {
let model = model_info("model", BuiltinProvider::Anthropic);
let (first_stream, first_tx) = controlled_stream();
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![first_stream, text_stream(&model.id, "must not run")],
);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model.clone()).expect("spawn agent");
let steering = agent.steering_handle();
steering.steer(vec![ContentBlock::text("keep for later")]);
let stop = CancellationToken::default();
let stop_driver = stop.clone();
let drive = async {
wait_for_request_count(&provider_handle, 1).await;
stop_driver.cancel();
send_text_response(&first_tx, &model.id, "finished before steer");
drop(first_tx);
};
let (result, ()) = tokio::join!(
agent.run(
vec![ContentBlock::text("start")],
RunOptions {
stop: Some(stop),
..RunOptions::default()
}
),
drive
);
assert_eq!(
result.expect("graceful stop succeeds").text(),
"finished before steer"
);
assert!(steering.has_pending());
assert_eq!(provider_handle.recorded_requests().await.len(), 1);
}
async fn run_with_queue_mode(mode: QueueMode) -> Vec<Vec<Message>> {
let model = model_info("model", BuiltinProvider::Anthropic);
let scripts = match mode {
QueueMode::OneAtATime => vec![
text_stream(&model.id, "first"),
text_stream(&model.id, "second"),
text_stream(&model.id, "third"),
],
QueueMode::All => vec![
text_stream(&model.id, "first"),
text_stream(&model.id, "second"),
],
};
let provider = ScriptedProvider::new(BuiltinProvider::Anthropic, vec![model.clone()], scripts);
let provider_handle = provider.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
let steering = agent.steering_handle();
steering.set_steer_mode(mode);
steering.steer(vec![ContentBlock::text("first steer")]);
steering.steer(vec![ContentBlock::text("second steer")]);
agent
.send(vec![ContentBlock::text("start")])
.await
.expect("run");
provider_handle
.recorded_requests()
.await
.into_iter()
.map(|request| request.messages.to_vec())
.collect()
}
#[derive(Default)]
struct InjectOnceStrategy {
calls: AtomicUsize,
}
#[async_trait]
impl RoundStrategy for InjectOnceStrategy {
async fn on_round(&self, _ctx: RoundContext<'_>) -> RoundDecision {
if self.calls.fetch_add(1, Ordering::SeqCst) == 0 {
RoundDecision::inject(vec![ContentBlock::text("strategy correction")])
} else {
RoundDecision::stop()
}
}
}
struct ImmediateExecutor;
#[async_trait]
impl RuntimeExecutor for ImmediateExecutor {
async fn run(&self, _request: CommandRequest) -> Result<CommandOutput, String> {
Ok(CommandOutput {
stdout: "ordering background output".to_string(),
stderr: String::new(),
success: true,
status_code: Some(0),
timed_out: false,
stdout_truncated: false,
stderr_truncated: false,
})
}
}
fn request_contains(messages: &[Message], needle: &str) -> bool {
messages
.iter()
.any(|message| message.text().contains(needle))
}
fn message_index(messages: &[Message], needle: &str) -> usize {
messages
.iter()
.position(|message| message.text().contains(needle))
.unwrap_or_else(|| panic!("request did not contain {needle:?}"))
}
async fn wait_for_request_count(provider: &ScriptedProvider, expected: usize) {
loop {
if provider.recorded_requests().await.len() >= expected {
return;
}
tokio::task::yield_now().await;
}
}
fn send_text_response(
tx: &mpsc::UnboundedSender<Result<ProviderEvent, ProviderError>>,
model: &str,
text: &str,
) {
let events = [
ProviderEvent::MessageStarted {
id: format!("msg-{text}"),
model: model.to_string(),
role: Role::Assistant,
},
ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::Text,
},
ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::Text(text.to_string()),
},
ProviderEvent::ContentBlockStopped { index: 0 },
ProviderEvent::MessageStopped,
];
for event in events {
tx.send(Ok(event)).expect("stream receiver remains alive");
}
}

494
vendor/mentra/src/agent/tests/support.rs vendored Normal file
View File

@@ -0,0 +1,494 @@
use std::{
collections::VecDeque,
sync::{
Arc,
atomic::{AtomicUsize, Ordering},
},
time::Duration,
};
use async_trait::async_trait;
use serde_json::{Value, json};
use tokio::{
sync::{Mutex, mpsc},
time::sleep,
};
use crate::{
Role,
provider::{
CompactionRequest, CompactionResponse, ContentBlockDelta, ContentBlockStart, ModelInfo,
Provider, ProviderCapabilities, ProviderDescriptor, ProviderError, ProviderEvent,
ProviderEventStream, ProviderId, Request,
},
tool::{
ParallelToolContext, ToolContext, ToolDefinition, ToolExecutionCategory, ToolExecutor,
ToolResult, ToolSpec,
},
};
pub(super) enum StreamScript {
Buffered(Vec<Result<ProviderEvent, ProviderError>>),
Receiver(ProviderEventStream),
}
#[derive(Clone)]
pub(super) struct ScriptedProvider {
kind: ProviderId,
models: Vec<ModelInfo>,
scripts: Arc<Mutex<VecDeque<StreamScript>>>,
requests: Arc<Mutex<Vec<Request<'static>>>>,
compact_scripts: Arc<Mutex<VecDeque<Result<CompactionResponse, ProviderError>>>>,
capabilities: ProviderCapabilities,
}
impl ScriptedProvider {
pub(super) fn new(
kind: impl Into<ProviderId>,
models: Vec<ModelInfo>,
scripts: Vec<StreamScript>,
) -> Self {
Self {
kind: kind.into(),
models,
scripts: Arc::new(Mutex::new(VecDeque::from(scripts))),
requests: Arc::new(Mutex::new(Vec::<Request<'static>>::new())),
compact_scripts: Arc::new(Mutex::new(VecDeque::new())),
capabilities: ProviderCapabilities::default(),
}
}
pub(super) async fn recorded_requests(&self) -> Vec<Request<'static>> {
self.requests.lock().await.clone()
}
pub(super) async fn push_compact_response(
&self,
response: Result<CompactionResponse, ProviderError>,
) {
self.compact_scripts.lock().await.push_back(response);
}
pub(super) fn with_capabilities(mut self, capabilities: ProviderCapabilities) -> Self {
self.capabilities = capabilities;
self
}
}
#[async_trait]
impl Provider for ScriptedProvider {
fn descriptor(&self) -> ProviderDescriptor {
ProviderDescriptor::new(self.kind.clone())
}
fn capabilities(&self) -> ProviderCapabilities {
self.capabilities
}
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
Ok(self.models.clone())
}
async fn stream(&self, request: Request<'_>) -> Result<ProviderEventStream, ProviderError> {
self.requests.lock().await.push(request.into_owned());
match self.scripts.lock().await.pop_front() {
Some(StreamScript::Buffered(items)) => {
let (tx, rx) = mpsc::unbounded_channel();
for item in items {
tx.send(item)
.expect("test stream receiver dropped unexpectedly");
}
Ok(rx)
}
Some(StreamScript::Receiver(receiver)) => Ok(receiver),
None => panic!("no scripted stream available"),
}
}
async fn compact(
&self,
_request: CompactionRequest<'_>,
) -> Result<CompactionResponse, ProviderError> {
match self.compact_scripts.lock().await.pop_front() {
Some(result) => result,
None => Err(ProviderError::UnsupportedCapability(
"history_compaction".to_string(),
)),
}
}
}
pub(super) fn model_info(id: &str, provider: impl Into<ProviderId>) -> ModelInfo {
ModelInfo::new(id, provider)
}
pub(super) fn ok_stream(events: Vec<ProviderEvent>) -> StreamScript {
StreamScript::Buffered(events.into_iter().map(Ok).collect())
}
pub(super) fn command_input_json(command: &str) -> String {
json!({ "command": command }).to_string()
}
pub(super) fn command_input_with_working_directory_json(
command: &str,
working_directory: &str,
) -> String {
json!({
"command": command,
"workingDirectory": working_directory,
})
.to_string()
}
pub(super) fn shell_pwd_command() -> String {
#[cfg(unix)]
{
"pwd".to_string()
}
#[cfg(windows)]
{
"cd".to_string()
}
}
pub(super) fn background_success_command(output: &str, delay_ms: u64) -> String {
#[cfg(unix)]
{
format!(
"sleep {}; printf {}",
delay_seconds(delay_ms),
shell_single_quoted(output)
)
}
#[cfg(windows)]
{
let delay_seconds = (delay_ms / 1000).saturating_add(1);
format!(
"ping -n {delay_seconds} 127.0.0.1 >NUL & echo {output}",
output = cmd_echo_literal(output)
)
}
}
pub(super) fn background_failure_command(stderr: &str, exit_code: i32, delay_ms: u64) -> String {
#[cfg(unix)]
{
format!(
"sleep {}; printf {} >&2; exit {exit_code}",
delay_seconds(delay_ms),
shell_single_quoted(stderr)
)
}
#[cfg(windows)]
{
let delay_seconds = (delay_ms / 1000).saturating_add(1);
format!(
"ping -n {delay_seconds} 127.0.0.1 >NUL & echo {stderr} 1>&2 & exit /b {exit_code}",
stderr = cmd_echo_literal(stderr)
)
}
}
#[cfg(unix)]
fn delay_seconds(delay_ms: u64) -> String {
format!("{:.3}", delay_ms as f64 / 1000.0)
}
#[cfg(unix)]
fn shell_single_quoted(value: &str) -> String {
format!("'{}'", value.replace('\'', r"'\''"))
}
#[cfg(windows)]
fn cmd_echo_literal(value: &str) -> String {
value
.replace('^', "^^")
.replace('&', "^&")
.replace('|', "^|")
.replace('<', "^<")
.replace('>', "^>")
}
pub(super) fn erroring_stream(events: Vec<ProviderEvent>, error: ProviderError) -> StreamScript {
let mut items = events.into_iter().map(Ok).collect::<Vec<_>>();
items.push(Err(error));
StreamScript::Buffered(items)
}
pub(super) fn controlled_stream() -> (
StreamScript,
mpsc::UnboundedSender<Result<ProviderEvent, ProviderError>>,
) {
let (tx, rx) = mpsc::unbounded_channel();
(StreamScript::Receiver(rx), tx)
}
pub(super) struct StaticTool {
name: &'static str,
result: ToolResult,
loading_policy: crate::tool::ToolLoadingPolicy,
}
impl StaticTool {
pub(super) fn success(name: &'static str, output: &str) -> Self {
Self {
name,
result: Ok(output.to_string()),
loading_policy: crate::tool::ToolLoadingPolicy::Immediate,
}
}
pub(super) fn failure(name: &'static str, error: &str) -> Self {
Self {
name,
result: Err(error.to_string()),
loading_policy: crate::tool::ToolLoadingPolicy::Immediate,
}
}
pub(super) fn deferred_success(name: &'static str, output: &str) -> Self {
Self {
name,
result: Ok(output.to_string()),
loading_policy: crate::tool::ToolLoadingPolicy::Deferred,
}
}
}
#[async_trait]
impl ToolDefinition for StaticTool {
fn descriptor(&self) -> ToolSpec {
ToolSpec::builder(self.name)
.description("test tool")
.input_schema(json!({
"type": "object",
"properties": {
"value": { "type": "string" }
}
}))
.side_effect_level(crate::tool::ToolSideEffectLevel::None)
.durability(crate::tool::ToolDurability::ReplaySafe)
.loading_policy(self.loading_policy)
.build()
}
}
#[async_trait]
impl ToolExecutor for StaticTool {
async fn execute_mut(&self, _ctx: ToolContext<'_>, _input: Value) -> ToolResult {
self.result.clone()
}
}
/// A tool that trips a graceful-stop token when executed, then succeeds — used to
/// exercise [`RunOptions::stop`] firing at the round boundary *after* a real tool
/// round, so the gathered transcript is committed rather than rolled back.
pub(super) struct StopTrippingTool {
name: &'static str,
stop: crate::runtime::CancellationToken,
}
impl StopTrippingTool {
pub(super) fn new(name: &'static str, stop: crate::runtime::CancellationToken) -> Self {
Self { name, stop }
}
}
#[async_trait]
impl ToolDefinition for StopTrippingTool {
fn descriptor(&self) -> ToolSpec {
ToolSpec::builder(self.name)
.description("test tool that requests a graceful stop")
.input_schema(json!({
"type": "object",
"properties": { "value": { "type": "string" } }
}))
.side_effect_level(crate::tool::ToolSideEffectLevel::None)
.durability(crate::tool::ToolDurability::ReplaySafe)
.loading_policy(crate::tool::ToolLoadingPolicy::Immediate)
.build()
}
}
#[async_trait]
impl ToolExecutor for StopTrippingTool {
async fn execute_mut(&self, _ctx: ToolContext<'_>, _input: Value) -> ToolResult {
self.stop.cancel();
Ok("stopped".to_string())
}
}
#[derive(Clone)]
pub(super) struct ProbeTool {
name: &'static str,
parallel: bool,
delay: Duration,
log: Arc<Mutex<Vec<String>>>,
active: Arc<AtomicUsize>,
max_active: Arc<AtomicUsize>,
}
impl ProbeTool {
pub(super) fn new(
name: &'static str,
parallel: bool,
delay: Duration,
log: Arc<Mutex<Vec<String>>>,
active: Arc<AtomicUsize>,
max_active: Arc<AtomicUsize>,
) -> Self {
Self {
name,
parallel,
delay,
log,
active,
max_active,
}
}
async fn run(&self) -> ToolResult {
self.log.lock().await.push(format!("{}:start", self.name));
let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
let _ = self
.max_active
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |current| {
(active > current).then_some(active)
});
sleep(self.delay).await;
self.active.fetch_sub(1, Ordering::SeqCst);
self.log.lock().await.push(format!("{}:end", self.name));
Ok(format!("{} complete", self.name))
}
}
#[async_trait]
impl ToolDefinition for ProbeTool {
fn descriptor(&self) -> ToolSpec {
ToolSpec::builder(self.name)
.description("probe tool")
.input_schema(json!({
"type": "object",
"properties": {}
}))
.side_effect_level(crate::tool::ToolSideEffectLevel::None)
.durability(crate::tool::ToolDurability::ReplaySafe)
.build()
}
}
#[async_trait]
impl ToolExecutor for ProbeTool {
fn execution_category(&self, _input: &Value) -> ToolExecutionCategory {
if self.parallel {
ToolExecutionCategory::ReadOnlyParallel
} else {
ToolExecutionCategory::ExclusiveLocalMutation
}
}
async fn execute(&self, _ctx: ParallelToolContext, _input: Value) -> ToolResult {
self.run().await
}
}
/// Creates a buffered `StreamScript` representing a single text response.
pub(super) fn text_stream(model: &str, text: &str) -> StreamScript {
ok_stream(vec![
ProviderEvent::MessageStarted {
id: format!("msg-{text}"),
model: model.to_string(),
role: Role::Assistant,
},
ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::Text,
},
ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::Text(text.to_string()),
},
ProviderEvent::ContentBlockStopped { index: 0 },
ProviderEvent::MessageStopped,
])
}
/// Creates a buffered `StreamScript` representing a single tool-use response.
pub(super) fn tool_use_stream(model: &str, id: &str, name: &str, input_json: &str) -> StreamScript {
ok_stream(vec![
ProviderEvent::MessageStarted {
id: format!("msg-{id}"),
model: model.to_string(),
role: Role::Assistant,
},
ProviderEvent::ContentBlockStarted {
index: 0,
kind: ContentBlockStart::ToolUse {
id: id.to_string(),
name: name.to_string(),
},
},
ProviderEvent::ContentBlockDelta {
index: 0,
delta: ContentBlockDelta::ToolUseInputJson(input_json.to_string()),
},
ProviderEvent::ContentBlockStopped { index: 0 },
ProviderEvent::MessageStopped,
])
}
/// Builder for generating multi-turn scripted sessions for testing.
pub(super) struct SessionGenerator {
scripts: Vec<StreamScript>,
response_size: usize,
model_id: String,
}
impl SessionGenerator {
pub(super) fn new(model_id: &str) -> Self {
Self {
scripts: Vec::new(),
response_size: 50,
model_id: model_id.to_string(),
}
}
pub(super) fn with_response_size(mut self, chars: usize) -> Self {
self.response_size = chars;
self
}
pub(super) fn add_text_turns(mut self, n: usize) -> Self {
for i in 0..n {
let text = format!(
"Response {i}: {}",
"x".repeat(self.response_size.saturating_sub(15))
);
self.scripts.push(text_stream(&self.model_id, &text));
}
self
}
#[allow(dead_code)]
pub(super) fn add_tool_turns(mut self, n: usize, tool_name: &str) -> Self {
for i in 0..n {
self.scripts.push(tool_use_stream(
&self.model_id,
&format!("tool-{i}"),
tool_name,
&format!(r#"{{"index":{i}}}"#),
));
}
// Final text response after all tool calls
self.scripts.push(text_stream(&self.model_id, "tools done"));
self
}
pub(super) fn build(self) -> Vec<StreamScript> {
self.scripts
}
}

View File

@@ -0,0 +1,684 @@
//! What a typed turn ([`Agent::run_to_output`]) may do on its way to the
//! answer: the shaping turn that holds one forced tool, the working turn that
//! keeps its whole toolset, and what each does when the terminal call never
//! comes.
use std::{
collections::VecDeque,
sync::{Arc, Mutex},
};
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{Value, json};
use crate::{
AgentConfig, BuiltinProvider, ContentBlock, ModelInfo, Provider, ProviderDescriptor,
ProviderError, ProviderEventStream, Request, Role, Runtime, TerminalOutputSpec, TokenUsage,
error::RuntimeError,
provider::{Response, ToolChoice},
provider_event_stream_from_response,
runtime::{CancellationToken, EarlyEnd, RunOptions},
};
use super::support::{StaticTool, StopTrippingTool};
/// The prefix every generated terminal tool's name carries, which is how the
/// scripted model below finds a tool whose name it cannot know in advance.
const TERMINAL_PREFIX: &str = "mentra_terminal_";
#[derive(Debug, Deserialize, PartialEq, Eq)]
struct Review {
verdict: String,
}
/// One block of a scripted assistant response.
///
/// [`Say::Answer`] is resolved against the request's tool list when the round
/// runs, so a test never has to know the per-call name `run_to_output`
/// generates.
#[derive(Clone)]
enum Say {
Text(&'static str),
Call {
id: &'static str,
tool: &'static str,
},
Answer {
id: &'static str,
input: Value,
},
}
/// One scripted round: what the model says, and what it reports having spent.
#[derive(Clone)]
struct Round {
blocks: Vec<Say>,
usage: Option<TokenUsage>,
}
impl Round {
fn new(blocks: Vec<Say>) -> Self {
Self {
blocks,
usage: None,
}
}
fn spending(mut self, input_tokens: u64, output_tokens: u64) -> Self {
self.usage = Some(TokenUsage {
input_tokens: Some(input_tokens),
output_tokens: Some(output_tokens),
..Default::default()
});
self
}
}
/// What one request put in front of the model — the two things a typed turn
/// changes about a round.
#[derive(Clone, Debug)]
struct Offer {
tools: Vec<String>,
choice: Option<ToolChoice>,
}
impl Offer {
fn terminal_tool(&self) -> Option<&String> {
self.tools
.iter()
.find(|name| name.starts_with(TERMINAL_PREFIX))
}
fn ordinary_tools(&self) -> Vec<&String> {
self.tools
.iter()
.filter(|name| !name.starts_with(TERMINAL_PREFIX))
.collect()
}
}
/// A model that plays one scripted [`Round`] per request and records what each
/// request offered it.
#[derive(Clone)]
struct ScriptedModel {
model: ModelInfo,
rounds: Arc<Mutex<VecDeque<Round>>>,
offers: Arc<Mutex<Vec<Offer>>>,
}
impl ScriptedModel {
fn new(rounds: Vec<Round>) -> Self {
Self {
model: ModelInfo::new("typed-turn-model", BuiltinProvider::Anthropic),
rounds: Arc::new(Mutex::new(VecDeque::from(rounds))),
offers: Arc::new(Mutex::new(Vec::new())),
}
}
fn offers(&self) -> Vec<Offer> {
self.offers.lock().expect("offers poisoned").clone()
}
}
#[async_trait]
impl Provider for ScriptedModel {
fn descriptor(&self) -> ProviderDescriptor {
ProviderDescriptor::new(self.model.provider.clone())
}
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
Ok(vec![self.model.clone()])
}
async fn stream(&self, request: Request<'_>) -> Result<ProviderEventStream, ProviderError> {
let offer = Offer {
tools: request.tools.iter().map(|tool| tool.name.clone()).collect(),
choice: request.tool_choice.clone(),
};
let terminal = offer.terminal_tool().cloned();
let index = {
let mut offers = self.offers.lock().expect("offers poisoned");
offers.push(offer);
offers.len() - 1
};
let round = self
.rounds
.lock()
.expect("rounds poisoned")
.pop_front()
.unwrap_or_else(|| panic!("the model was asked for an unscripted round {index}"));
let mut content = Vec::new();
for block in round.blocks {
content.push(match block {
Say::Text(text) => ContentBlock::text(text),
Say::Call { id, tool } => ContentBlock::ToolUse {
id: id.to_string(),
name: tool.to_string(),
input: json!({ "value": "please" }),
},
Say::Answer { id, input } => ContentBlock::ToolUse {
id: id.to_string(),
name: terminal
.clone()
.expect("the terminal tool must be on a typed turn's request"),
input,
},
});
}
let calls_a_tool = content
.iter()
.any(|block| matches!(block, ContentBlock::ToolUse { .. }));
Ok(provider_event_stream_from_response(Response {
id: format!("message-{index}"),
model: self.model.id.clone(),
role: Role::Assistant,
content,
stop_reason: calls_a_tool.then(|| "tool_use".to_string()),
usage: round.usage,
}))
}
}
fn review_spec() -> TerminalOutputSpec {
TerminalOutputSpec::new(
"submit_review",
"Return the verdict you reached",
json!({
"type": "object",
"properties": { "verdict": { "type": "string" } },
"required": ["verdict"]
}),
)
}
fn hold() -> Value {
json!({ "verdict": "hold" })
}
/// An agent that forces one ordinary tool of its own, so a test can tell a
/// typed turn's choice apart from the default one every agent already sends.
fn forcing_probe() -> AgentConfig {
AgentConfig {
tool_choice: Some(ToolChoice::Tool {
name: "probe".to_string(),
}),
..AgentConfig::default()
}
}
/// The `(tool_use_id, text, is_error)` of every result on the message that
/// ended the turn, in the order the round committed them.
fn last_results(message: &crate::Message) -> Vec<(String, String, bool)> {
message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
} => Some((tool_use_id.clone(), content.to_display_string(), *is_error)),
_ => None,
})
.collect()
}
#[tokio::test]
async fn a_shaping_turn_offers_only_the_terminal_tool_and_forces_it() {
// The default typed turn, unchanged: an agent with a perfectly usable
// ordinary tool is not offered it, because the turn exists to decide a
// shape and nothing else.
let provider = ScriptedModel::new(vec![Round::new(vec![Say::Answer {
id: "answer-1",
input: hold(),
}])]);
let handle = provider.clone();
let model = provider.model.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("probe", "read the file"))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("reviewer", model).expect("spawn agent");
let output = agent
.run_to_output::<Review>(
vec![ContentBlock::text("shape what you have")],
RunOptions::default(),
review_spec(),
)
.await
.expect("a shaping turn answers");
assert_eq!(output.value.verdict, "hold");
let offers = handle.offers();
assert_eq!(offers.len(), 1, "a shaping turn takes one round");
let terminal = offers[0]
.terminal_tool()
.expect("the terminal tool is offered")
.clone();
assert_eq!(
offers[0].tools,
vec![terminal.clone()],
"the terminal tool is the only tool on the request"
);
assert_eq!(
offers[0].choice,
Some(ToolChoice::Tool { name: terminal }),
"and the model is told to call it"
);
}
#[tokio::test]
async fn a_working_turn_reaches_an_ordinary_tool_and_then_answers_through_the_terminal_one() {
// The opt-in: one turn that reads and then answers in the declared shape,
// where the shaping turn would have needed a turn for each. The agent is
// configured to force a tool of its own, so the `Auto` below is this
// turn's doing and not a default.
let provider = ScriptedModel::new(vec![
Round::new(vec![Say::Call {
id: "probe-1",
tool: "probe",
}]),
Round::new(vec![Say::Answer {
id: "answer-1",
input: hold(),
}]),
]);
let handle = provider.clone();
let model = provider.model.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("probe", "read the file"))
.build()
.expect("build runtime");
let mut agent = runtime
.spawn_with_config("reviewer", model, forcing_probe())
.expect("spawn agent");
let output = agent
.run_to_output::<Review>(
vec![ContentBlock::text("read, then review")],
RunOptions::default(),
review_spec().with_tools(),
)
.await
.expect("a working turn answers");
assert_eq!(output.value.verdict, "hold");
let offers = handle.offers();
assert_eq!(offers.len(), 2, "the turn worked a round, then answered");
for (round, offer) in offers.iter().enumerate() {
assert!(
offer.terminal_tool().is_some(),
"round {round} can end the turn"
);
assert_eq!(
offer.ordinary_tools(),
vec!["probe"],
"round {round} keeps the ordinary toolset"
);
assert_eq!(
offer.choice,
Some(ToolChoice::Auto),
"round {round} forces nothing: a forced choice — the agent's own \
included — precludes the working rounds that are the point"
);
}
// The tool really ran — the point of the mode is the reading, not the
// roster.
let read_it = agent.history().iter().any(|message| {
message.content.iter().any(|block| {
matches!(block, ContentBlock::ToolResult { tool_use_id, content, .. }
if tool_use_id == "probe-1" && content.to_display_string() == "read the file")
})
});
assert!(read_it, "the ordinary tool executed: {:?}", agent.history());
}
#[tokio::test]
async fn a_working_turn_that_settles_for_prose_reports_the_missing_terminal_call() {
// Nothing forces the ending, so a model can work and then simply talk.
// That is not an answer, and it must not be reported as one.
let provider = ScriptedModel::new(vec![
Round::new(vec![Say::Call {
id: "probe-1",
tool: "probe",
}]),
Round::new(vec![Say::Text("looks fine to me")]),
]);
let model = provider.model.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("probe", "read the file"))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("reviewer", model).expect("spawn agent");
let error = agent
.run_to_output::<Review>(
vec![ContentBlock::text("read, then review")],
RunOptions::default(),
review_spec().with_tools(),
)
.await
.expect_err("prose is not a typed answer");
assert!(
error
.to_string()
.contains("without invoking the expected terminal tool"),
"got: {error}"
);
assert!(
agent
.history()
.iter()
.any(|message| message.text().contains("looks fine to me")),
"the turn keeps what it gathered and said"
);
}
#[tokio::test]
async fn a_working_turn_stopped_at_a_round_boundary_fails_instead_of_answering_nothing() {
// A working turn can run many rounds, so a graceful stop can now land
// between them. It ends the turn exactly as it ends any other — at the
// boundary, transcript kept — and the typed caller is told the terminal
// call never came rather than handed a value nobody produced.
let stop = CancellationToken::default();
let provider = ScriptedModel::new(vec![
Round::new(vec![Say::Call {
id: "probe-1",
tool: "stop_probe",
}]),
Round::new(vec![Say::Answer {
id: "answer-1",
input: hold(),
}]),
]);
let handle = provider.clone();
let model = provider.model.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StopTrippingTool::new("stop_probe", stop.clone()))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("reviewer", model).expect("spawn agent");
let options = RunOptions {
stop: Some(stop),
..Default::default()
};
let error = agent
.run_to_output::<Review>(
vec![ContentBlock::text("read, then review")],
options.clone(),
review_spec().with_tools(),
)
.await
.expect_err("a turn stopped before the terminal call has no value");
assert!(
error
.to_string()
.contains("without invoking the expected terminal tool"),
"got: {error}"
);
assert_eq!(
options.ended_early(),
Some(EarlyEnd::StopRequested),
"and the run says which bound ended it"
);
assert_eq!(
handle.offers().len(),
1,
"the stop was honored at the boundary: the answering round never ran"
);
assert_eq!(
agent.history().len(),
3,
"the gathered round stays committed, not rolled back"
);
}
#[tokio::test]
async fn a_working_turn_out_of_token_budget_fails_the_same_way() {
// The other graceful bound, at the same boundary, reported as itself.
let provider = ScriptedModel::new(vec![
Round::new(vec![Say::Call {
id: "probe-1",
tool: "probe",
}])
.spending(60, 40),
Round::new(vec![Say::Answer {
id: "answer-1",
input: hold(),
}]),
]);
let handle = provider.clone();
let model = provider.model.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("probe", "read the file"))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("reviewer", model).expect("spawn agent");
let options = RunOptions {
token_budget: Some(100),
..Default::default()
};
let error = agent
.run_to_output::<Review>(
vec![ContentBlock::text("read, then review")],
options.clone(),
review_spec().with_tools(),
)
.await
.expect_err("a turn out of budget before the terminal call has no value");
assert!(
error
.to_string()
.contains("without invoking the expected terminal tool"),
"got: {error}"
);
assert_eq!(options.ended_early(), Some(EarlyEnd::TokenBudget));
assert_eq!(
handle.offers().len(),
1,
"the budget halted the run before the answering round"
);
}
#[tokio::test]
async fn a_terminal_call_beside_other_calls_ends_the_round_and_skips_what_follows() {
// A working turn is the first typed turn where the model can put other
// calls in the round it answers from. The terminal tool terminates its
// round, so calls before it run and calls after it do not — each still
// getting an explicit result, never a silent drop.
let provider = ScriptedModel::new(vec![Round::new(vec![
Say::Call {
id: "before-1",
tool: "probe",
},
Say::Answer {
id: "answer-1",
input: hold(),
},
Say::Call {
id: "after-1",
tool: "probe",
},
])]);
let model = provider.model.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("probe", "read the file"))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("reviewer", model).expect("spawn agent");
let output = agent
.run_to_output::<Review>(
vec![ContentBlock::text("read and review in one breath")],
RunOptions::default(),
review_spec().with_tools(),
)
.await
.expect("the terminal call in the round is still the answer");
assert_eq!(output.value.verdict, "hold");
let results = last_results(&output.message);
assert_eq!(
results
.iter()
.map(|(id, _, _)| id.as_str())
.collect::<Vec<_>>(),
vec!["before-1", "answer-1", "after-1"],
"every call in the round has exactly one result"
);
assert_eq!(
(results[0].1.as_str(), results[0].2),
("read the file", false),
"the call before the answer ran"
);
assert!(
results[2].1.contains("not executed: run terminated by") && results[2].2,
"the call after the answer did not run, and says so: {:?}",
results[2]
);
}
#[tokio::test]
async fn two_terminal_calls_in_one_round_answer_with_the_first() {
// The same rule read from the other side: the second terminal call is
// simply a call scheduled after a terminating one, so the first is the
// answer and the second is reported as skipped. Deliberate, because a
// model that emits two shapes has not told anyone which it meant.
let provider = ScriptedModel::new(vec![Round::new(vec![
Say::Answer {
id: "answer-1",
input: json!({ "verdict": "hold" }),
},
Say::Answer {
id: "answer-2",
input: json!({ "verdict": "ship" }),
},
])]);
let model = provider.model.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("reviewer", model).expect("spawn agent");
let output = agent
.run_to_output::<Review>(
vec![ContentBlock::text("review it")],
RunOptions::default(),
review_spec().with_tools(),
)
.await
.expect("the first terminal call answers");
assert_eq!(output.value.verdict, "hold");
let results = last_results(&output.message);
assert_eq!(results.len(), 2);
assert!(
results[1].1.contains("not executed: run terminated by") && results[1].2,
"the second answer was never executed: {:?}",
results[1]
);
}
#[tokio::test]
async fn a_working_turn_leaves_the_gate_shut_behind_it() {
// The gate is per-run: whatever the typed turn did to the roster and to
// the choice, the next ordinary turn on the same agent is back to its own.
let provider = ScriptedModel::new(vec![
Round::new(vec![Say::Answer {
id: "answer-1",
input: hold(),
}]),
Round::new(vec![Say::Text("back to prose")]),
]);
let handle = provider.clone();
let model = provider.model.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_tool(StaticTool::success("probe", "read the file"))
.build()
.expect("build runtime");
let mut agent = runtime
.spawn_with_config("reviewer", model, forcing_probe())
.expect("spawn agent");
agent
.run_to_output::<Review>(
vec![ContentBlock::text("review it")],
RunOptions::default(),
review_spec().with_tools(),
)
.await
.expect("a working turn answers");
let plain = agent
.send(vec![ContentBlock::text("and now just talk")])
.await
.expect("an ordinary turn follows");
assert_eq!(plain.text(), "back to prose");
let offers = handle.offers();
assert_eq!(offers[1].ordinary_tools(), vec!["probe"]);
assert!(
offers[1].terminal_tool().is_none(),
"the generated tool is gone once its run is over: {:?}",
offers[1]
);
assert_eq!(
offers[1].choice,
Some(ToolChoice::Tool {
name: "probe".to_string()
}),
"and the agent's own forced choice is back"
);
}
/// A run that ends with no assistant message and no terminal call is reported
/// as the missing terminal call, not as the empty assistant response
/// `Agent::run` sees. Kept as its own test because it is the one place where
/// the typed helper deliberately reinterprets an error from underneath it.
#[tokio::test]
async fn a_run_that_answers_nothing_at_all_still_names_the_missing_terminal_call() {
let provider = ScriptedModel::new(vec![Round::new(Vec::new())]);
let model = provider.model.clone();
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.build()
.expect("build runtime");
let mut agent = runtime.spawn("reviewer", model).expect("spawn agent");
let error = agent
.run_to_output::<Review>(
vec![ContentBlock::text("review it")],
RunOptions::default(),
review_spec(),
)
.await
.expect_err("an empty response is not an answer");
assert!(
!matches!(error, RuntimeError::EmptyAssistantResponse),
"the typed caller asked about the terminal call, not about prose"
);
assert!(
error
.to_string()
.contains("without invoking the expected terminal tool"),
"got: {error}"
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,566 @@
//! End-to-end coverage for automatic tool-result paging: what the model sees,
//! what the event stream keeps, and how `read_tool_result` walks a retained
//! result window by window.
use async_trait::async_trait;
use serde_json::{Value, json};
use crate::{
AgentConfig, BuiltinProvider, ContentBlock, Message, Role, ToolResultPagingConfig,
agent::AgentEvent,
provider::{ContentBlockDelta, ContentBlockStart, ProviderEvent},
runtime::{Runtime, RuntimePolicy},
tool::{
ParallelToolContext, ToolDefinition, ToolDurability, ToolExecutionCategory, ToolExecutor,
ToolResult, ToolSideEffectLevel, ToolSpec,
},
};
use super::support::{ScriptedProvider, StaticTool, StreamScript, model_info, ok_stream};
/// Builds `count` lines of exactly 20 bytes each (`{tag}-{n:03}` padded), so
/// every window boundary asserted below is exact arithmetic on line counts
/// rather than an approximation.
fn numbered_lines(tag: &str, count: usize) -> String {
assert_eq!(
tag.len(),
4,
"the fixed 20-byte line layout assumes a 4-byte tag"
);
(1..=count)
.map(|line| format!("{tag}-{line:03}{}\n", "x".repeat(11)))
.collect()
}
/// Removes the runtime's own tool-result caps from the picture. Paging runs
/// downstream of that limiter, so a threshold above `max_tool_result_bytes`
/// would never be reached — every paging test has to raise the caps first,
/// exactly as a real consumer enabling paging must.
fn unlimited_results() -> RuntimePolicy {
RuntimePolicy::default()
.with_max_tool_result_bytes(usize::MAX)
.with_max_tool_result_lines(usize::MAX)
.spill_full_tool_output(false)
}
fn paged_config(threshold_bytes: usize, page_bytes: usize) -> AgentConfig {
AgentConfig {
tool_result_paging: Some(ToolResultPagingConfig {
threshold_bytes,
page_bytes,
}),
..Default::default()
}
}
fn tool_results(messages: &[Message]) -> Vec<(String, String, bool)> {
messages
.iter()
.filter(|message| message.role == Role::User)
.flat_map(|message| message.content.iter())
.filter_map(|block| match block {
ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
} => Some((tool_use_id.clone(), content.to_display_string(), *is_error)),
_ => None,
})
.collect()
}
fn multi_tool_use_stream(model: &str, calls: &[(&str, &str, &str)]) -> StreamScript {
let mut events = vec![ProviderEvent::MessageStarted {
id: "msg-multi-tool".to_string(),
model: model.to_string(),
role: Role::Assistant,
}];
for (index, (id, name, input_json)) in calls.iter().enumerate() {
events.push(ProviderEvent::ContentBlockStarted {
index,
kind: ContentBlockStart::ToolUse {
id: (*id).to_string(),
name: (*name).to_string(),
},
});
events.push(ProviderEvent::ContentBlockDelta {
index,
delta: ContentBlockDelta::ToolUseInputJson((*input_json).to_string()),
});
events.push(ProviderEvent::ContentBlockStopped { index });
}
events.push(ProviderEvent::MessageStopped);
ok_stream(events)
}
fn read_window_input(tool_use_id: &str, start_line: usize) -> String {
json!({ "tool_use_id": tool_use_id, "start_line": start_line }).to_string()
}
/// A parallel-lane tool returning a caller-supplied oversized result.
struct ParallelPagedTool {
name: &'static str,
output: String,
}
impl ToolDefinition for ParallelPagedTool {
fn descriptor(&self) -> ToolSpec {
ToolSpec::builder(self.name)
.description("test tool: returns an oversized parallel result")
.input_schema(json!({ "type": "object", "properties": {} }))
.side_effect_level(ToolSideEffectLevel::None)
.durability(ToolDurability::ReplaySafe)
.execution_category(ToolExecutionCategory::ReadOnlyParallel)
.build()
}
}
#[async_trait]
impl ToolExecutor for ParallelPagedTool {
async fn execute(&self, _ctx: ParallelToolContext, _input: Value) -> ToolResult {
Ok(self.output.clone())
}
}
// (a) With paging unconfigured, an oversized result is inserted whole and the
// reader is neither registered nor offered to the model.
#[tokio::test]
async fn unpaged_agents_receive_oversized_results_whole_without_the_reader() {
let model = model_info("model", BuiltinProvider::Anthropic);
let full = numbered_lines("line", 40);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
super::support::tool_use_stream(&model.id, "call-1", "big_tool", r#"{}"#),
super::support::text_stream(&model.id, "done"),
],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_policy(unlimited_results())
.with_tool(StaticTool::success("big_tool", &full))
.build()
.expect("build runtime");
let mut agent = runtime.spawn("agent", model).expect("spawn agent");
agent
.send(vec![ContentBlock::text("run the big tool")])
.await
.expect("send");
let results = tool_results(agent.history());
assert_eq!(results.len(), 1);
assert_eq!(results[0].1, full, "the result must be byte-identical");
assert!(
agent
.tools()
.iter()
.all(|tool| tool.name != "read_tool_result"),
"read_tool_result must not be offered to an unpaged agent"
);
assert!(
runtime.tool_descriptor("read_tool_result").is_none(),
"read_tool_result must not be registered for an unpaged agent"
);
}
// (e) With paging enabled, a result at or below the threshold is still
// byte-identical — only the roster changes.
#[tokio::test]
async fn sub_threshold_results_stay_byte_identical_under_paging() {
let model = model_info("model", BuiltinProvider::Anthropic);
let full = numbered_lines("line", 40);
assert_eq!(full.len(), 800);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
super::support::tool_use_stream(&model.id, "call-1", "big_tool", r#"{}"#),
super::support::text_stream(&model.id, "done"),
],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_policy(unlimited_results())
.with_tool(StaticTool::success("big_tool", &full))
.build()
.expect("build runtime");
let mut agent = runtime
.spawn_with_config("agent", model, paged_config(800, 100))
.expect("spawn agent");
agent
.send(vec![ContentBlock::text("run the big tool")])
.await
.expect("send");
let results = tool_results(agent.history());
assert_eq!(results[0].1, full, "a result at the threshold is not paged");
assert!(!results[0].1.contains("[paged:"));
assert!(
agent
.tools()
.iter()
.any(|tool| tool.name == "read_tool_result"),
"the reader is offered whenever paging is enabled, not only once it fires"
);
}
// (b) An oversized result reaches the model as page 1 plus a trailer, while
// the event stream still carries the complete block.
#[tokio::test]
async fn oversized_results_reach_the_model_paged_and_the_event_stream_whole() {
let model = model_info("model", BuiltinProvider::Anthropic);
let full = numbered_lines("line", 40);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
super::support::tool_use_stream(&model.id, "call-1", "big_tool", r#"{}"#),
super::support::text_stream(&model.id, "done"),
],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_policy(unlimited_results())
.with_tool(StaticTool::success("big_tool", &full))
.build()
.expect("build runtime");
let mut agent = runtime
.spawn_with_config("agent", model, paged_config(100, 100))
.expect("spawn agent");
let mut events = agent.subscribe_events();
agent
.send(vec![ContentBlock::text("run the big tool")])
.await
.expect("send");
let results = tool_results(agent.history());
assert_eq!(results.len(), 1);
let page = &results[0].1;
assert!(page.starts_with(&numbered_lines("line", 5)));
assert!(!page.contains("line-006"));
assert!(
page.contains(
"…[paged: lines 15 of 40 (0.1 KB of 0.8 KB). \
Call read_tool_result(tool_use_id=\"call-1\", start_line=6) for the next window.]"
),
"unexpected trailer: {page}"
);
let finished = collect_events(&mut events)
.into_iter()
.find_map(|event| match event {
AgentEvent::ToolExecutionFinished { result } => Some(result),
_ => None,
})
.expect("a ToolExecutionFinished event");
let ContentBlock::ToolResult { content, .. } = finished else {
panic!("expected a tool result block");
};
assert_eq!(
content.to_display_string(),
full,
"the event stream must keep carrying the unpaged result"
);
}
// (c) Successive windows tile the result with absolute line numbers, the last
// one is marked as the end, and a start_line past the end is empty.
#[tokio::test]
async fn read_tool_result_windows_tile_the_result_and_mark_the_end() {
let model = model_info("model", BuiltinProvider::Anthropic);
let full = numbered_lines("line", 40);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
super::support::tool_use_stream(&model.id, "call-1", "big_tool", r#"{}"#),
super::support::tool_use_stream(
&model.id,
"call-2",
"read_tool_result",
&read_window_input("call-1", 6),
),
super::support::tool_use_stream(
&model.id,
"call-3",
"read_tool_result",
&read_window_input("call-1", 36),
),
super::support::tool_use_stream(
&model.id,
"call-4",
"read_tool_result",
&read_window_input("call-1", 41),
),
super::support::text_stream(&model.id, "done"),
],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_policy(unlimited_results())
.with_tool(StaticTool::success("big_tool", &full))
.build()
.expect("build runtime");
let mut agent = runtime
.spawn_with_config("agent", model, paged_config(100, 100))
.expect("spawn agent");
let message = agent
.send(vec![ContentBlock::text("read the whole result")])
.await
.expect("send");
assert_eq!(message.text(), "done");
let results = tool_results(agent.history());
assert_eq!(results.len(), 4);
assert!(results[1].1.starts_with("line-006"));
assert!(results[1].1.contains("lines 610 of 40"));
assert!(results[1].1.contains("start_line=11"));
assert!(results[2].1.starts_with("line-036"));
assert!(results[2].1.contains("line-040"));
assert!(
results[2].1.ends_with("…[end of result]"),
"the window reaching the last line ends the result: {}",
results[2].1
);
assert!(!results[2].1.contains("[paged:"));
assert_eq!(
results[3].1, "…[end of result]",
"a start_line past the end is an empty window, not an error"
);
assert!(!results[3].2, "reading past the end is not a tool error");
}
// The reader's own windows are never paged again, even when `page_bytes`
// exceeds `threshold_bytes` and a window is therefore itself "oversized".
#[tokio::test]
async fn read_tool_result_windows_are_never_paged_recursively() {
let model = model_info("model", BuiltinProvider::Anthropic);
let full = numbered_lines("line", 40);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
super::support::tool_use_stream(&model.id, "call-1", "big_tool", r#"{}"#),
super::support::tool_use_stream(
&model.id,
"call-2",
"read_tool_result",
&read_window_input("call-1", 16),
),
super::support::text_stream(&model.id, "done"),
],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_policy(unlimited_results())
.with_tool(StaticTool::success("big_tool", &full))
.build()
.expect("build runtime");
let mut agent = runtime
.spawn_with_config("agent", model, paged_config(100, 300))
.expect("spawn agent");
agent
.send(vec![ContentBlock::text("read a large window")])
.await
.expect("send");
let results = tool_results(agent.history());
let window = &results[1].1;
assert!(window.starts_with("line-016"));
assert!(window.contains("lines 1630 of 40"));
assert_eq!(
window.matches("[paged:").count(),
1,
"a window must carry exactly one trailer, never a trailer nested in a page: {window}"
);
assert!(
!window.contains("call-2"),
"a window must never be re-paged under its own tool_use_id: {window}"
);
}
// (d) An unknown tool_use_id is an ordinary tool error and the run continues.
#[tokio::test]
async fn an_unknown_tool_use_id_is_a_tool_error_and_the_run_continues() {
let model = model_info("model", BuiltinProvider::Anthropic);
let full = numbered_lines("line", 40);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
super::support::tool_use_stream(&model.id, "call-1", "big_tool", r#"{}"#),
super::support::tool_use_stream(
&model.id,
"call-2",
"read_tool_result",
&read_window_input("call-does-not-exist", 1),
),
super::support::text_stream(&model.id, "done"),
],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_policy(unlimited_results())
.with_tool(StaticTool::success("big_tool", &full))
.build()
.expect("build runtime");
let mut agent = runtime
.spawn_with_config("agent", model, paged_config(100, 100))
.expect("spawn agent");
let message = agent
.send(vec![ContentBlock::text(
"read a result that was never paged",
)])
.await
.expect("an unknown id must not fail the run");
assert_eq!(message.text(), "done");
let results = tool_results(agent.history());
assert!(results[1].2, "the failed read is an is_error result");
assert!(results[1].1.contains("no retained result for tool_use_id"));
assert!(results[1].1.contains("call-does-not-exist"));
}
// (f) A single line longer than a page is the one case that cuts mid-line:
// it cuts on a character boundary and says so.
#[tokio::test]
async fn a_line_longer_than_a_page_hard_cuts_on_a_character_boundary() {
let model = model_info("model", BuiltinProvider::Anthropic);
// 50 four-byte characters: a 200-byte line, plus a short second line.
let full = format!("{}\ntail line\n", "𝄞".repeat(50));
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
super::support::tool_use_stream(&model.id, "call-1", "big_tool", r#"{}"#),
super::support::tool_use_stream(
&model.id,
"call-2",
"read_tool_result",
&read_window_input("call-1", 2),
),
super::support::text_stream(&model.id, "done"),
],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_policy(unlimited_results())
.with_tool(StaticTool::success("big_tool", &full))
.build()
.expect("build runtime");
let mut agent = runtime
// 102 is not a multiple of the 4-byte character width, so a correct
// cut must round down to 100.
.spawn_with_config("agent", model, paged_config(100, 102))
.expect("spawn agent");
agent
.send(vec![ContentBlock::text("run the long-line tool")])
.await
.expect("send");
let results = tool_results(agent.history());
let page = &results[0].1;
assert!(page.starts_with(&"𝄞".repeat(25)));
assert!(!page.starts_with(&"𝄞".repeat(26)));
assert!(
page.contains("…[line 1 hard-cut at 100 of 201 bytes"),
"unexpected hard-cut marker: {page}"
);
assert!(page.contains("start_line=2"));
assert!(
results[1].1.starts_with("tail line\n"),
"the next window resumes at the following whole line: {}",
results[1].1
);
assert!(results[1].1.ends_with("…[end of result]"));
}
// (g) Parallel oversized results page independently, and each one's full text
// is retained under its own tool_use_id.
#[tokio::test]
async fn parallel_oversized_results_page_independently_per_tool_use_id() {
let model = model_info("model", BuiltinProvider::Anthropic);
let first = numbered_lines("aaaa", 40);
let second = numbered_lines("bbbb", 40);
let provider = ScriptedProvider::new(
BuiltinProvider::Anthropic,
vec![model.clone()],
vec![
multi_tool_use_stream(
&model.id,
&[
("call-a", "parallel_a", r#"{}"#),
("call-b", "parallel_b", r#"{}"#),
],
),
super::support::tool_use_stream(
&model.id,
"call-c",
"read_tool_result",
&read_window_input("call-b", 6),
),
super::support::text_stream(&model.id, "done"),
],
);
let runtime = Runtime::empty_builder()
.with_provider_instance(provider)
.with_policy(unlimited_results())
.with_tool(ParallelPagedTool {
name: "parallel_a",
output: first,
})
.with_tool(ParallelPagedTool {
name: "parallel_b",
output: second,
})
.build()
.expect("build runtime");
let mut agent = runtime
.spawn_with_config("agent", model, paged_config(100, 100))
.expect("spawn agent");
agent
.send(vec![ContentBlock::text("run both parallel tools")])
.await
.expect("send");
let results = tool_results(agent.history());
assert_eq!(results.len(), 3);
assert_eq!(results[0].0, "call-a");
assert!(results[0].1.starts_with("aaaa-001"));
assert!(results[0].1.contains("tool_use_id=\"call-a\""));
assert!(!results[0].1.contains("bbbb"));
assert_eq!(results[1].0, "call-b");
assert!(results[1].1.starts_with("bbbb-001"));
assert!(results[1].1.contains("tool_use_id=\"call-b\""));
assert!(!results[1].1.contains("aaaa"));
assert!(
results[2].1.starts_with("bbbb-006"),
"each result is retained under its own id: {}",
results[2].1
);
assert!(results[2].1.contains("lines 610 of 40"));
}
fn collect_events(receiver: &mut tokio::sync::broadcast::Receiver<AgentEvent>) -> Vec<AgentEvent> {
let mut events = Vec::new();
while let Ok(event) = receiver.try_recv() {
events.push(event);
}
events
}

136
vendor/mentra/src/agent/wait.rs vendored Normal file
View File

@@ -0,0 +1,136 @@
use std::{future::Future, path::PathBuf, pin::Pin};
use tokio::sync::watch;
use crate::{error::RuntimeError, runtime::RuntimeHandle, team::TeamMessage};
use super::{Agent, AgentSnapshot, AgentStatus};
/// Owned future returned by [`Agent`] and [`AgentWaitHandle`] wait helpers.
///
/// The future does not borrow the agent, so it can be polled concurrently with
/// a call that holds `&mut Agent`, including [`Agent::run`](crate::Agent::run).
pub type AgentWaitFuture<T> = Pin<Box<dyn Future<Output = T> + Send + 'static>>;
/// Cloneable observation handle for an agent's snapshot and teammate inbox.
#[derive(Clone)]
pub struct AgentWaitHandle {
snapshots: watch::Receiver<AgentSnapshot>,
runtime: RuntimeHandle,
team_dir: PathBuf,
agent_name: String,
}
impl AgentWaitHandle {
/// Resolves with the first current or future snapshot satisfying `predicate`.
///
/// If every snapshot sender is dropped first, the final published snapshot
/// is returned even when it does not satisfy `predicate`. Dropping the
/// [`Agent`] alone does not close this channel while runtime observers for
/// that agent still own sender clones.
pub fn wait_for_snapshot<P>(&self, predicate: P) -> AgentWaitFuture<AgentSnapshot>
where
P: Fn(&AgentSnapshot) -> bool + Send + 'static,
{
let mut snapshots = self.snapshots.clone();
Box::pin(async move {
loop {
let snapshot = snapshots.borrow().clone();
if predicate(&snapshot) {
return snapshot;
}
if snapshots.changed().await.is_err() {
return snapshots.borrow().clone();
}
}
})
}
/// Waits for the relevant run generation to become terminal.
///
/// If called while a run is active, this waits for that generation. If
/// called while the agent is initially idle or already terminal, it waits
/// for the *next* generation, avoiding an immediate stale return from a
/// previous run. Terminal statuses are `Finished`, `Failed`, and
/// `Interrupted`; the initial `Idle` snapshot is not a completed run.
pub fn wait_until_idle(&self) -> AgentWaitFuture<AgentSnapshot> {
let snapshot = self.snapshots.borrow().clone();
let target_generation = if is_active(&snapshot.status) {
snapshot.run_generation
} else {
snapshot.run_generation.saturating_add(1)
};
self.wait_for_snapshot(move |snapshot| {
snapshot.run_generation >= target_generation && is_terminal(&snapshot.status)
})
}
/// Waits for and consumes the next batch of teammate replies.
///
/// This is a host-consumption API, not a non-destructive observer. The
/// underlying inbox read moves pending rows to the store's inflight state
/// and resets `pending_team_messages`; the returned messages will therefore
/// not also be injected into a later provider request. Do not race this
/// helper with `Agent::run` reading the same inbox. The next successful run
/// acknowledges inflight rows; a failed run requeues them.
pub fn wait_for_teammate_reply(
&self,
) -> AgentWaitFuture<Result<Vec<TeamMessage>, RuntimeError>> {
let snapshots = self.clone();
let runtime = self.runtime.clone();
let team_dir = self.team_dir.clone();
let agent_name = self.agent_name.clone();
Box::pin(async move {
snapshots
.wait_for_snapshot(|snapshot| snapshot.pending_team_messages > 0)
.await;
runtime.read_team_inbox(&team_dir, &agent_name)
})
}
}
impl Agent {
/// Returns a cloneable observation handle that does not borrow this agent.
pub fn wait_handle(&self) -> AgentWaitHandle {
AgentWaitHandle {
snapshots: self.watch_snapshot(),
runtime: self.runtime.clone(),
team_dir: self.config.team.team_dir.clone(),
agent_name: self.name.clone(),
}
}
/// Owned-future convenience for [`AgentWaitHandle::wait_for_snapshot`].
pub fn wait_for_snapshot<P>(&self, predicate: P) -> AgentWaitFuture<AgentSnapshot>
where
P: Fn(&AgentSnapshot) -> bool + Send + 'static,
{
self.wait_handle().wait_for_snapshot(predicate)
}
/// Owned-future convenience for [`AgentWaitHandle::wait_until_idle`].
pub fn wait_until_idle(&self) -> AgentWaitFuture<AgentSnapshot> {
self.wait_handle().wait_until_idle()
}
/// Owned-future convenience for [`AgentWaitHandle::wait_for_teammate_reply`].
pub fn wait_for_teammate_reply(
&self,
) -> AgentWaitFuture<Result<Vec<TeamMessage>, RuntimeError>> {
self.wait_handle().wait_for_teammate_reply()
}
}
fn is_active(status: &AgentStatus) -> bool {
matches!(
status,
AgentStatus::AwaitingModel | AgentStatus::Streaming | AgentStatus::ExecutingTool { .. }
)
}
fn is_terminal(status: &AgentStatus) -> bool {
matches!(
status,
AgentStatus::Finished | AgentStatus::Failed(_) | AgentStatus::Interrupted
)
}

1
vendor/mentra/src/auth.rs vendored Normal file
View File

@@ -0,0 +1 @@
pub mod openai;

13
vendor/mentra/src/auth/openai.rs vendored Normal file
View File

@@ -0,0 +1,13 @@
mod client;
mod credential;
mod store;
pub use client::{
DEFAULT_AUTH_URL, DEFAULT_CLIENT_ID, DEFAULT_SCOPE, DEFAULT_TOKEN_URL, OpenAIOAuthClient,
OpenAIOAuthError, OpenAITokenSet, PendingAuthorization,
};
pub use credential::OpenAIOAuthCredentialSource;
pub use store::{
FileTokenStore, KeychainTokenStore, MemoryTokenStore, PersistentTokenStoreKind, TokenStore,
persistent_token_store, selected_store_kind,
};

334
vendor/mentra/src/auth/openai/client.rs vendored Normal file
View File

@@ -0,0 +1,334 @@
use std::{collections::BTreeMap, net::SocketAddr, time::Duration as StdDuration};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use rand::RngCore;
use reqwest::StatusCode;
use ring::digest::{SHA256, digest};
use serde::{Deserialize, Serialize};
use time::{Duration, OffsetDateTime};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
time::timeout,
};
use url::Url;
pub const DEFAULT_CLIENT_ID: &str = "T19P7LJMcLZgUbhBzA85goHf";
pub const DEFAULT_AUTH_URL: &str = "https://auth.openai.com/oauth/authorize";
pub const DEFAULT_TOKEN_URL: &str = "https://auth.openai.com/oauth/token";
pub const DEFAULT_SCOPE: &str = "openid profile email offline_access";
const CALLBACK_PATH: &str = "/callback";
const CALLBACK_TIMEOUT: StdDuration = StdDuration::from_secs(300);
#[derive(Debug, thiserror::Error)]
pub enum OpenAIOAuthError {
#[error("http transport error: {0}")]
Transport(#[from] reqwest::Error),
#[error("failed to parse url: {0}")]
Url(#[from] url::ParseError),
#[error("failed to encode or decode json: {0}")]
Json(#[from] serde_json::Error),
#[error("failed to bind loopback callback listener: {0}")]
Io(#[from] std::io::Error),
#[error("oauth endpoint returned HTTP {status}: {body}")]
Http { status: StatusCode, body: String },
#[error("callback timed out waiting for browser redirect")]
CallbackTimeout,
#[error("callback did not include an authorization code")]
MissingCode,
#[error("callback state mismatch")]
StateMismatch,
#[error("oauth endpoint did not return an API key")]
MissingApiKey,
#[error("no stored OAuth tokens found")]
MissingStoredTokens,
#[error("token store is unsupported on this platform: {0}")]
UnsupportedStore(&'static str),
#[error("credential store command `{command}` failed with status {status}: {stderr}")]
CredentialCommand {
command: &'static str,
status: i32,
stderr: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAITokenSet {
pub access_token: String,
pub refresh_token: String,
pub id_token: Option<String>,
pub api_key: Option<String>,
pub expires_at: OffsetDateTime,
}
impl OpenAITokenSet {
pub fn is_expired(&self, refresh_skew: Duration) -> bool {
self.expires_at <= OffsetDateTime::now_utc() + refresh_skew
}
pub fn require_api_key(&self) -> Result<&str, OpenAIOAuthError> {
self.api_key
.as_deref()
.ok_or(OpenAIOAuthError::MissingApiKey)
}
}
#[derive(Debug, Deserialize)]
struct TokenResponse {
access_token: String,
refresh_token: String,
#[serde(default)]
id_token: Option<String>,
#[serde(default)]
api_key: Option<String>,
expires_in_seconds: i64,
}
impl TokenResponse {
fn into_tokens(self) -> OpenAITokenSet {
OpenAITokenSet {
access_token: self.access_token,
refresh_token: self.refresh_token,
id_token: self.id_token,
api_key: self.api_key,
expires_at: OffsetDateTime::now_utc() + Duration::seconds(self.expires_in_seconds),
}
}
}
#[derive(Debug, Clone)]
pub struct OpenAIOAuthClient {
client: reqwest::Client,
client_id: String,
auth_url: Url,
token_url: Url,
}
impl Default for OpenAIOAuthClient {
fn default() -> Self {
Self::new(DEFAULT_CLIENT_ID)
}
}
impl OpenAIOAuthClient {
pub fn new(client_id: impl Into<String>) -> Self {
Self {
client: reqwest::Client::builder()
.build()
.expect("Failed to build OpenAI OAuth client"),
client_id: client_id.into(),
auth_url: Url::parse(DEFAULT_AUTH_URL).expect("Failed to parse auth url"),
token_url: Url::parse(DEFAULT_TOKEN_URL).expect("Failed to parse token url"),
}
}
pub async fn start_authorization(&self) -> Result<PendingAuthorization, OpenAIOAuthError> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let redirect_addr = listener.local_addr()?;
let redirect_uri = loopback_redirect_uri(redirect_addr)?;
let code_verifier = random_base64_url(32);
let code_challenge = pkce_s256(&code_verifier);
let state = random_base64_url(32);
let mut authorize_url = self.auth_url.clone();
authorize_url
.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("code_challenge_method", "S256")
.append_pair("client_id", &self.client_id)
.append_pair("redirect_uri", redirect_uri.as_str())
.append_pair("code_challenge", &code_challenge)
.append_pair("scope", DEFAULT_SCOPE)
.append_pair("state", &state);
Ok(PendingAuthorization {
authorize_url,
redirect_uri,
state,
code_verifier,
listener,
})
}
pub async fn exchange_code(
&self,
code: &str,
redirect_uri: &Url,
code_verifier: &str,
) -> Result<OpenAITokenSet, OpenAIOAuthError> {
let body = self
.client
.post(self.token_url.clone())
.form(&BTreeMap::from([
("grant_type", "authorization_code"),
("client_id", self.client_id.as_str()),
("redirect_uri", redirect_uri.as_str()),
("code", code),
("code_verifier", code_verifier),
]))
.send()
.await?;
parse_token_response(body).await
}
pub async fn refresh_tokens(
&self,
refresh_token: &str,
) -> Result<OpenAITokenSet, OpenAIOAuthError> {
let body = self
.client
.post(self.token_url.clone())
.form(&BTreeMap::from([
("grant_type", "refresh_token"),
("client_id", self.client_id.as_str()),
("refresh_token", refresh_token),
]))
.send()
.await?;
parse_token_response(body).await
}
}
pub struct PendingAuthorization {
authorize_url: Url,
redirect_uri: Url,
state: String,
code_verifier: String,
listener: TcpListener,
}
impl PendingAuthorization {
pub fn authorize_url(&self) -> &Url {
&self.authorize_url
}
pub fn redirect_uri(&self) -> &Url {
&self.redirect_uri
}
pub async fn complete(
self,
client: &OpenAIOAuthClient,
) -> Result<OpenAITokenSet, OpenAIOAuthError> {
let code = timeout(CALLBACK_TIMEOUT, receive_code(self.listener, &self.state))
.await
.map_err(|_| OpenAIOAuthError::CallbackTimeout)??;
client
.exchange_code(&code, &self.redirect_uri, &self.code_verifier)
.await
}
}
async fn parse_token_response(
response: reqwest::Response,
) -> Result<OpenAITokenSet, OpenAIOAuthError> {
if !response.status().is_success() {
return Err(OpenAIOAuthError::Http {
status: response.status(),
body: response.text().await.unwrap_or_default(),
});
}
let body = response.json::<TokenResponse>().await?;
Ok(body.into_tokens())
}
async fn receive_code(
listener: TcpListener,
expected_state: &str,
) -> Result<String, OpenAIOAuthError> {
let (mut stream, _) = listener.accept().await?;
let mut buffer = [0_u8; 8192];
let bytes_read = stream.read(&mut buffer).await?;
let request = String::from_utf8_lossy(&buffer[..bytes_read]);
let path = request
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.unwrap_or("/");
let callback_url = Url::parse(&format!("http://localhost{path}"))?;
let params: BTreeMap<_, _> = callback_url.query_pairs().into_owned().collect();
let response = if params
.get("state")
.is_some_and(|state| state == expected_state)
{
success_response().to_string()
} else {
error_response("State mismatch")
};
stream.write_all(response.as_bytes()).await?;
stream.shutdown().await?;
if params
.get("state")
.is_none_or(|state| state != expected_state)
{
return Err(OpenAIOAuthError::StateMismatch);
}
params
.get("code")
.cloned()
.ok_or(OpenAIOAuthError::MissingCode)
}
fn loopback_redirect_uri(addr: SocketAddr) -> Result<Url, OpenAIOAuthError> {
Url::parse(&format!(
"http://{}:{}{CALLBACK_PATH}",
addr.ip(),
addr.port()
))
.map_err(Into::into)
}
fn pkce_s256(verifier: &str) -> String {
URL_SAFE_NO_PAD.encode(digest(&SHA256, verifier.as_bytes()))
}
fn random_base64_url(len: usize) -> String {
let mut bytes = vec![0_u8; len];
rand::rng().fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
fn success_response() -> &'static str {
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\r\n<!doctype html><html><body><h1>Authorization complete</h1><p>You can return to Mentra.</p></body></html>"
}
fn error_response(message: &str) -> String {
format!(
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/html; charset=utf-8\r\n\r\n<!doctype html><html><body><h1>Authorization failed</h1><p>{message}</p></body></html>"
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_expiry_uses_refresh_skew() {
let tokens = OpenAITokenSet {
access_token: "access".into(),
refresh_token: "refresh".into(),
id_token: None,
api_key: Some("api".into()),
expires_at: OffsetDateTime::now_utc() + Duration::seconds(30),
};
assert!(tokens.is_expired(Duration::seconds(60)));
assert!(!tokens.is_expired(Duration::seconds(5)));
}
#[test]
fn pkce_challenge_is_url_safe() {
let challenge = pkce_s256("test-verifier");
assert!(!challenge.contains('='));
assert!(!challenge.contains('+'));
assert!(!challenge.contains('/'));
}
}

View File

@@ -0,0 +1,136 @@
use std::sync::Arc;
use async_trait::async_trait;
use time::Duration;
use tokio::sync::Mutex;
use crate::{
auth::openai::{
OpenAIOAuthClient, OpenAIOAuthError, OpenAITokenSet, PendingAuthorization,
PersistentTokenStoreKind, TokenStore, persistent_token_store,
},
provider::openai::OpenAICredentialSource,
};
pub struct OpenAIOAuthCredentialSource {
client: OpenAIOAuthClient,
tokens: Mutex<OpenAITokenSet>,
store: Option<Arc<dyn TokenStore>>,
refresh_skew: Duration,
}
impl OpenAIOAuthCredentialSource {
pub fn new(client: OpenAIOAuthClient, tokens: OpenAITokenSet) -> Self {
Self {
client,
tokens: Mutex::new(tokens),
store: None,
refresh_skew: Duration::seconds(60),
}
}
pub fn with_store(mut self, store: Arc<dyn TokenStore>) -> Self {
self.store = Some(store);
self
}
pub fn with_refresh_skew(mut self, refresh_skew: Duration) -> Self {
self.refresh_skew = refresh_skew;
self
}
pub fn from_store(
client: OpenAIOAuthClient,
store: Arc<dyn TokenStore>,
) -> Result<Self, OpenAIOAuthError> {
let tokens = store.load()?.ok_or(OpenAIOAuthError::MissingStoredTokens)?;
Ok(Self::new(client, tokens).with_store(store))
}
pub fn from_persistent_store(
client: OpenAIOAuthClient,
kind: PersistentTokenStoreKind,
) -> Result<Self, OpenAIOAuthError> {
Self::from_store(client, persistent_token_store(kind))
}
pub fn from_default_persistent_store(
client: OpenAIOAuthClient,
) -> Result<Self, OpenAIOAuthError> {
Self::from_persistent_store(client, PersistentTokenStoreKind::Auto)
}
pub async fn from_store_or_authorize<F>(
client: OpenAIOAuthClient,
store: Arc<dyn TokenStore>,
on_pending_authorization: F,
) -> Result<Self, OpenAIOAuthError>
where
F: FnOnce(&PendingAuthorization),
{
match Self::from_store(client.clone(), store.clone()) {
Ok(source) => Ok(source),
Err(OpenAIOAuthError::MissingStoredTokens) => {
let pending = client.start_authorization().await?;
on_pending_authorization(&pending);
let tokens = pending.complete(&client).await?;
store.save(&tokens)?;
Ok(Self::new(client, tokens).with_store(store))
}
Err(error) => Err(error),
}
}
pub async fn from_persistent_store_or_authorize<F>(
client: OpenAIOAuthClient,
kind: PersistentTokenStoreKind,
on_pending_authorization: F,
) -> Result<Self, OpenAIOAuthError>
where
F: FnOnce(&PendingAuthorization),
{
Self::from_store_or_authorize(
client,
persistent_token_store(kind),
on_pending_authorization,
)
.await
}
pub async fn from_default_persistent_store_or_authorize<F>(
client: OpenAIOAuthClient,
on_pending_authorization: F,
) -> Result<Self, OpenAIOAuthError>
where
F: FnOnce(&PendingAuthorization),
{
Self::from_persistent_store_or_authorize(
client,
PersistentTokenStoreKind::Auto,
on_pending_authorization,
)
.await
}
async fn current_api_key(&self) -> Result<String, OpenAIOAuthError> {
let mut tokens = self.tokens.lock().await;
if tokens.is_expired(self.refresh_skew) {
let refreshed = self.client.refresh_tokens(&tokens.refresh_token).await?;
if let Some(store) = &self.store {
store.save(&refreshed)?;
}
*tokens = refreshed;
}
Ok(tokens.require_api_key()?.to_string())
}
}
#[async_trait]
impl OpenAICredentialSource for OpenAIOAuthCredentialSource {
async fn api_key(&self) -> Result<String, String> {
self.current_api_key()
.await
.map_err(|error| error.to_string())
}
}

348
vendor/mentra/src/auth/openai/store.rs vendored Normal file
View File

@@ -0,0 +1,348 @@
use std::{
fs,
io::Write,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
#[cfg(target_os = "macos")]
use std::process::Command;
use directories::BaseDirs;
use crate::auth::openai::{OpenAIOAuthError, OpenAITokenSet};
const DEFAULT_KEYCHAIN_SERVICE: &str = "com.mentra.openai";
const DEFAULT_KEYCHAIN_ACCOUNT: &str = "default";
#[cfg(target_os = "macos")]
const KEYCHAIN_NOT_FOUND_EXIT_CODE: i32 = 44;
pub trait TokenStore: Send + Sync {
fn load(&self) -> Result<Option<OpenAITokenSet>, OpenAIOAuthError>;
fn save(&self, tokens: &OpenAITokenSet) -> Result<(), OpenAIOAuthError>;
fn clear(&self) -> Result<(), OpenAIOAuthError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PersistentTokenStoreKind {
Auto,
File,
Keychain,
}
impl PersistentTokenStoreKind {
pub fn label(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::File => "file",
Self::Keychain => "keychain",
}
}
}
#[derive(Clone, Default)]
pub struct MemoryTokenStore {
state: Arc<Mutex<Option<OpenAITokenSet>>>,
}
impl MemoryTokenStore {
pub fn new() -> Self {
Self::default()
}
}
impl TokenStore for MemoryTokenStore {
fn load(&self) -> Result<Option<OpenAITokenSet>, OpenAIOAuthError> {
Ok(self
.state
.lock()
.expect("memory token store poisoned")
.clone())
}
fn save(&self, tokens: &OpenAITokenSet) -> Result<(), OpenAIOAuthError> {
*self.state.lock().expect("memory token store poisoned") = Some(tokens.clone());
Ok(())
}
fn clear(&self) -> Result<(), OpenAIOAuthError> {
*self.state.lock().expect("memory token store poisoned") = None;
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct FileTokenStore {
path: PathBuf,
}
impl FileTokenStore {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn default_path() -> PathBuf {
let base = BaseDirs::new()
.map(|dirs| dirs.data_local_dir().to_path_buf())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
base.join("mentra").join("auth").join("openai.json")
}
pub fn path(&self) -> &Path {
&self.path
}
}
impl Default for FileTokenStore {
fn default() -> Self {
Self::new(Self::default_path())
}
}
impl TokenStore for FileTokenStore {
fn load(&self) -> Result<Option<OpenAITokenSet>, OpenAIOAuthError> {
match fs::read_to_string(&self.path) {
Ok(contents) => Ok(Some(serde_json::from_str(&contents)?)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(OpenAIOAuthError::Io(error)),
}
}
fn save(&self, tokens: &OpenAITokenSet) -> Result<(), OpenAIOAuthError> {
if let Some(parent) = self.path.parent() {
fs::create_dir_all(parent)?;
}
#[cfg(unix)]
let mut file = {
use std::os::unix::fs::OpenOptionsExt;
fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
.open(&self.path)?
};
#[cfg(not(unix))]
let mut file = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&self.path)?;
let payload = serde_json::to_vec_pretty(tokens)?;
file.write_all(&payload)?;
file.flush()?;
Ok(())
}
fn clear(&self) -> Result<(), OpenAIOAuthError> {
match fs::remove_file(&self.path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(OpenAIOAuthError::Io(error)),
}
}
}
#[derive(Debug, Clone)]
pub struct KeychainTokenStore {
#[cfg(target_os = "macos")]
service: String,
#[cfg(target_os = "macos")]
account: String,
}
impl KeychainTokenStore {
#[cfg(target_os = "macos")]
pub fn new(service: impl Into<String>, account: impl Into<String>) -> Self {
Self {
service: service.into(),
account: account.into(),
}
}
#[cfg(not(target_os = "macos"))]
pub fn new(service: impl Into<String>, account: impl Into<String>) -> Self {
let _ = (service.into(), account.into());
Self {}
}
pub fn default_service() -> &'static str {
DEFAULT_KEYCHAIN_SERVICE
}
pub fn default_account() -> &'static str {
DEFAULT_KEYCHAIN_ACCOUNT
}
}
impl Default for KeychainTokenStore {
fn default() -> Self {
Self::new(Self::default_service(), Self::default_account())
}
}
impl TokenStore for KeychainTokenStore {
fn load(&self) -> Result<Option<OpenAITokenSet>, OpenAIOAuthError> {
#[cfg(target_os = "macos")]
{
let output = Command::new("security")
.args([
"find-generic-password",
"-a",
&self.account,
"-s",
&self.service,
"-w",
])
.output()?;
if output.status.success() {
let secret = String::from_utf8_lossy(&output.stdout);
return Ok(Some(serde_json::from_str(secret.trim())?));
}
if output.status.code() == Some(KEYCHAIN_NOT_FOUND_EXIT_CODE) {
return Ok(None);
}
Err(command_error(output, "security"))
}
#[cfg(not(target_os = "macos"))]
{
let _ = self;
Err(OpenAIOAuthError::UnsupportedStore("keychain"))
}
}
fn save(&self, tokens: &OpenAITokenSet) -> Result<(), OpenAIOAuthError> {
#[cfg(target_os = "macos")]
{
let payload = serde_json::to_string(tokens)?;
let output = Command::new("security")
.args([
"add-generic-password",
"-U",
"-a",
&self.account,
"-s",
&self.service,
"-w",
&payload,
])
.output()?;
if output.status.success() {
return Ok(());
}
Err(command_error(output, "security"))
}
#[cfg(not(target_os = "macos"))]
{
let _ = tokens;
Err(OpenAIOAuthError::UnsupportedStore("keychain"))
}
}
fn clear(&self) -> Result<(), OpenAIOAuthError> {
#[cfg(target_os = "macos")]
{
let output = Command::new("security")
.args([
"delete-generic-password",
"-a",
&self.account,
"-s",
&self.service,
])
.output()?;
if output.status.success() || output.status.code() == Some(KEYCHAIN_NOT_FOUND_EXIT_CODE)
{
return Ok(());
}
Err(command_error(output, "security"))
}
#[cfg(not(target_os = "macos"))]
{
Err(OpenAIOAuthError::UnsupportedStore("keychain"))
}
}
}
pub fn persistent_token_store(kind: PersistentTokenStoreKind) -> Arc<dyn TokenStore> {
match selected_store_kind(kind) {
PersistentTokenStoreKind::File => Arc::new(FileTokenStore::default()),
PersistentTokenStoreKind::Keychain => Arc::new(KeychainTokenStore::default()),
PersistentTokenStoreKind::Auto => unreachable!("auto should resolve to a concrete store"),
}
}
pub fn selected_store_kind(kind: PersistentTokenStoreKind) -> PersistentTokenStoreKind {
match kind {
PersistentTokenStoreKind::Auto => {
if cfg!(target_os = "macos") {
PersistentTokenStoreKind::Keychain
} else {
PersistentTokenStoreKind::File
}
}
other => other,
}
}
#[cfg(target_os = "macos")]
fn command_error(output: std::process::Output, command: &'static str) -> OpenAIOAuthError {
OpenAIOAuthError::CredentialCommand {
command,
status: output.status.code().unwrap_or(-1),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
}
}
#[cfg(test)]
mod tests {
use time::{Duration, OffsetDateTime};
use super::*;
#[test]
fn memory_store_round_trips_tokens() {
let store = MemoryTokenStore::new();
let tokens = OpenAITokenSet {
access_token: "access".into(),
refresh_token: "refresh".into(),
id_token: Some("id".into()),
api_key: Some("api".into()),
expires_at: OffsetDateTime::now_utc() + Duration::seconds(60),
};
store.save(&tokens).expect("save tokens");
assert_eq!(
store
.load()
.expect("load tokens")
.expect("missing tokens")
.api_key,
Some("api".into())
);
}
#[test]
fn auto_store_resolves_to_platform_backend() {
let resolved = selected_store_kind(PersistentTokenStoreKind::Auto);
if cfg!(target_os = "macos") {
assert_eq!(resolved, PersistentTokenStoreKind::Keychain);
} else {
assert_eq!(resolved, PersistentTokenStoreKind::File);
}
}
}

417
vendor/mentra/src/background.rs vendored Normal file
View File

@@ -0,0 +1,417 @@
mod hook;
mod observer;
mod store;
use std::{
collections::HashMap,
path::PathBuf,
sync::{
Arc, Mutex,
atomic::{AtomicU64, Ordering},
},
};
use serde::{Deserialize, Serialize};
use strum::Display;
use crate::agent::AgentEvent;
use crate::runtime::{
RuntimeStore,
control::{CommandOutput, CommandRequest, RuntimeExecutor},
};
pub(crate) use hook::BackgroundHookSink;
pub(crate) use observer::{BackgroundObserverSink, BackgroundRegistration};
pub use store::BackgroundStore;
const OUTPUT_PREVIEW_MAX_CHARS: usize = 500;
const NOTIFICATION_PENDING: i64 = 0;
const NOTIFICATION_ACKED: i64 = 2;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display)]
#[strum(serialize_all = "snake_case")]
#[serde(rename_all = "snake_case")]
pub enum BackgroundTaskStatus {
Running,
Finished,
Failed,
Interrupted,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackgroundTaskSummary {
pub id: String,
pub command: String,
pub cwd: PathBuf,
pub status: BackgroundTaskStatus,
pub output_preview: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BackgroundNotification {
pub task_id: String,
pub command: String,
pub cwd: PathBuf,
pub status: BackgroundTaskStatus,
pub output_preview: String,
}
#[derive(Clone)]
pub(crate) struct BackgroundTaskManager {
inner: Arc<BackgroundTaskManagerInner>,
}
struct BackgroundTaskManagerInner {
store: Arc<dyn RuntimeStore>,
executor: Arc<dyn RuntimeExecutor>,
hooks: Arc<dyn BackgroundHookSink>,
next_task_id: AtomicU64,
state: Mutex<BackgroundTaskManagerState>,
}
#[derive(Default)]
struct BackgroundTaskManagerState {
agents: HashMap<String, AgentBackgroundState>,
}
#[derive(Default)]
struct AgentBackgroundState {
tasks: Vec<BackgroundTaskSummary>,
observer: Option<BackgroundObserver>,
}
#[derive(Clone)]
struct BackgroundObserver {
sink: Arc<dyn BackgroundObserverSink>,
}
impl BackgroundTaskManager {
pub(crate) fn new(
store: Arc<dyn RuntimeStore>,
executor: Arc<dyn RuntimeExecutor>,
hooks: Arc<dyn BackgroundHookSink>,
) -> Self {
Self {
inner: Arc::new(BackgroundTaskManagerInner {
store,
executor,
hooks,
next_task_id: AtomicU64::default(),
state: Mutex::new(BackgroundTaskManagerState::default()),
}),
}
}
pub(crate) fn register_agent(&self, registration: BackgroundRegistration) {
let BackgroundRegistration { agent_id, observer } = registration;
let tasks = {
let mut state = self
.inner
.state
.lock()
.expect("background manager poisoned");
let agent = state.agents.entry(agent_id.clone()).or_default();
agent.tasks = self
.inner
.store
.load_background_tasks(&agent_id)
.unwrap_or_default();
agent.observer = Some(BackgroundObserver {
sink: observer.clone(),
});
agent.tasks.clone()
};
observer.publish_snapshot(&tasks);
}
pub(crate) fn start_task(
&self,
agent_id: &str,
request: CommandRequest,
) -> Result<BackgroundTaskSummary, String> {
let task_id = format!(
"bg-{}",
self.inner.next_task_id.fetch_add(1, Ordering::Relaxed) + 1
);
let summary = BackgroundTaskSummary {
id: task_id.clone(),
command: request.spec.display().to_string(),
cwd: request.cwd.clone(),
status: BackgroundTaskStatus::Running,
output_preview: None,
};
let _ = self
.inner
.store
.upsert_background_task(agent_id, &summary, NOTIFICATION_ACKED);
let (observer, tasks) = {
let mut state = self
.inner
.state
.lock()
.expect("background manager poisoned");
let agent = state.agents.entry(agent_id.to_string()).or_default();
agent.tasks.push(summary.clone());
(agent.observer.clone(), agent.tasks.clone())
};
self.publish_observer(
observer,
tasks,
AgentEvent::BackgroundTaskStarted {
task: summary.clone(),
},
);
let _ =
self.inner
.hooks
.task_started(agent_id, &summary.id, &summary.command, &summary.cwd);
let manager = self.clone();
let agent_id = agent_id.to_string();
let executor = self.inner.executor.clone();
tokio::spawn(async move {
let completed = execute_task(task_id, request, executor).await;
manager.finish_task(&agent_id, completed);
});
Ok(summary)
}
pub(crate) fn running_task_count(&self, agent_id: &str) -> usize {
let state = self
.inner
.state
.lock()
.expect("background manager poisoned");
state
.agents
.get(agent_id)
.map(|agent| {
agent
.tasks
.iter()
.filter(|task| task.status == BackgroundTaskStatus::Running)
.count()
})
.unwrap_or(0)
}
pub(crate) fn drain_notifications(&self, agent_id: &str) -> Vec<BackgroundNotification> {
self.inner
.store
.drain_background_notifications(agent_id)
.unwrap_or_default()
}
pub(crate) fn has_pending_notifications(&self, agent_id: &str) -> bool {
self.inner
.store
.has_pending_background_notifications(agent_id)
.unwrap_or(false)
}
pub(crate) fn has_deliverable_notifications(&self, agent_id: &str) -> bool {
self.inner
.store
.has_deliverable_background_notifications(agent_id)
.unwrap_or(false)
}
pub(crate) fn requeue_notifications(
&self,
agent_id: &str,
notifications: Vec<BackgroundNotification>,
) {
if notifications.is_empty() {
return;
}
let _ = self.inner.store.requeue_background_notifications(agent_id);
}
pub(crate) fn acknowledge_notifications(&self, agent_id: &str) {
let _ = self.inner.store.ack_background_notifications(agent_id);
}
pub(crate) fn check_task(
&self,
agent_id: &str,
task_id: Option<&str>,
) -> Result<String, String> {
let state = self
.inner
.state
.lock()
.expect("background manager poisoned");
let Some(agent) = state.agents.get(agent_id) else {
return Ok("No background tasks.".to_string());
};
if let Some(task_id) = task_id {
let task = agent
.tasks
.iter()
.find(|task| task.id == task_id)
.ok_or_else(|| format!("Unknown background task {task_id}"))?;
return Ok(render_task_detail(task));
}
if agent.tasks.is_empty() {
return Ok("No background tasks.".to_string());
}
Ok(agent
.tasks
.iter()
.map(render_task_summary)
.collect::<Vec<_>>()
.join("\n"))
}
fn finish_task(&self, agent_id: &str, completed: CompletedBackgroundTask) {
let summary = BackgroundTaskSummary {
id: completed.id.clone(),
command: completed.command.clone(),
cwd: completed.cwd.clone(),
status: completed.status.clone(),
output_preview: Some(completed.output_preview.clone()),
};
let (observer, tasks) = {
let mut state = self
.inner
.state
.lock()
.expect("background manager poisoned");
let agent = state.agents.entry(agent_id.to_string()).or_default();
if let Some(existing) = agent.tasks.iter_mut().find(|task| task.id == summary.id) {
*existing = summary.clone();
} else {
agent.tasks.push(summary.clone());
}
(agent.observer.clone(), agent.tasks.clone())
};
let _ = self
.inner
.store
.upsert_background_task(agent_id, &summary, NOTIFICATION_PENDING);
let status = summary.status.to_string();
let _ = self
.inner
.hooks
.task_finished(agent_id, &summary.id, &status);
self.publish_observer(
observer,
tasks,
AgentEvent::BackgroundTaskFinished { task: summary },
);
}
fn publish_observer(
&self,
observer: Option<BackgroundObserver>,
tasks: Vec<BackgroundTaskSummary>,
event: AgentEvent,
) {
let Some(observer) = observer else {
return;
};
observer.sink.publish_snapshot(&tasks);
observer.sink.publish_event(event);
}
}
struct CompletedBackgroundTask {
id: String,
command: String,
cwd: PathBuf,
status: BackgroundTaskStatus,
output_preview: String,
}
async fn execute_task(
id: String,
request: CommandRequest,
executor: Arc<dyn RuntimeExecutor>,
) -> CompletedBackgroundTask {
let command = request.spec.display().to_string();
let cwd = request.cwd.clone();
match executor.run(request).await {
Ok(output) => completed_task_from_output(id, command, cwd, output),
Err(error) => CompletedBackgroundTask {
id,
command,
cwd,
status: BackgroundTaskStatus::Failed,
output_preview: truncate_preview(&error),
},
}
}
fn completed_task_from_output(
id: String,
command: String,
cwd: PathBuf,
output: CommandOutput,
) -> CompletedBackgroundTask {
let combined = format!("{} {}", output.stdout, output.stderr);
let preview = if combined.trim().is_empty() {
"(no output)".to_string()
} else {
truncate_preview(&combined)
};
let status = if output.success() {
BackgroundTaskStatus::Finished
} else {
BackgroundTaskStatus::Failed
};
CompletedBackgroundTask {
id,
command,
cwd,
status,
output_preview: preview,
}
}
fn truncate_preview(text: &str) -> String {
let mut compact = String::new();
for (index, chunk) in text.split_whitespace().enumerate() {
if index > 0 {
compact.push(' ');
}
compact.push_str(chunk);
}
let mut truncated = compact
.chars()
.take(OUTPUT_PREVIEW_MAX_CHARS)
.collect::<String>();
if compact.chars().count() > OUTPUT_PREVIEW_MAX_CHARS {
truncated.push_str("...");
}
truncated
}
fn render_task_summary(task: &BackgroundTaskSummary) -> String {
format!(
"{}: [{}] cwd={} {}",
task.id,
task.status,
task.cwd.display(),
task.command
)
}
fn render_task_detail(task: &BackgroundTaskSummary) -> String {
let output = task.output_preview.as_deref().unwrap_or("(running)");
format!(
"[{}] cwd={}\n{}\n{}",
task.status,
task.cwd.display(),
task.command,
output
)
}

20
vendor/mentra/src/background/hook.rs vendored Normal file
View File

@@ -0,0 +1,20 @@
use std::path::Path;
use crate::error::RuntimeError;
pub(crate) trait BackgroundHookSink: Send + Sync {
fn task_started(
&self,
agent_id: &str,
task_id: &str,
command: &str,
cwd: &Path,
) -> Result<(), RuntimeError>;
fn task_finished(
&self,
agent_id: &str,
task_id: &str,
status: &str,
) -> Result<(), RuntimeError>;
}

View File

@@ -0,0 +1,16 @@
use std::sync::Arc;
use crate::agent::AgentEvent;
use super::BackgroundTaskSummary;
pub(crate) trait BackgroundObserverSink: Send + Sync {
fn publish_snapshot(&self, tasks: &[BackgroundTaskSummary]);
fn publish_event(&self, event: AgentEvent);
}
#[derive(Clone)]
pub(crate) struct BackgroundRegistration {
pub(crate) agent_id: String,
pub(crate) observer: Arc<dyn BackgroundObserverSink>,
}

27
vendor/mentra/src/background/store.rs vendored Normal file
View File

@@ -0,0 +1,27 @@
use crate::error::RuntimeError;
use super::{BackgroundNotification, BackgroundTaskSummary};
pub trait BackgroundStore: Send + Sync {
fn load_background_tasks(
&self,
agent_id: &str,
) -> Result<Vec<BackgroundTaskSummary>, RuntimeError>;
fn upsert_background_task(
&self,
agent_id: &str,
task: &BackgroundTaskSummary,
notification_state: i64,
) -> Result<(), RuntimeError>;
fn drain_background_notifications(
&self,
agent_id: &str,
) -> Result<Vec<BackgroundNotification>, RuntimeError>;
fn has_deliverable_background_notifications(
&self,
agent_id: &str,
) -> Result<bool, RuntimeError>;
fn has_pending_background_notifications(&self, agent_id: &str) -> Result<bool, RuntimeError>;
fn ack_background_notifications(&self, agent_id: &str) -> Result<(), RuntimeError>;
fn requeue_background_notifications(&self, agent_id: &str) -> Result<(), RuntimeError>;
}

775
vendor/mentra/src/compaction.rs vendored Normal file
View File

@@ -0,0 +1,775 @@
#[cfg(test)]
mod tests;
use std::{
borrow::Cow,
collections::HashSet,
path::Path,
path::PathBuf,
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use async_trait::async_trait;
use regex::Regex;
use crate::{
ContentBlock, Message,
error::RuntimeError,
provider::{
CompactionInputItem, CompactionRequest as ProviderCompactionRequest,
CompactionResponse as ProviderCompactionResponse, Provider, ProviderError,
ProviderRequestOptions, Request,
},
transcript::{AgentTranscript, CompactionSummary, TranscriptItem, TranscriptKind},
};
/// Context mechanically extracted from transcript items before summarization.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ExtractedContext {
pub files_touched: Vec<String>,
pub verification_outcomes: Vec<String>,
pub permission_decisions: Vec<String>,
}
/// Scan transcript items to extract file paths, verification outcomes, and permission decisions.
pub fn extract_context(items: &[TranscriptItem]) -> ExtractedContext {
use std::sync::LazyLock;
static FILE_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?:^|[\s"'`(,])([a-zA-Z0-9_.][a-zA-Z0-9_./\-]*\.[a-zA-Z]{1,10})"#)
.expect("valid regex literal")
});
static VERIFICATION_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)(cargo\s+test|pytest|npm\s+test|jest|mocha|go\s+test|make\s+test|rspec|yarn\s+test).*?(pass|fail|error|ok|success|FAILED|PASSED)",
)
.expect("valid regex literal")
});
static PERMISSION_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)(permission|allowed|denied|approved|rejected|authorized)")
.expect("valid regex literal")
});
let file_re = &*FILE_RE;
let verification_re = &*VERIFICATION_RE;
let permission_re = &*PERMISSION_RE;
let mut files_seen = HashSet::new();
let mut files = Vec::new();
let mut verifications = Vec::new();
let mut permissions = Vec::new();
for item in items {
let text = item.text();
let is_tool_exchange = matches!(item.kind, TranscriptKind::ToolExchange { .. });
if is_tool_exchange {
for cap in file_re.captures_iter(&text) {
if let Some(m) = cap.get(1) {
let path = m.as_str().to_string();
if files_seen.insert(path.clone()) {
files.push(path);
}
}
}
}
for line in text.lines() {
if verification_re.is_match(line) {
let trimmed = line.trim().to_string();
if !trimmed.is_empty() {
verifications.push(trimmed);
}
}
if permission_re.is_match(line) {
let trimmed = line.trim().to_string();
if !trimmed.is_empty() {
permissions.push(trimmed);
}
}
}
}
ExtractedContext {
files_touched: files,
verification_outcomes: verifications,
permission_decisions: permissions,
}
}
/// Format extracted context as a text preamble for the compaction prompt.
pub fn format_extracted_context(ctx: &ExtractedContext) -> String {
let mut sections = Vec::new();
if !ctx.files_touched.is_empty() {
let mut section = String::from("FILES TOUCHED (must preserve):\n");
for f in &ctx.files_touched {
section.push_str("- ");
section.push_str(f);
section.push('\n');
}
sections.push(section);
}
if !ctx.verification_outcomes.is_empty() {
let mut section = String::from("VERIFICATION OUTCOMES (must preserve):\n");
for v in &ctx.verification_outcomes {
section.push_str("- ");
section.push_str(v);
section.push('\n');
}
sections.push(section);
}
if !ctx.permission_decisions.is_empty() {
let mut section = String::from("PERMISSION DECISIONS (must preserve):\n");
for p in &ctx.permission_decisions {
section.push_str("- ");
section.push_str(p);
section.push('\n');
}
sections.push(section);
}
sections.join("\n")
}
/// Diagnostics captured during a compaction operation.
#[derive(Debug, Clone)]
pub struct CompactionDiagnostics {
pub items_before: usize,
pub items_after: usize,
pub approx_tokens_before: usize,
pub approx_tokens_after: usize,
pub preserved_user_turns: usize,
pub preserved_delegation_results: usize,
pub extracted_facts_count: usize,
pub summary_preview: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum CompactionMode {
#[default]
LocalOnly,
PreferRemote,
RemoteOnly,
}
#[derive(Debug, Clone)]
pub struct CompactionRequest {
pub model: String,
pub transcript: AgentTranscript,
pub transcript_dir: PathBuf,
pub summary_max_input_chars: usize,
pub summary_max_output_tokens: u32,
pub preserve_recent_user_tokens: usize,
pub preserve_recent_delegation_results: usize,
pub provider_request_options: ProviderRequestOptions,
pub mode: CompactionMode,
pub max_persisted_transcripts: Option<usize>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompactionExecutionMode {
Local,
Remote,
}
/// The result of one compaction: a replacement [`AgentTranscript`] plus
/// counts describing how it was built from the original.
///
/// **Metadata-preservation guarantee (mentra ADR-0001 §6).** Every item that
/// survives into `transcript` — the untouched continuation tail, salvaged
/// recent user turns, and salvaged recent delegation results — is copied
/// **verbatim** from the original transcript, so any opaque
/// [`TranscriptItem::details`] a host attached survives bit-for-bit. This
/// holds by construction: mentra never rebuilds a preserved item from its
/// projected [`crate::Message`] (which would drop `details`, a field that
/// exists only on `TranscriptItem`); it clones the original item.
///
/// This guarantee is scoped to *preserved* items only. An item inside the
/// summarized prefix that is **not** salvaged is replaced by the
/// [`CompactionSummary`] along with the rest of its content — its `details`
/// go with it. That is honest, documented behavior, not a violation: the
/// contract never promises to resurrect a discarded item's metadata, only to
/// never silently drop it from one that was kept. The full pre-compaction
/// transcript — discarded items included — is written to `transcript_path`
/// before summarization runs, so a host that needs a discarded item's
/// `details` after the fact can still recover them from that snapshot.
#[derive(Debug, Clone)]
pub struct CompactionOutcome {
pub mode: CompactionExecutionMode,
/// Path to the `.jsonl` snapshot of the **entire pre-compaction**
/// transcript, one [`TranscriptItem`] per line, written before
/// summarization runs. Every item's `details` round-trips through this
/// file, including items the compacted `transcript` goes on to discard —
/// this is the recovery artifact for the summarized prefix.
pub transcript_path: PathBuf,
pub transcript: AgentTranscript,
pub summary: CompactionSummary,
/// Count of original items in the summarized prefix
/// (`required_tail_start_for_continuation`'s `preserve_from` split).
/// This counts every item in that prefix, **including** ones also
/// salvaged into `transcript` by `preserved_user_turns` /
/// `preserved_delegation_results` — from this count's point of view they
/// were replaced by the summary, even though their content (and
/// `details`) survives verbatim elsewhere in the replacement transcript.
/// It does not mean "gone".
pub replaced_items: usize,
/// Count of items kept strictly because they are the untouched
/// continuation tail (outside the summarized prefix), independent of
/// `preserved_user_turns` / `preserved_delegation_results` below, which
/// count salvaged items pulled *out of* the summarized prefix instead.
/// The three counts are disjoint by construction.
pub preserved_items: usize,
/// Count of recent user turns salvaged out of the summarized prefix and
/// copied verbatim (details included) into the replacement transcript.
pub preserved_user_turns: usize,
/// Count of recent delegation results salvaged out of the summarized
/// prefix and copied verbatim (details included) into the replacement
/// transcript.
pub preserved_delegation_results: usize,
pub diagnostics: CompactionDiagnostics,
}
/// Compacts an agent transcript into a shorter one carrying a summary of the
/// discarded portion. See [`CompactionOutcome`] for the metadata-preservation
/// contract every implementation must uphold: `details` on any item that
/// survives compaction (tail, salvaged user turns, salvaged delegation
/// results) is preserved bit-for-bit; `details` on a discarded, unsalvaged
/// item is honestly gone with the rest of that item's content, recoverable
/// only from the pre-compaction snapshot at [`CompactionOutcome::transcript_path`].
#[async_trait]
pub trait CompactionEngine: Send + Sync {
async fn compact(
&self,
provider: Arc<dyn Provider>,
request: CompactionRequest,
) -> Result<Option<CompactionOutcome>, RuntimeError>;
}
/// The default [`CompactionEngine`]: summarizes the compactable prefix of a
/// transcript (locally via the provider's chat completion, or remotely via
/// [`Provider::compact`] when supported), while keeping the continuation
/// tail and a bounded number of recent user turns and delegation results
/// verbatim — including their opaque `details` — per the
/// [`CompactionOutcome`] contract.
#[derive(Debug, Default)]
pub struct StandardCompactionEngine;
#[async_trait]
impl CompactionEngine for StandardCompactionEngine {
async fn compact(
&self,
provider: Arc<dyn Provider>,
request: CompactionRequest,
) -> Result<Option<CompactionOutcome>, RuntimeError> {
if request.transcript.is_empty() {
return Ok(None);
}
let items = request.transcript.items();
let protected_tail_start = required_tail_start_for_continuation(items);
// When the protected tail *is* the whole transcript there is nothing
// older to summarize, and compaction used to give up — leaving an
// over-budget turn with no way out. It can be summarized as a unit,
// but only when the thing pinning the tail is a tool call and its
// result: those must travel together, and summarizing both at once
// orphans nothing.
//
// A bare user turn is deliberately excluded. Replacing the user's
// actual instruction with a summary of itself, before the model has
// even read it, loses the very thing the turn exists to convey.
let split_turn = protected_tail_start == 0 && ends_in_tool_exchange(items);
let (compacted_prefix, tail_start) = if split_turn {
(items, items.len())
} else {
(&items[..protected_tail_start], protected_tail_start)
};
if compacted_prefix.is_empty() {
return Ok(None);
}
let transcript_path =
persist_transcript(request.transcript.items(), &request.transcript_dir).await?;
if let Some(max) = request.max_persisted_transcripts {
let _ = cleanup_old_transcripts(&request.transcript_dir, max).await;
}
let supports_remote = provider.capabilities().supports_history_compaction;
let (mode, mut summary) = match request.mode {
CompactionMode::LocalOnly => (
CompactionExecutionMode::Local,
summarize_locally(provider, &request, compacted_prefix).await?,
),
CompactionMode::PreferRemote => {
if supports_remote {
match compact_remotely(provider.clone(), &request, compacted_prefix).await {
Ok(Some(summary)) => (CompactionExecutionMode::Remote, summary),
Ok(None)
| Err(RuntimeError::FailedToCompactHistory(
ProviderError::UnsupportedCapability(_),
)) => (
CompactionExecutionMode::Local,
summarize_locally(provider, &request, compacted_prefix).await?,
),
Err(error) => return Err(error),
}
} else {
(
CompactionExecutionMode::Local,
summarize_locally(provider, &request, compacted_prefix).await?,
)
}
}
CompactionMode::RemoteOnly => {
if !supports_remote {
return Err(RuntimeError::FailedToCompactHistory(
ProviderError::UnsupportedCapability("history_compaction".to_string()),
));
}
(
CompactionExecutionMode::Remote,
compact_remotely(provider, &request, compacted_prefix)
.await?
.ok_or_else(|| {
RuntimeError::FailedToCompactHistory(
ProviderError::UnsupportedCapability(
"history_compaction".to_string(),
),
)
})?,
)
}
};
let items_before = request.transcript.len();
let tokens_before = approx_token_count_items(request.transcript.items());
let preserved_user_turns =
select_recent_user_turns(compacted_prefix, request.preserve_recent_user_tokens);
let preserved_delegation_results = select_recent_delegation_results(
compacted_prefix,
request.preserve_recent_delegation_results,
);
let extracted = extract_context(compacted_prefix);
let extracted_facts_count = extracted.files_touched.len()
+ extracted.verification_outcomes.len()
+ extracted.permission_decisions.len();
// Union with whatever the previous compaction recorded, so the set
// grows monotonically instead of being re-derived from a prefix that
// no longer contains the older tool exchanges.
summary.files_touched = accumulate_files(
carried_files(items),
extracted.files_touched.iter().map(String::as_str),
);
let mut replacement = Vec::new();
replacement.extend(preserved_user_turns.iter().cloned());
for item in &preserved_delegation_results {
if !replacement.contains(item) {
replacement.push(item.clone());
}
}
replacement.push(TranscriptItem::compaction_summary(summary.clone()));
replacement.extend_from_slice(&items[tail_start..]);
let items_after = replacement.len();
let tokens_after = approx_token_count_items(&replacement);
let summary_preview = summary
.render_for_handoff()
.chars()
.take(200)
.collect::<String>();
let diagnostics = CompactionDiagnostics {
items_before,
items_after,
approx_tokens_before: tokens_before,
approx_tokens_after: tokens_after,
preserved_user_turns: preserved_user_turns.len(),
preserved_delegation_results: preserved_delegation_results.len(),
extracted_facts_count,
summary_preview,
};
Ok(Some(CompactionOutcome {
mode,
transcript_path,
transcript: AgentTranscript::new(replacement),
summary,
replaced_items: compacted_prefix.len(),
preserved_items: request.transcript.len().saturating_sub(tail_start),
preserved_user_turns: preserved_user_turns.len(),
preserved_delegation_results: preserved_delegation_results.len(),
diagnostics,
}))
}
}
pub(crate) fn compaction_request_from_agent(
model: &str,
transcript: AgentTranscript,
config: &crate::agent::CompactionConfig,
provider_request_options: ProviderRequestOptions,
) -> CompactionRequest {
CompactionRequest {
model: model.to_string(),
transcript,
transcript_dir: config.transcript_dir.clone(),
summary_max_input_chars: config.summary_max_input_chars,
summary_max_output_tokens: config.summary_max_output_tokens,
preserve_recent_user_tokens: config.preserve_recent_user_tokens,
preserve_recent_delegation_results: config.preserve_recent_delegation_results,
provider_request_options,
mode: config.mode,
max_persisted_transcripts: config.max_persisted_transcripts,
}
}
async fn summarize_locally(
provider: Arc<dyn Provider>,
request: &CompactionRequest,
items: &[TranscriptItem],
) -> Result<CompactionSummary, RuntimeError> {
let summary_items = items_without_thinking(items);
let serialized =
serde_json::to_string(&summary_items).map_err(RuntimeError::FailedToSerializeTranscript)?;
let transcript = truncate_to_char_boundary(&serialized, request.summary_max_input_chars);
let extracted = extract_context(items);
let context_preamble = format_extracted_context(&extracted);
let system = "\
You are a coding-session compaction engine. Your job is to compress an agent transcript \
into a structured JSON summary that preserves all operationally critical context for \
session continuity.\n\n\
You MUST preserve:\n\
- All file paths that were read, written, or modified\n\
- Shell command outcomes (build results, test pass/fail, lint output)\n\
- Permission decisions (what was allowed, denied, or deferred)\n\
- Architectural decisions and their rationale\n\
- Constraints and invariants discovered during the session\n\
- Current working state (what is done, what is in progress, what remains)\n\
- Error states and how they were resolved\n\
- Delegated work outcomes and pending delegations\n\n\
Return strict JSON with keys: goal, progress, decisions, constraints, \
delegated_work, artifacts, open_questions, next_steps.\n\
Each key should contain concrete, specific information -- not vague summaries.\n\
File paths, command outputs, and error messages should be quoted verbatim.";
let mut prompt = String::new();
if !context_preamble.is_empty() {
prompt.push_str("=== EXTRACTED FACTS (must preserve verbatim) ===\n");
prompt.push_str(&context_preamble);
prompt.push_str("\n=== END EXTRACTED FACTS ===\n\n");
}
prompt.push_str("Summarize this agent transcript for continuity and multi-agent handoff. Preserve goal, progress, concrete decisions, constraints, delegated work outcomes, artifacts, open questions, and next steps.\n\nTranscript JSON:\n");
prompt.push_str(transcript);
let response = provider
.send(Request {
model: Cow::Borrowed(request.model.as_str()),
system: Some(Cow::Borrowed(system)),
messages: Cow::Owned(vec![Message::user(ContentBlock::text(prompt))]),
tools: Cow::Owned(Vec::new()),
tool_choice: None,
temperature: None,
max_output_tokens: Some(request.summary_max_output_tokens),
metadata: Cow::Owned(Default::default()),
provider_request_options: request.provider_request_options.clone(),
})
.await
.map_err(RuntimeError::FailedToCompactHistory)?;
let text = response
.content
.into_iter()
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(text),
_ => None,
})
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string();
if text.is_empty() {
return Ok(CompactionSummary::default());
}
serde_json::from_str(&text)
.unwrap_or_else(|_| CompactionSummary::from_fallback_text(text))
.pipe(Ok)
}
fn items_without_thinking(items: &[TranscriptItem]) -> Vec<TranscriptItem> {
items
.iter()
.cloned()
.map(|mut item| {
if let Some(message) = item.message.as_mut() {
message
.content
.retain(|block| !matches!(block, ContentBlock::Thinking { .. }));
}
item
})
.collect()
}
async fn compact_remotely(
provider: Arc<dyn Provider>,
request: &CompactionRequest,
items: &[TranscriptItem],
) -> Result<Option<CompactionSummary>, RuntimeError> {
let input = items
.iter()
.map(project_compaction_item)
.collect::<Vec<_>>();
let response = provider
.compact(ProviderCompactionRequest {
model: Cow::Borrowed(request.model.as_str()),
instructions: Cow::Borrowed(
"Compact this transcript into a continuity handoff that preserves delegated work.",
),
input: Cow::Owned(input),
metadata: Cow::Owned(Default::default()),
provider_request_options: request.provider_request_options.clone(),
})
.await
.map_err(RuntimeError::FailedToCompactHistory)?;
Ok(parse_remote_summary(response))
}
fn parse_remote_summary(response: ProviderCompactionResponse) -> Option<CompactionSummary> {
response
.output
.into_iter()
.rev()
.find_map(|item| match item {
CompactionInputItem::CompactionSummary { content } => serde_json::from_str(&content)
.ok()
.or_else(|| Some(CompactionSummary::from_fallback_text(content))),
_ => None,
})
}
fn project_compaction_item(item: &TranscriptItem) -> CompactionInputItem {
match &item.kind {
TranscriptKind::UserTurn => CompactionInputItem::UserTurn {
content: item.text(),
},
TranscriptKind::AssistantTurn => CompactionInputItem::AssistantTurn {
content: item.text(),
},
TranscriptKind::ToolExchange { is_error, .. } => CompactionInputItem::ToolExchange {
request: None,
result: item.text(),
is_error: *is_error,
},
TranscriptKind::CanonicalContext => CompactionInputItem::CanonicalContext {
content: item.text(),
},
TranscriptKind::MemoryRecall => CompactionInputItem::MemoryRecall {
content: item.text(),
},
TranscriptKind::DelegationRequest { delegation, .. }
| TranscriptKind::DelegationResult { delegation, .. } => {
CompactionInputItem::DelegationResult {
agent_id: delegation.agent_id.clone(),
agent_name: delegation.agent_name.clone(),
role: delegation.role.clone(),
status: format!("{:?}", delegation.status).to_lowercase(),
content: item.text(),
}
}
TranscriptKind::CompactionSummary { summary } => CompactionInputItem::CompactionSummary {
content: summary.render_for_handoff(),
},
}
}
/// Whether the transcript ends with a tool exchange, i.e. the pinned tail is
/// a tool call and its result rather than a plain turn.
fn ends_in_tool_exchange(items: &[TranscriptItem]) -> bool {
items
.last()
.is_some_and(|item| matches!(item.kind, TranscriptKind::ToolExchange { .. }))
}
/// The file list recorded by the newest compaction already in `items`.
///
/// `extract_context` only scans tool exchanges, and a compaction summary is
/// not one, so without this the previous round's findings would be invisible
/// to the next.
fn carried_files(items: &[TranscriptItem]) -> Vec<String> {
items
.iter()
.rev()
.find_map(|item| match &item.kind {
TranscriptKind::CompactionSummary { summary } => Some(summary.files_touched.clone()),
_ => None,
})
.unwrap_or_default()
}
/// Unions two file lists, preserving first-seen order and dropping repeats.
fn accumulate_files<'a>(carried: Vec<String>, fresh: impl Iterator<Item = &'a str>) -> Vec<String> {
let mut seen: HashSet<String> = carried.iter().cloned().collect();
let mut files = carried;
for path in fresh {
if seen.insert(path.to_string()) {
files.push(path.to_string());
}
}
files
}
fn select_recent_user_turns(items: &[TranscriptItem], token_budget: usize) -> Vec<TranscriptItem> {
let mut selected = Vec::new();
let mut remaining = token_budget;
for item in items.iter().rev() {
if !item.is_real_user_turn() {
continue;
}
let tokens = approx_token_count(&item.text());
if tokens > remaining && !selected.is_empty() {
break;
}
remaining = remaining.saturating_sub(tokens);
selected.push(item.clone());
if remaining == 0 {
break;
}
}
selected.reverse();
selected
}
fn select_recent_delegation_results(
items: &[TranscriptItem],
max_items: usize,
) -> Vec<TranscriptItem> {
let mut selected = items
.iter()
.filter(|item| item.is_delegation_result())
.rev()
.take(max_items)
.cloned()
.collect::<Vec<_>>();
selected.reverse();
selected
}
fn required_tail_start_for_continuation(items: &[TranscriptItem]) -> usize {
let Some(last_index) = items.len().checked_sub(1) else {
return 0;
};
let last = &items[last_index];
if matches!(last.kind, TranscriptKind::ToolExchange { .. })
&& last_index > 0
&& matches!(items[last_index - 1].kind, TranscriptKind::AssistantTurn)
{
last_index - 1
} else {
last_index
}
}
fn approx_token_count(text: &str) -> usize {
let char_estimate = text.chars().count().div_ceil(4);
let word_count = text.split_whitespace().count();
let word_estimate = ((word_count as f64) * 1.3).ceil() as usize;
char_estimate.max(word_estimate)
}
fn approx_token_count_items(items: &[TranscriptItem]) -> usize {
items
.iter()
.map(|item| approx_token_count(&item.text()))
.sum()
}
async fn persist_transcript(
transcript: &[TranscriptItem],
transcript_dir: &Path,
) -> Result<PathBuf, RuntimeError> {
tokio::fs::create_dir_all(transcript_dir)
.await
.map_err(RuntimeError::FailedToPersistTranscript)?;
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos();
let transcript_path = transcript_dir.join(format!("{timestamp}.jsonl"));
let mut serialized = String::new();
for item in transcript {
let line =
serde_json::to_string(item).map_err(RuntimeError::FailedToSerializeTranscript)?;
serialized.push_str(&line);
serialized.push('\n');
}
tokio::fs::write(&transcript_path, serialized)
.await
.map_err(RuntimeError::FailedToPersistTranscript)?;
Ok(transcript_path)
}
/// Removes the oldest transcript files in `dir` when count exceeds `keep`.
/// Files are sorted by filename (nanosecond timestamps → oldest first).
/// Delete errors are ignored — this is best-effort cleanup.
pub(crate) async fn cleanup_old_transcripts(dir: &Path, keep: usize) -> Result<(), RuntimeError> {
let mut read_dir = tokio::fs::read_dir(dir)
.await
.map_err(RuntimeError::FailedToPersistTranscript)?;
let mut files: Vec<std::path::PathBuf> = Vec::new();
while let Some(entry) = read_dir
.next_entry()
.await
.map_err(RuntimeError::FailedToPersistTranscript)?
{
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("jsonl") {
files.push(path);
}
}
if files.len() <= keep {
return Ok(());
}
// Sort ascending by filename — nanosecond timestamps put oldest first.
files.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
let to_delete = files.len() - keep;
for path in files.iter().take(to_delete) {
let _ = tokio::fs::remove_file(path).await;
}
Ok(())
}
fn truncate_to_char_boundary(input: &str, max_chars: usize) -> &str {
if input.chars().count() <= max_chars {
return input;
}
let mut end = input.len();
for (index, _) in input.char_indices().take(max_chars + 1) {
end = index;
}
&input[..end]
}
trait Pipe: Sized {
fn pipe<T>(self, f: impl FnOnce(Self) -> T) -> T {
f(self)
}
}
impl<T> Pipe for T {}

678
vendor/mentra/src/compaction/tests.rs vendored Normal file
View File

@@ -0,0 +1,678 @@
use std::{
collections::BTreeMap,
sync::atomic::{AtomicU64, Ordering},
};
use serde_json::{Value, json};
use super::*;
use crate::{
ContentBlock, DelegationArtifact, DelegationKind, DelegationStatus, Message, ModelInfo, Role,
provider::{
ProviderDescriptor, ProviderEventStream, Response, provider_event_stream_from_response,
},
};
/// Asserts an entry survived compaction unchanged in identity and content.
///
/// `parent_id` is deliberately excluded: it records where an entry sits on the
/// active path, and compaction genuinely moves salvaged entries. Holding the
/// old link would leave it pointing at an entry the replacement transcript no
/// longer contains. Identity (`id`), content, and `details` must not move.
fn assert_same_entry(actual: &TranscriptItem, expected: &TranscriptItem, context: &str) {
assert_eq!(actual.id, expected.id, "{context} (identity)");
assert_eq!(actual.kind, expected.kind, "{context} (kind)");
assert_eq!(actual.message, expected.message, "{context} (message)");
assert_eq!(actual.details(), expected.details(), "{context} (details)");
}
fn tool_exchange_item(text: &str) -> TranscriptItem {
TranscriptItem::tool_exchange(
Message::user(ContentBlock::text(text)),
Some("tool_1".to_string()),
false,
)
}
fn user_turn_item(text: &str) -> TranscriptItem {
TranscriptItem::user_turn(Message::user(ContentBlock::text(text)))
}
#[test]
fn local_summary_projection_excludes_thinking_from_full_transcript_json() {
let items = vec![TranscriptItem::assistant_turn(Message {
role: Role::Assistant,
content: vec![
ContentBlock::Thinking {
thinking: "private chain".to_string(),
signature: Some("opaque-signature".to_string()),
encrypted_content: None,
id: None,
provenance: Some(crate::ReasoningProvenance {
provider: crate::ProviderId::new("anthropic"),
model: "claude-test".to_string(),
format: crate::ReasoningFormat::AnthropicSigned,
}),
redacted: false,
},
ContentBlock::text("visible answer"),
],
})];
let serialized = serde_json::to_string(&items_without_thinking(&items)).unwrap();
assert!(serialized.contains("visible answer"));
assert!(!serialized.contains("private chain"));
assert!(!serialized.contains("opaque-signature"));
assert!(!serialized.contains("Thinking"));
assert_eq!(items[0].message.as_ref().unwrap().content.len(), 2);
}
#[test]
fn extract_context_finds_file_paths_in_tool_exchanges() {
let items = vec![
tool_exchange_item("Reading file src/main.rs and also lib/utils.py"),
tool_exchange_item("Modified path/to/config.toml successfully"),
];
let ctx = extract_context(&items);
assert!(
ctx.files_touched.contains(&"src/main.rs".to_string()),
"should find src/main.rs, got: {:?}",
ctx.files_touched
);
assert!(
ctx.files_touched.contains(&"lib/utils.py".to_string()),
"should find lib/utils.py, got: {:?}",
ctx.files_touched
);
assert!(
ctx.files_touched
.contains(&"path/to/config.toml".to_string()),
"should find path/to/config.toml, got: {:?}",
ctx.files_touched
);
}
#[test]
fn extract_context_deduplicates_file_paths() {
let items = vec![
tool_exchange_item("Reading src/main.rs"),
tool_exchange_item("Writing src/main.rs again"),
];
let ctx = extract_context(&items);
let count = ctx
.files_touched
.iter()
.filter(|p| p.as_str() == "src/main.rs")
.count();
assert_eq!(count, 1, "file paths should be deduplicated");
}
#[test]
fn extract_context_ignores_file_paths_in_non_tool_items() {
let items = vec![user_turn_item("Please edit src/main.rs")];
let ctx = extract_context(&items);
assert!(
ctx.files_touched.is_empty(),
"user turns should not contribute file paths, got: {:?}",
ctx.files_touched
);
}
#[test]
fn extract_context_finds_verification_outcomes() {
let items = vec![
tool_exchange_item("Running: cargo test result: ok. 5 passed; 0 FAILED"),
tool_exchange_item("npm test completed with error code 1"),
];
let ctx = extract_context(&items);
assert!(
!ctx.verification_outcomes.is_empty(),
"should find verification outcomes"
);
assert!(
ctx.verification_outcomes
.iter()
.any(|v| v.contains("cargo test") || v.contains("FAILED")),
"should find cargo test outcome, got: {:?}",
ctx.verification_outcomes
);
}
#[test]
fn extract_context_finds_verification_in_any_item_kind() {
let items = vec![user_turn_item("cargo test result: 10 passed; 0 FAILED")];
let ctx = extract_context(&items);
assert!(
!ctx.verification_outcomes.is_empty(),
"verification outcomes should be found in any item kind"
);
}
#[test]
fn extract_context_finds_permission_decisions() {
let items = vec![tool_exchange_item(
"Permission denied for writing to /etc/hosts",
)];
let ctx = extract_context(&items);
assert!(
!ctx.permission_decisions.is_empty(),
"should find permission decisions"
);
}
#[test]
fn format_extracted_context_empty_produces_empty_string() {
let ctx = ExtractedContext::default();
let formatted = format_extracted_context(&ctx);
assert!(formatted.is_empty());
}
#[test]
fn format_extracted_context_includes_all_sections() {
let ctx = ExtractedContext {
files_touched: vec!["src/main.rs".to_string()],
verification_outcomes: vec!["cargo test passed".to_string()],
permission_decisions: vec!["write permission denied".to_string()],
};
let formatted = format_extracted_context(&ctx);
assert!(formatted.contains("FILES TOUCHED"));
assert!(formatted.contains("src/main.rs"));
assert!(formatted.contains("VERIFICATION OUTCOMES"));
assert!(formatted.contains("cargo test passed"));
assert!(formatted.contains("PERMISSION DECISIONS"));
assert!(formatted.contains("write permission denied"));
}
#[test]
fn approx_token_count_uses_larger_of_two_heuristics() {
// Short words: "a b c d" = 4 words * 1.3 = 5.2 -> 6, chars = 7 / 4 = 2
assert!(approx_token_count("a b c d") >= 6);
// Long word: "abcdefghijklmnop" = 1 word * 1.3 = 2, chars = 16 / 4 = 4
assert!(approx_token_count("abcdefghijklmnop") >= 4);
}
#[test]
fn approx_token_count_empty_string() {
assert_eq!(approx_token_count(""), 0);
}
#[test]
fn approx_token_count_items_sums_correctly() {
let items = vec![
user_turn_item("hello world"),
tool_exchange_item("some tool output"),
];
let total = approx_token_count_items(&items);
let expected = approx_token_count("hello world") + approx_token_count("some tool output");
assert_eq!(total, expected);
}
// -------------------------------------------------------------------
// M5: metadata-preserving compaction (mentra ADR-0001 §6)
// -------------------------------------------------------------------
fn delegation_result_item(label: &str) -> TranscriptItem {
TranscriptItem::delegation_result(
Message::user(ContentBlock::text(format!("{label} done"))),
DelegationArtifact {
kind: DelegationKind::Subagent,
agent_id: format!("agent-{label}"),
agent_name: label.to_string(),
role: None,
status: DelegationStatus::Finished,
task_summary: format!("{label} task"),
result_summary: None,
artifacts: Vec::new(),
},
None,
)
}
fn with_marker(item: TranscriptItem, key: &str, value: Value) -> TranscriptItem {
item.with_details(BTreeMap::from([(key.to_string(), value)]))
}
// Regression test 1/2: proves `select_recent_user_turns` copies its
// selections verbatim rather than rebuilding them from `Message`. A
// regression that swapped `item.clone()` for something like
// `TranscriptItem::user_turn(item.message.clone().unwrap())` would
// produce items with `details: None` here, and the derived `PartialEq`
// (which compares every field, `details` included) would catch it.
#[test]
fn select_recent_user_turns_copies_items_verbatim_details_included() {
let older = with_marker(user_turn_item("older"), "older", json!({ "keep": "older" }));
let newer = with_marker(user_turn_item("newer"), "newer", json!({ "keep": "newer" }));
let items = vec![
older.clone(),
tool_exchange_item("not a user turn"),
newer.clone(),
];
let selected = select_recent_user_turns(&items, 20_000);
assert_eq!(selected, vec![older, newer]);
}
// Regression test 2/2: same property for
// `select_recent_delegation_results`.
#[test]
fn select_recent_delegation_results_copies_items_verbatim_details_included() {
let first = with_marker(delegation_result_item("first"), "first", json!({ "n": 1 }));
let second = with_marker(
delegation_result_item("second"),
"second",
json!({ "n": 2 }),
);
let items = vec![
first.clone(),
user_turn_item("not a delegation result"),
second.clone(),
];
let selected = select_recent_delegation_results(&items, 8);
assert_eq!(selected, vec![first, second]);
}
#[tokio::test]
async fn persist_transcript_snapshot_carries_every_items_details_bit_for_bit() {
let items = vec![
with_marker(user_turn_item("kept"), "kept", json!({ "n": 1 })),
with_marker(
tool_exchange_item("about to be discarded"),
"about-to-be-discarded",
json!({ "n": 2 }),
),
TranscriptItem::assistant_turn(Message::assistant(ContentBlock::text("no details here"))),
];
let dir = temp_dir("persist-transcript-details");
let path = persist_transcript(&items, &dir)
.await
.expect("persist snapshot");
let content = tokio::fs::read_to_string(&path)
.await
.expect("read snapshot");
let reloaded: Vec<TranscriptItem> = content
.lines()
.map(|line| serde_json::from_str(line).expect("valid TranscriptItem json"))
.collect();
assert_eq!(
reloaded, items,
"the pre-compaction snapshot must carry every item's details bit-for-bit, \
including items about to be discarded by summarization"
);
}
/// Minimal provider that returns one fixed local-summarization response —
/// enough to drive `StandardCompactionEngine::compact` end to end
/// without pulling in the full scripted-provider harness from
/// `agent::tests::support`, which is `pub(super)`-scoped to
/// `agent::tests` and unreachable from this module.
struct FixedSummaryProvider {
model: ModelInfo,
}
#[async_trait]
impl Provider for FixedSummaryProvider {
fn descriptor(&self) -> ProviderDescriptor {
ProviderDescriptor::new(self.model.provider.clone())
}
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
Ok(vec![self.model.clone()])
}
async fn stream(&self, _request: Request<'_>) -> Result<ProviderEventStream, ProviderError> {
Ok(provider_event_stream_from_response(Response {
id: "fixed-summary-response".to_string(),
model: self.model.id.clone(),
role: Role::Assistant,
content: vec![ContentBlock::text("test summary")],
stop_reason: None,
usage: None,
}))
}
}
#[tokio::test]
async fn compact_preserves_salvaged_details_and_lets_discarded_details_go_with_their_items() {
let model = ModelInfo::new("test-model", "test-provider");
let provider: Arc<dyn Provider> = Arc::new(FixedSummaryProvider {
model: model.clone(),
});
// Compacted-away prefix: one user turn and one delegation result
// that the engine salvages (and must copy verbatim, details
// included), plus one assistant turn and one tool exchange that are
// *not* salvaged and are honestly discarded along with their
// details.
let salvaged_user = with_marker(
user_turn_item("first message"),
"salvaged-user",
json!({ "keep": "u0" }),
);
let discarded_assistant =
TranscriptItem::assistant_turn(Message::assistant(ContentBlock::text("ack")));
let salvaged_delegation = with_marker(
delegation_result_item("helper"),
"salvaged-delegation",
json!({ "keep": "d0" }),
);
let discarded_tool_result = with_marker(
tool_exchange_item("stale tool output"),
"discarded-tool",
json!({ "drop": "t0" }),
);
// Continuation tail: kept untouched outside the compacted prefix
// (`required_tail_start_for_continuation` keeps the final
// assistant tool_use + tool result pair intact).
let tail_assistant =
TranscriptItem::assistant_turn(Message::assistant(ContentBlock::ToolUse {
id: "tail-1".to_string(),
name: "tail_tool".to_string(),
input: json!({}),
}));
let tail_result = with_marker(
TranscriptItem::tool_exchange(
Message::user(ContentBlock::text("tail tool output")),
Some("tail-1".to_string()),
false,
),
"tail-tool",
json!({ "keep": "tail" }),
);
let items = vec![
salvaged_user.clone(),
discarded_assistant.clone(),
salvaged_delegation.clone(),
discarded_tool_result.clone(),
tail_assistant.clone(),
tail_result.clone(),
];
let transcript = AgentTranscript::new(items.clone());
let transcript_dir = temp_dir("m5-compaction-salvage");
let request = CompactionRequest {
model: model.id.clone(),
transcript,
transcript_dir,
summary_max_input_chars: 100_000,
summary_max_output_tokens: 512,
preserve_recent_user_tokens: 20_000,
preserve_recent_delegation_results: 8,
provider_request_options: ProviderRequestOptions::default(),
mode: CompactionMode::LocalOnly,
max_persisted_transcripts: None,
};
let outcome = StandardCompactionEngine
.compact(provider, request)
.await
.expect("compaction should not error")
.expect("compaction should produce an outcome");
// Counts stay consistent with the documented semantics: the whole
// compacted prefix (4 items) counts as replaced even though two of
// its items are also salvaged; only the untouched tail (2 items)
// counts as preserved_items.
assert_eq!(
outcome.replaced_items, 4,
"the whole compacted prefix counts as replaced, salvaged items included"
);
assert_eq!(
outcome.preserved_items, 2,
"preserved_items counts only the untouched continuation tail"
);
assert_eq!(outcome.preserved_user_turns, 1);
assert_eq!(outcome.preserved_delegation_results, 1);
let replacement = outcome.transcript.items();
// Salvaged items survive verbatim, details included.
let replayed_user = replacement
.iter()
.find(|item| item.is_real_user_turn())
.expect("salvaged user turn present in the replacement transcript");
assert_same_entry(
replayed_user,
&salvaged_user,
"the salvaged user turn must survive bit-for-bit, details included",
);
let replayed_delegation = replacement
.iter()
.find(|item| item.is_delegation_result())
.expect("salvaged delegation result present in the replacement transcript");
assert_same_entry(
replayed_delegation,
&salvaged_delegation,
"the salvaged delegation result must survive bit-for-bit, details included",
);
// The untouched tail survives verbatim too.
assert_same_entry(
replacement.last().expect("a tail item"),
&tail_result,
"the untouched tail item must survive bit-for-bit, details included",
);
// Discarded items are honestly gone: their details never resurface
// on any other item in the replacement transcript.
let replacement_json =
serde_json::to_string(&replacement).expect("serialize replacement transcript");
assert!(
!replacement_json.contains("discarded-tool"),
"a discarded item's details must not leak into the replacement transcript, got: {replacement_json}"
);
// But the pre-compaction snapshot on disk still has everything,
// including the discarded item's details — the recovery artifact
// for the summarized prefix.
let snapshot = tokio::fs::read_to_string(&outcome.transcript_path)
.await
.expect("read pre-compaction snapshot");
let snapshot_items: Vec<TranscriptItem> = snapshot
.lines()
.map(|line| serde_json::from_str(line).expect("valid TranscriptItem json"))
.collect();
// Compared against the linked transcript rather than the raw vec the test
// assembled: appending to a transcript is what establishes an entry's
// parent, so only the linked form is what was ever snapshotted.
let linked = AgentTranscript::new(items.clone());
assert_eq!(
snapshot_items,
linked.items(),
"the pre-compaction snapshot must preserve every original item bit-for-bit, \
including ones the compaction goes on to discard"
);
}
static NEXT_TEST_DIR_ID: AtomicU64 = AtomicU64::new(1);
fn temp_dir(label: &str) -> PathBuf {
let unique = NEXT_TEST_DIR_ID.fetch_add(1, Ordering::Relaxed);
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after unix epoch")
.as_nanos();
std::env::temp_dir().join(format!(
"mentra-compaction-test-{label}-{timestamp}-{unique}"
))
}
/// Builds a request around `items` with generous, non-interfering budgets.
fn request_for(items: Vec<TranscriptItem>, label: &str, model: &ModelInfo) -> CompactionRequest {
CompactionRequest {
model: model.id.clone(),
transcript: AgentTranscript::new(items),
transcript_dir: temp_dir(label),
summary_max_input_chars: 100_000,
summary_max_output_tokens: 512,
preserve_recent_user_tokens: 20_000,
preserve_recent_delegation_results: 8,
provider_request_options: ProviderRequestOptions::default(),
mode: CompactionMode::LocalOnly,
max_persisted_transcripts: None,
}
}
fn fixed_provider(model: &ModelInfo) -> Arc<dyn Provider> {
Arc::new(FixedSummaryProvider {
model: model.clone(),
})
}
#[tokio::test]
async fn a_turn_pinned_by_a_tool_result_is_summarized_instead_of_refused() {
let model = ModelInfo::new("test-model", "test-provider");
// The whole transcript is one assistant tool call and its result: the
// continuation rule pins both, so there is nothing older to compact.
// This used to return `Ok(None)`, leaving an over-budget turn stuck.
let items = vec![
TranscriptItem::assistant_turn(Message::assistant(ContentBlock::ToolUse {
id: "only-1".to_string(),
name: "huge_tool".to_string(),
input: json!({}),
})),
TranscriptItem::tool_exchange(
Message::user(ContentBlock::text("an enormous tool result")),
Some("only-1".to_string()),
false,
),
];
let outcome = StandardCompactionEngine
.compact(
fixed_provider(&model),
request_for(items, "m5-split-turn", &model),
)
.await
.expect("compaction should not error")
.expect("an over-budget turn must still compact");
assert_eq!(outcome.replaced_items, 2, "both halves of the pair go");
assert_eq!(outcome.preserved_items, 0, "nothing is pinned any more");
// Crucially, the tool result is not left without its call.
let kinds: Vec<&TranscriptKind> = outcome
.transcript
.items()
.iter()
.map(|item| &item.kind)
.collect();
assert!(
!kinds
.iter()
.any(|kind| matches!(kind, TranscriptKind::ToolExchange { .. })),
"a tool result must never survive without the call that produced it"
);
assert!(
kinds
.iter()
.any(|kind| matches!(kind, TranscriptKind::CompactionSummary { .. })),
"the pair is replaced by a summary"
);
}
#[tokio::test]
async fn a_lone_user_turn_is_never_replaced_by_a_summary_of_itself() {
let model = ModelInfo::new("test-model", "test-provider");
let items = vec![user_turn_item("please do the thing")];
let outcome = StandardCompactionEngine
.compact(
fixed_provider(&model),
request_for(items, "m5-lone-user", &model),
)
.await
.expect("compaction should not error");
assert!(
outcome.is_none(),
"summarizing the user's only instruction would discard the very thing \
the turn exists to convey"
);
}
#[tokio::test]
async fn files_touched_accumulate_across_successive_compactions() {
let model = ModelInfo::new("test-model", "test-provider");
// A previous compaction already recorded a file that no surviving tool
// exchange mentions any more.
let earlier = CompactionSummary {
files_touched: vec!["src/old.rs".to_string()],
..CompactionSummary::default()
};
let items = vec![
TranscriptItem::compaction_summary(earlier),
tool_exchange_item("edited src/new.rs just now"),
TranscriptItem::assistant_turn(Message::assistant(ContentBlock::text("ack"))),
user_turn_item("carry on"),
];
let outcome = StandardCompactionEngine
.compact(
fixed_provider(&model),
request_for(items, "m5-cumulative-files", &model),
)
.await
.expect("compaction should not error")
.expect("an outcome");
assert!(
outcome
.summary
.files_touched
.contains(&"src/old.rs".to_string()),
"a file recorded by an earlier compaction must survive the next one; \
got {:?}",
outcome.summary.files_touched
);
assert!(
outcome
.summary
.files_touched
.contains(&"src/new.rs".to_string()),
"newly touched files must be added; got {:?}",
outcome.summary.files_touched
);
}
#[test]
fn accumulating_files_keeps_first_seen_order_and_drops_repeats() {
let merged = accumulate_files(
vec!["a.rs".to_string(), "b.rs".to_string()],
["b.rs", "c.rs"].into_iter(),
);
assert_eq!(merged, vec!["a.rs", "b.rs", "c.rs"]);
}
#[test]
fn carried_files_reads_the_newest_summary_only() {
let older = CompactionSummary {
files_touched: vec!["stale.rs".to_string()],
..CompactionSummary::default()
};
let newer = CompactionSummary {
files_touched: vec!["fresh.rs".to_string()],
..CompactionSummary::default()
};
let items = vec![
TranscriptItem::compaction_summary(older),
TranscriptItem::compaction_summary(newer),
];
// The newest summary is already cumulative, so reading only it is
// sufficient — and reading every summary would resurrect files an
// earlier round deliberately carried forward or dropped.
assert_eq!(carried_files(&items), vec!["fresh.rs".to_string()]);
}

143
vendor/mentra/src/default_paths.rs vendored Normal file
View File

@@ -0,0 +1,143 @@
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
path::{Path, PathBuf},
};
#[cfg(not(test))]
use directories::BaseDirs;
const APP_DIR_NAME: &str = "mentra";
const WORKSPACES_DIR_NAME: &str = "workspaces";
const TEAM_DIR_NAME: &str = "team";
const TASKS_DIR_NAME: &str = "tasks";
const TRANSCRIPTS_DIR_NAME: &str = "transcripts";
const FALLBACK_DIR_NAME: &str = ".mentra";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct WorkspaceDefaultPaths {
pub(crate) root_dir: PathBuf,
pub(crate) default_store_path: PathBuf,
pub(crate) team_dir: PathBuf,
pub(crate) tasks_dir: PathBuf,
pub(crate) transcripts_dir: PathBuf,
}
#[cfg(not(test))]
pub(crate) fn workspace_default_paths() -> WorkspaceDefaultPaths {
workspace_default_paths_for(canonical_workspace_dir(), platform_data_local_dir())
}
pub(crate) fn workspace_default_paths_for(
workspace_dir: PathBuf,
data_local_dir: Option<PathBuf>,
) -> WorkspaceDefaultPaths {
let workspace_dir = canonicalize_or_original(workspace_dir);
let workspace_hash = workspace_hash(&workspace_dir);
let root_dir = match data_local_dir {
Some(data_local_dir) => data_local_dir
.join(APP_DIR_NAME)
.join(WORKSPACES_DIR_NAME)
.join(workspace_hash),
None => workspace_dir
.join(FALLBACK_DIR_NAME)
.join(WORKSPACES_DIR_NAME)
.join(workspace_hash),
};
WorkspaceDefaultPaths {
default_store_path: root_dir.join("runtime.sqlite"),
team_dir: root_dir.join(TEAM_DIR_NAME),
tasks_dir: root_dir.join(TASKS_DIR_NAME),
transcripts_dir: root_dir.join(TRANSCRIPTS_DIR_NAME),
root_dir,
}
}
#[cfg(not(test))]
fn platform_data_local_dir() -> Option<PathBuf> {
BaseDirs::new().map(|dirs| dirs.data_local_dir().to_path_buf())
}
#[cfg(not(test))]
fn canonical_workspace_dir() -> PathBuf {
canonicalize_or_original(std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
}
fn canonicalize_or_original(path: PathBuf) -> PathBuf {
path.canonicalize().unwrap_or(path)
}
fn workspace_hash(path: &Path) -> String {
let mut hasher = DefaultHasher::new();
path.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
#[cfg(test)]
mod tests {
use super::*;
fn test_path(label: &str) -> PathBuf {
std::env::temp_dir()
.join("mentra-default-paths-tests")
.join(label)
}
#[test]
fn uses_platform_data_directory_when_available() {
let workspace = test_path("release-check-workspace");
let data_dir = test_path("release-check-data");
let paths = workspace_default_paths_for(workspace.clone(), Some(data_dir.clone()));
assert!(
paths
.root_dir
.starts_with(data_dir.join(APP_DIR_NAME).join(WORKSPACES_DIR_NAME))
);
assert!(paths.root_dir.ends_with(workspace_hash(&workspace)));
assert_eq!(
paths.default_store_path,
paths.root_dir.join("runtime.sqlite")
);
assert_eq!(paths.team_dir, paths.root_dir.join(TEAM_DIR_NAME));
assert_eq!(paths.tasks_dir, paths.root_dir.join(TASKS_DIR_NAME));
assert_eq!(
paths.transcripts_dir,
paths.root_dir.join(TRANSCRIPTS_DIR_NAME)
);
}
#[test]
fn falls_back_to_workspace_dot_directory_without_platform_data_dir() {
let workspace = test_path("fallback-check-workspace");
let paths = workspace_default_paths_for(workspace.clone(), None);
assert_eq!(
paths.root_dir,
workspace
.join(FALLBACK_DIR_NAME)
.join(WORKSPACES_DIR_NAME)
.join(workspace_hash(&workspace))
);
}
#[test]
fn same_workspace_produces_shared_root_for_all_default_paths() {
let workspace = test_path("shared-root-workspace");
let data_dir = test_path("shared-root-data");
let paths = workspace_default_paths_for(workspace, Some(data_dir));
for derived_path in [
&paths.default_store_path,
&paths.team_dir,
&paths.tasks_dir,
&paths.transcripts_dir,
] {
assert!(derived_path.starts_with(&paths.root_dir));
}
}
}

84
vendor/mentra/src/lib.rs vendored Normal file
View File

@@ -0,0 +1,84 @@
#![doc = include_str!("../README.md")]
mod default_paths;
pub use mentra_provider as provider_core;
/// Agent configuration, lifecycle, and event handling.
pub mod agent;
/// Optional OAuth helpers for provider authentication.
#[cfg(feature = "openai-oauth")]
pub mod auth;
/// Background task coordination types and services.
pub mod background;
/// Transcript compaction engine and related types.
pub mod compaction;
/// Model Context Protocol (MCP) client and tool bridge.
pub mod mcp;
/// Working-memory journal and long-term memory services.
pub mod memory;
/// Provider integrations and transport-neutral request/response types.
pub mod provider;
/// Runtime orchestration, persistence, policies, and agent APIs.
pub mod runtime;
/// Session types, metadata, and event stream primitives.
pub mod session;
/// Team coordination types and collaboration services.
pub mod team;
/// Optional test helpers for deterministic scripted runtimes.
#[cfg(any(test, feature = "test-utils"))]
pub mod test;
/// Tool traits, metadata, and builtin tools.
pub mod tool;
/// Canonical runtime transcript primitives.
pub mod transcript;
pub use mentra_provider::{
AnthropicRequestOptions, BuiltinProvider, ContentBlock, ContentBlockDelta, ContentBlockStart,
GeminiRequestOptions, ImageSource, Message, ModelInfo, ModelSelector, OpenAIRequestOptions,
ProviderCapabilities, ProviderCredentials, ProviderDefinition, ProviderDescriptor,
ProviderError, ProviderEvent, ProviderEventStream, ProviderId, ProviderRequestOptions,
ReasoningEffort, ReasoningFormat, ReasoningOptions, ReasoningProvenance, Request,
ResponsesRequestOptions, ResponsesStateMode, ResponsesTransport, RetryPolicy, Role, TokenUsage,
ToolChoice, ToolSearchMode, WireApi, collect_response_from_stream,
provider_event_stream_from_response,
};
pub use provider::{Provider, ProviderRegistry};
pub use agent::{
Agent, AgentConfig, AgentWaitFuture, AgentWaitHandle, FinalOutput, QueueMode, ReasoningChange,
RoundAdjustment, RoundBoundary, RoundContext, RoundDecision, RoundStrategy, RoundToolResult,
SpawnedAgentStatus, SpawnedAgentSummary, SteeringHandle, TerminalOutputSpec,
ToolResultPagingConfig,
};
pub use background::{BackgroundNotification, BackgroundTaskStatus, BackgroundTaskSummary};
pub use compaction::{CompactionEngine, CompactionMode, StandardCompactionEngine};
pub use mcp::{
McpClientError, McpManager, McpServerConfig, McpServerStatus, McpServerSummary, McpSseClient,
McpSseConfigError, McpSseError, McpSseLimits, McpSseServerConfig,
};
pub use runtime::{
AgentStore, AuditStore, HybridRuntimeStore, LeaseStore, NewTask, PermissionRuleStore, RunStore,
Runtime, RuntimeBuilder, RuntimePolicy, ShellValidationMode, SkillInfo, SkillLoadError,
TaskBoard, TaskBoardError, TaskPatch, TaskStore,
};
pub use session::{
PermissionDecision, PermissionRequest, RememberedRule, RuleKey, RuleStore, Session,
SessionEvent, SessionEventReceiver, SessionId, SessionMetadata, SessionPermissionHandle,
SessionStatus, SubagentHandle,
};
pub use team::{
TeamDispatch, TeamMemberStatus, TeamMemberSummary, TeamMessage, TeamMessageKind,
TeamProtocolRequestSummary, TeamProtocolStatus,
};
pub use tool::FileToolProfile;
pub use transcript::{
AgentTranscript, BranchError, CompactionSummary, DelegationArtifact, DelegationEdge,
DelegationKind, DelegationStatus, EntryId, TranscriptItem, TranscriptKind,
};
pub mod error {
pub use crate::provider::ProviderError;
pub use crate::runtime::{ErrorCategory, RuntimeError};
}

53
vendor/mentra/src/mcp.rs vendored Normal file
View File

@@ -0,0 +1,53 @@
//! Model Context Protocol (MCP) client support.
//!
//! This module provides generic MCP clients that connect to external MCP
//! servers, discover their tools, and bridge those tools into the Mentra
//! runtime tool system.
//!
//! # Transports
//!
//! Two transports are supported, chosen by which configuration type you use:
//!
//! - **stdio** — [`McpServerConfig`] spawns a child process and speaks JSON-RPC
//! over its standard input and output.
//! - **legacy HTTP+SSE** — [`McpSseServerConfig`] opens a long-lived
//! `text/event-stream` `GET` and posts JSON-RPC messages to a second URL that
//! the server names. This is the transport from protocol revision
//! 2024-11-05, not Streamable HTTP; see [`McpSseClient`] for the distinction.
//!
//! # Architecture
//!
//! These links use absolute paths because the module's documentation is merged
//! with the outer comment on its `pub mod` declaration, which resolves relative
//! links against the crate root rather than this module.
//!
//! - [`protocol`](crate::mcp::protocol) — JSON-RPC 2.0 and MCP protocol types
//! shared by both transports
//! - [`client`](crate::mcp::client) — stdio transport client for a single MCP
//! server process
//! - [`sse`](crate::mcp::sse) — legacy HTTP+SSE transport client
//! - [`bridge`](crate::mcp::bridge) — wraps MCP tools as Mentra
//! [`ExecutableTool`] instances
//! - [`manager`](crate::mcp::manager) — manages multiple MCP server connections
//! and lifecycle
//!
//! [`ExecutableTool`]: crate::tool::ExecutableTool
pub mod bridge;
pub mod client;
pub mod manager;
pub mod protocol;
pub mod sse;
#[cfg(test)]
mod registration_tests;
#[cfg(test)]
mod tests;
pub use bridge::{McpBridgedTool, mcp_tool_name, parse_mcp_tool_name};
pub use client::{McpClientError, McpStdioClient};
pub use manager::{McpManager, McpServerStatus, McpServerSummary};
pub use protocol::{McpServerConfig, McpToolDefinition};
pub use sse::client::{McpSseClient, McpSseError};
pub use sse::config::{McpSseConfigError, McpSseLimits, McpSseServerConfig, SecretString};
pub use sse::endpoint::EndpointError;

200
vendor/mentra/src/mcp/bridge.rs vendored Normal file
View File

@@ -0,0 +1,200 @@
//! Bridge that wraps MCP server tools as Mentra `ExecutableTool` instances.
use std::sync::Arc;
use async_trait::async_trait;
use serde_json::{Value, json};
use crate::tool::{
ParallelToolContext, RuntimeToolDescriptor, ToolApprovalCategory, ToolCapability,
ToolDefinition, ToolDurability, ToolExecutionCategory, ToolExecutor, ToolResult,
ToolSideEffectLevel,
};
use super::client::McpStdioClient;
use super::protocol::{McpToolCallResult, McpToolDefinition};
use super::sse::client::McpSseClient;
/// The transport-independent surface [`McpBridgedTool`] needs from a client.
///
/// Each transport reports failures with its own error type, so this trait
/// flattens them to a message rather than forcing a shared error enum on the
/// public clients.
///
/// This is a sealed trait: it is public only so that
/// [`McpBridgedTool::new`] can be generic over the transport, and it is not
/// implementable outside this crate.
#[async_trait]
pub trait McpToolClient: sealed::Sealed + Send + Sync {
/// Calls one tool, rendering any transport failure as a message.
async fn call_tool(
&self,
tool_name: &str,
arguments: Option<Value>,
) -> Result<McpToolCallResult, String>;
}
mod sealed {
/// Prevents outside implementations of [`super::McpToolClient`].
pub trait Sealed {}
impl Sealed for super::McpStdioClient {}
impl Sealed for super::McpSseClient {}
#[cfg(test)]
impl Sealed for crate::mcp::tests::SuccessfulMcpClient {}
}
#[async_trait]
impl McpToolClient for McpStdioClient {
async fn call_tool(
&self,
tool_name: &str,
arguments: Option<Value>,
) -> Result<McpToolCallResult, String> {
McpStdioClient::call_tool(self, tool_name, arguments)
.await
.map_err(|error| error.to_string())
}
}
#[async_trait]
impl McpToolClient for McpSseClient {
async fn call_tool(
&self,
tool_name: &str,
arguments: Option<Value>,
) -> Result<McpToolCallResult, String> {
McpSseClient::call_tool(self, tool_name, arguments)
.await
.map_err(|error| error.to_string())
}
}
/// Prefix applied to MCP tool names to namespace them.
const MCP_TOOL_PREFIX: &str = "mcp__";
/// Construct the namespaced tool name for an MCP tool.
pub fn mcp_tool_name(server_name: &str, tool_name: &str) -> String {
format!("{MCP_TOOL_PREFIX}{server_name}__{tool_name}")
}
/// Parse a namespaced MCP tool name back into `(server_name, tool_name)`.
pub fn parse_mcp_tool_name(name: &str) -> Option<(&str, &str)> {
let rest = name.strip_prefix(MCP_TOOL_PREFIX)?;
let (server, tool) = rest.split_once("__")?;
Some((server, tool))
}
/// A Mentra tool backed by an MCP server tool.
pub struct McpBridgedTool {
server_name: String,
tool_def: McpToolDefinition,
client: Arc<dyn McpToolClient>,
}
impl McpBridgedTool {
/// Wraps one tool from a connected MCP server.
///
/// The client is generic over the transport, so this accepts an
/// `Arc<McpStdioClient>` and an `Arc<McpSseClient>` alike.
pub fn new<C>(server_name: String, tool_def: McpToolDefinition, client: Arc<C>) -> Self
where
C: McpToolClient + 'static,
{
Self::from_client(server_name, tool_def, client)
}
fn from_client(
server_name: String,
tool_def: McpToolDefinition,
client: Arc<dyn McpToolClient>,
) -> Self {
Self {
server_name,
tool_def,
client,
}
}
#[cfg(test)]
pub(crate) fn new_for_test(
server_name: String,
tool_def: McpToolDefinition,
client: Arc<dyn McpToolClient>,
) -> Self {
Self::from_client(server_name, tool_def, client)
}
fn full_name(&self) -> String {
mcp_tool_name(&self.server_name, &self.tool_def.name)
}
}
impl std::fmt::Debug for McpBridgedTool {
/// Renders the bridged identity without reaching into the client, which
/// holds transport credentials.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpBridgedTool")
.field("name", &self.full_name())
.finish_non_exhaustive()
}
}
impl ToolDefinition for McpBridgedTool {
fn descriptor(&self) -> RuntimeToolDescriptor {
let description = self.tool_def.description.clone().unwrap_or_default();
let input_schema = self
.tool_def
.input_schema
.clone()
.unwrap_or_else(|| json!({"type": "object", "properties": {}}));
RuntimeToolDescriptor::builder(self.full_name())
.description(description)
.input_schema(input_schema)
.capability(ToolCapability::Custom(format!("mcp:{}", self.server_name)))
.side_effect_level(ToolSideEffectLevel::External)
.durability(ToolDurability::Ephemeral)
.execution_category(ToolExecutionCategory::ExclusiveLocalMutation)
.approval_category(ToolApprovalCategory::Process)
.build()
}
}
#[async_trait]
impl ToolExecutor for McpBridgedTool {
async fn execute(&self, _ctx: ParallelToolContext, input: Value) -> ToolResult {
let arguments = if input.is_null()
|| (input.is_object() && input.as_object().is_none_or(|o| o.is_empty()))
{
None
} else {
Some(input)
};
let result = self
.client
.call_tool(&self.tool_def.name, arguments)
.await
.map_err(|error| format!("MCP tool call failed: {error}"))?;
// Concatenate text content blocks into the result string.
let mut output = String::new();
for block in &result.content {
if let Some(text) = &block.text {
if !output.is_empty() {
output.push('\n');
}
output.push_str(text);
}
}
if result.is_error {
Err(output)
} else {
Ok(output)
}
}
}

354
vendor/mentra/src/mcp/client.rs vendored Normal file
View File

@@ -0,0 +1,354 @@
//! MCP stdio client — spawns a child process and communicates via JSON-RPC over stdin/stdout.
#[cfg(test)]
mod tests;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use serde::de::DeserializeOwned;
use serde_json::Value as JsonValue;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{Child, ChildStdin, Command};
use tokio::sync::{Mutex, oneshot};
use super::protocol::*;
/// Default timeout for the MCP `initialize` handshake.
const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10);
/// Default timeout for `tools/list`.
const LIST_TOOLS_TIMEOUT: Duration = Duration::from_secs(30);
/// Default timeout for `tools/call`.
const CALL_TOOL_TIMEOUT: Duration = Duration::from_secs(120);
/// Bound on how many `tools/list` pages are followed.
///
/// Cursors are opaque, so a server repeating one cannot be detected by value;
/// only a page bound stops the walk.
const MAX_TOOL_PAGES: usize = 1_000;
/// Errors from the MCP stdio client.
#[derive(Debug, thiserror::Error)]
pub enum McpClientError {
#[error("failed to spawn MCP server process: {0}")]
SpawnFailed(#[from] std::io::Error),
#[error("MCP server process has no stdin")]
NoStdin,
#[error("MCP server process has no stdout")]
NoStdout,
#[error("MCP server returned JSON-RPC error: {0}")]
JsonRpc(JsonRpcError),
#[error("timeout waiting for MCP response ({0:?})")]
Timeout(Duration),
#[error("MCP server process exited unexpectedly")]
ProcessExited,
#[error("failed to parse MCP response: {0}")]
ParseError(String),
#[error("MCP server kept paginating tools/list past {limit} pages")]
TooManyToolPages { limit: usize },
#[error("MCP client is already shut down")]
Shutdown,
}
type PendingMap = HashMap<u64, oneshot::Sender<Result<JsonValue, McpClientError>>>;
/// A running MCP stdio client connected to one server process.
pub struct McpStdioClient {
stdin: Mutex<ChildStdin>,
_child: Mutex<Child>,
next_id: AtomicU64,
pending: Arc<Mutex<PendingMap>>,
server_info: Option<McpServerInfo>,
tools: Vec<McpToolDefinition>,
server_name: String,
}
impl McpStdioClient {
/// Spawn the MCP server process and perform the `initialize` handshake.
pub async fn connect(config: &McpServerConfig) -> Result<Self, McpClientError> {
let mut cmd = Command::new(&config.command);
cmd.args(&config.args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null());
for (key, value) in &config.env {
cmd.env(key, value);
}
if let Some(cwd) = &config.cwd {
cmd.current_dir(cwd);
}
let mut child = cmd.spawn()?;
let stdin = child.stdin.take().ok_or(McpClientError::NoStdin)?;
let stdout = child.stdout.take().ok_or(McpClientError::NoStdout)?;
let pending: Arc<Mutex<PendingMap>> = Arc::new(Mutex::new(HashMap::new()));
// Spawn the reader task that routes responses to pending callers.
let pending_clone = pending.clone();
tokio::spawn(async move {
let mut reader = BufReader::new(stdout);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(resp) = serde_json::from_str::<JsonRpcResponse>(trimmed) {
// A response carries a result or an error. A server-initiated
// request such as `ping` also has an id, and without this
// check it would resolve the caller holding that id with a
// null result.
if resp.result.is_none() && resp.error.is_none() {
continue;
}
let id = match &resp.id {
JsonRpcId::Number(n) => *n,
_ => continue,
};
let mut pending = pending_clone.lock().await;
if let Some(tx) = pending.remove(&id) {
let result = if let Some(err) = resp.error {
Err(McpClientError::JsonRpc(err))
} else {
Ok(resp.result.unwrap_or(JsonValue::Null))
};
let _ = tx.send(result);
}
}
}
// When the reader exits, signal all pending callers.
let mut pending = pending_clone.lock().await;
for (_, tx) in pending.drain() {
let _ = tx.send(Err(McpClientError::ProcessExited));
}
});
let mut client = Self {
stdin: Mutex::new(stdin),
_child: Mutex::new(child),
next_id: AtomicU64::new(1),
pending,
server_info: None,
tools: Vec::new(),
server_name: config.name.clone(),
};
// Perform initialize handshake.
client.initialize().await?;
// Discover tools.
client.discover_tools().await?;
Ok(client)
}
/// Server name from the configuration.
pub fn server_name(&self) -> &str {
&self.server_name
}
/// Server info returned by the `initialize` handshake.
pub fn server_info(&self) -> Option<&McpServerInfo> {
self.server_info.as_ref()
}
/// Tools discovered from this server.
pub fn tools(&self) -> &[McpToolDefinition] {
&self.tools
}
/// Send a JSON-RPC request and wait for the response.
async fn call<P: serde::Serialize, R: DeserializeOwned>(
&self,
method: &str,
params: Option<P>,
timeout_duration: Duration,
) -> Result<R, McpClientError> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let params_value = params
.map(|p| serde_json::to_value(p).expect("serialize params"))
.filter(|v| !v.is_null());
let request = JsonRpcRequest::new(id, method, params_value);
let mut line = serde_json::to_string(&request).expect("serialize request");
line.push('\n');
let (tx, rx) = oneshot::channel();
{
let mut pending = self.pending.lock().await;
pending.insert(id, tx);
}
{
let mut stdin = self.stdin.lock().await;
if stdin.write_all(line.as_bytes()).await.is_err() || stdin.flush().await.is_err() {
// The request never reached the server, so drop its
// registration rather than leaving it to time out.
self.pending.lock().await.remove(&id);
return Err(McpClientError::ProcessExited);
}
}
let result = match tokio::time::timeout(timeout_duration, rx).await {
Ok(Ok(result)) => result?,
Ok(Err(_)) => return Err(McpClientError::ProcessExited),
Err(_) => {
// Remove the registration so a timed-out request cannot leak an
// entry for the lifetime of the connection.
self.pending.lock().await.remove(&id);
return Err(McpClientError::Timeout(timeout_duration));
}
};
serde_json::from_value(result)
.map_err(|e| McpClientError::ParseError(format!("deserialize response: {e}")))
}
/// Send a JSON-RPC notification (no response expected).
async fn notify<P: serde::Serialize>(
&self,
method: &str,
params: Option<P>,
) -> Result<(), McpClientError> {
// Notifications have no id — use a raw object.
let mut obj = serde_json::json!({
"jsonrpc": "2.0",
"method": method,
});
if let Some(p) = params {
obj["params"] = serde_json::to_value(p).expect("serialize params");
}
let mut line = serde_json::to_string(&obj).expect("serialize notification");
line.push('\n');
let mut stdin = self.stdin.lock().await;
stdin
.write_all(line.as_bytes())
.await
.map_err(|_| McpClientError::ProcessExited)?;
stdin
.flush()
.await
.map_err(|_| McpClientError::ProcessExited)?;
Ok(())
}
async fn initialize(&mut self) -> Result<(), McpClientError> {
let params = McpInitializeParams {
protocol_version: "2024-11-05".to_string(),
capabilities: serde_json::json!({}),
client_info: McpClientInfo {
name: "mentra".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
};
let result: McpInitializeResult = self
.call("initialize", Some(params), INITIALIZE_TIMEOUT)
.await?;
self.server_info = Some(result.server_info);
// Send initialized notification.
self.notify::<JsonValue>("notifications/initialized", None)
.await?;
Ok(())
}
async fn discover_tools(&mut self) -> Result<(), McpClientError> {
let mut all_tools = Vec::new();
let mut cursor: Option<String> = None;
let mut pages = 0_usize;
loop {
let params = McpListToolsParams {
cursor: cursor.clone(),
};
let result: McpListToolsResult = self
.call("tools/list", Some(params), LIST_TOOLS_TIMEOUT)
.await?;
all_tools.extend(result.tools);
pages += 1;
if pages >= MAX_TOOL_PAGES {
// A server that keeps handing back a cursor would otherwise
// loop forever, growing the tool list without bound. Cursors
// are opaque, so a repeat cannot be detected by value.
return Err(McpClientError::TooManyToolPages {
limit: MAX_TOOL_PAGES,
});
}
match result.next_cursor {
Some(next) if !next.is_empty() => cursor = Some(next),
_ => break,
}
}
self.tools = all_tools;
Ok(())
}
/// Call a tool on this server.
pub async fn call_tool(
&self,
tool_name: &str,
arguments: Option<JsonValue>,
) -> Result<McpToolCallResult, McpClientError> {
self.call_tool_with_timeout(tool_name, arguments, CALL_TOOL_TIMEOUT)
.await
}
/// Call a tool on this server, bounding the wait explicitly.
pub async fn call_tool_with_timeout(
&self,
tool_name: &str,
arguments: Option<JsonValue>,
timeout: Duration,
) -> Result<McpToolCallResult, McpClientError> {
let params = McpToolCallParams {
name: tool_name.to_string(),
arguments,
};
self.call("tools/call", Some(params), timeout).await
}
/// The number of requests still awaiting a response.
#[cfg(test)]
pub(crate) async fn pending_len(&self) -> usize {
self.pending.lock().await.len()
}
/// Shut down the MCP server process gracefully.
pub async fn shutdown(&self) {
// Best-effort: drop stdin to signal the child.
let mut stdin = self.stdin.lock().await;
drop(stdin.shutdown().await);
}
}
impl Drop for McpStdioClient {
fn drop(&mut self) {
// The child process will be killed when the Child handle is dropped.
}
}

197
vendor/mentra/src/mcp/client/tests.rs vendored Normal file
View File

@@ -0,0 +1,197 @@
//! Tests for the MCP stdio client, driven by a scripted server process.
//!
//! The server is a short Python program so the test can control exactly which
//! JSON-RPC frames come back, including ones a well-behaved server would never
//! send. Tests that need it are skipped when no interpreter is available rather
//! than failing, so the suite still runs on a machine without Python.
use std::collections::HashMap;
use std::time::Duration;
use super::{McpClientError, McpStdioClient};
use crate::mcp::protocol::McpServerConfig;
/// Returns an interpreter that can run the scripted server, if one exists.
fn python() -> Option<&'static str> {
["python3", "python"].into_iter().find(|candidate| {
std::process::Command::new(candidate)
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|status| status.success())
})
}
/// Builds a config running the given Python source as an MCP server.
fn scripted_server(python: &str, source: &str) -> McpServerConfig {
McpServerConfig {
name: "scripted".to_string(),
command: python.to_string(),
args: vec!["-c".to_string(), source.to_string()],
env: HashMap::new(),
cwd: None,
}
}
/// A server that completes the handshake, then behaves as `extra` directs.
///
/// `extra` runs after `tools/list`, receiving each subsequent request line.
fn handshake_server(extra: &str) -> String {
format!(
r#"
import sys, json
def send(payload):
sys.stdout.write(json.dumps(payload) + "\n")
sys.stdout.flush()
def read():
line = sys.stdin.readline()
if not line:
raise SystemExit(0)
return json.loads(line)
# initialize
request = read()
send({{"jsonrpc": "2.0", "id": request["id"], "result": {{
"protocolVersion": "2024-11-05",
"capabilities": {{}},
"serverInfo": {{"name": "scripted", "version": "9.9.9"}}}}}})
# notifications/initialized carries no id and expects no reply
read()
# tools/list
request = read()
send({{"jsonrpc": "2.0", "id": request["id"], "result": {{"tools": [
{{"name": "echo", "inputSchema": {{"type": "object"}}}}]}}}})
{extra}
"#
)
}
#[tokio::test]
async fn completes_the_handshake_and_discovers_tools() {
let Some(python) = python() else {
eprintln!("skipping: no Python interpreter available");
return;
};
let config = scripted_server(python, &handshake_server("read()"));
let client = McpStdioClient::connect(&config)
.await
.expect("the handshake should succeed");
assert_eq!(
client.server_info().map(|info| info.name.as_str()),
Some("scripted")
);
assert_eq!(client.tools().len(), 1);
assert_eq!(client.tools()[0].name, "echo");
}
/// A server-initiated request carries a method and an id but no result. Without
/// a guard the reader treats it as a response and resolves whichever caller
/// happens to hold that id with a null result.
#[tokio::test]
async fn a_server_initiated_request_does_not_resolve_a_pending_call() {
let Some(python) = python() else {
eprintln!("skipping: no Python interpreter available");
return;
};
let extra = r#"
# tools/call — answer with a ping request first, reusing the caller's id.
request = read()
send({"jsonrpc": "2.0", "id": request["id"], "method": "ping"})
send({"jsonrpc": "2.0", "id": request["id"], "result": {
"content": [{"type": "text", "text": "real result"}], "isError": False}})
read()
"#;
let config = scripted_server(python, &handshake_server(extra));
let client = McpStdioClient::connect(&config)
.await
.expect("the handshake should succeed");
let result = client
.call_tool("echo", None)
.await
.expect("the real response should resolve the call");
assert_eq!(
result.content[0].text.as_deref(),
Some("real result"),
"a ping request must not be mistaken for the response"
);
}
/// A request that times out must remove its pending entry. A leak here is
/// bounded by request count, but a long-lived agent session makes many.
#[tokio::test]
async fn a_timed_out_request_does_not_leak_its_pending_entry() {
let Some(python) = python() else {
eprintln!("skipping: no Python interpreter available");
return;
};
let extra = r#"
# Swallow one tools/call without answering, then answer the next.
read()
request = read()
send({"jsonrpc": "2.0", "id": request["id"], "result": {
"content": [{"type": "text", "text": "second call"}], "isError": False}})
read()
"#;
let config = scripted_server(python, &handshake_server(extra));
let client = McpStdioClient::connect(&config)
.await
.expect("the handshake should succeed");
let timed_out = tokio::time::timeout(
Duration::from_secs(5),
client.call_tool_with_timeout("echo", None, Duration::from_millis(150)),
)
.await
.expect("the call should give up on its own")
.expect_err("no response arrives for the first call");
assert!(matches!(timed_out, McpClientError::Timeout(_)));
assert_eq!(
client.pending_len().await,
0,
"a timed-out request must not leave an entry behind"
);
let result = client
.call_tool("echo", None)
.await
.expect("the connection should remain usable");
assert_eq!(result.content[0].text.as_deref(), Some("second call"));
}
#[tokio::test]
async fn every_pending_call_fails_when_the_process_exits() {
let Some(python) = python() else {
eprintln!("skipping: no Python interpreter available");
return;
};
// Exit immediately after the handshake, without answering the tool call.
let config = scripted_server(python, &handshake_server("raise SystemExit(0)"));
let client = McpStdioClient::connect(&config)
.await
.expect("the handshake should succeed");
let error = tokio::time::timeout(Duration::from_secs(10), client.call_tool("echo", None))
.await
.expect("the call must fail rather than hang")
.expect_err("a dead process cannot answer");
assert!(
matches!(error, McpClientError::ProcessExited),
"got {error:?}"
);
}

262
vendor/mentra/src/mcp/manager.rs vendored Normal file
View File

@@ -0,0 +1,262 @@
//! Manages multiple MCP server connections and their lifecycle.
use std::collections::HashMap;
use std::sync::Arc;
use super::bridge::{McpBridgedTool, McpToolClient, mcp_tool_name};
use super::client::{McpClientError, McpStdioClient};
use super::protocol::{McpServerConfig, McpToolDefinition};
use super::sse::client::{McpSseClient, McpSseError};
use super::sse::config::McpSseServerConfig;
/// Status of an MCP server connection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpServerStatus {
Disconnected,
Connecting,
Connected,
Error,
}
impl std::fmt::Display for McpServerStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Disconnected => write!(f, "disconnected"),
Self::Connecting => write!(f, "connecting"),
Self::Connected => write!(f, "connected"),
Self::Error => write!(f, "error"),
}
}
}
/// Summary of a managed MCP server.
#[derive(Debug, Clone)]
pub struct McpServerSummary {
pub name: String,
pub status: McpServerStatus,
pub server_version: Option<String>,
pub tool_count: usize,
pub error: Option<String>,
}
/// A connected client, whichever transport it speaks.
///
/// The manager needs more than [`McpToolClient`] provides — it reports server
/// versions and shuts connections down — so the transports are held in an enum
/// rather than behind that trait.
enum TransportClient {
Stdio(Arc<McpStdioClient>),
Sse(Arc<McpSseClient>),
}
impl TransportClient {
/// The server version reported by the `initialize` handshake.
fn server_version(&self) -> Option<String> {
match self {
Self::Stdio(client) => client.server_info().map(|info| info.version.clone()),
Self::Sse(client) => client.server_info().map(|info| info.version.clone()),
}
}
/// Closes the connection.
async fn shutdown(&self) {
match self {
Self::Stdio(client) => client.shutdown().await,
Self::Sse(client) => client.shutdown().await,
}
}
/// Calls a tool, flattening the transport's error to a message.
async fn call_tool(
&self,
tool_name: &str,
arguments: Option<serde_json::Value>,
) -> Result<super::protocol::McpToolCallResult, String> {
match self {
Self::Stdio(client) => McpToolClient::call_tool(&**client, tool_name, arguments).await,
Self::Sse(client) => McpToolClient::call_tool(&**client, tool_name, arguments).await,
}
}
/// Bridges every advertised tool into a runtime tool.
fn bridge(&self, server_name: &str, tools: &[McpToolDefinition]) -> Vec<McpBridgedTool> {
tools
.iter()
.map(|tool| match self {
Self::Stdio(client) => {
McpBridgedTool::new(server_name.to_string(), tool.clone(), client.clone())
}
Self::Sse(client) => {
McpBridgedTool::new(server_name.to_string(), tool.clone(), client.clone())
}
})
.collect()
}
}
/// Tracks a connected MCP server.
struct ConnectedServer {
client: TransportClient,
tools: Vec<McpToolDefinition>,
}
/// Manages the lifecycle of multiple MCP server processes.
pub struct McpManager {
servers: HashMap<String, ConnectedServer>,
errors: HashMap<String, String>,
}
impl McpManager {
pub fn new() -> Self {
Self {
servers: HashMap::new(),
errors: HashMap::new(),
}
}
/// Connect to an MCP server over stdio and discover its tools.
/// Returns the bridged tools ready for registration.
pub async fn connect(
&mut self,
config: &McpServerConfig,
) -> Result<Vec<McpBridgedTool>, McpClientError> {
// Disconnect existing connection if any.
self.disconnect(&config.name).await;
let client = McpStdioClient::connect(config).await.inspect_err(|e| {
self.errors.insert(config.name.clone(), e.to_string());
})?;
let tools = client.tools().to_vec();
let client = TransportClient::Stdio(Arc::new(client));
Ok(self.register(config.name.clone(), client, tools))
}
/// Connect to an MCP server over the legacy HTTP+SSE transport and discover
/// its tools.
///
/// Returns the bridged tools ready for registration, exactly as
/// [`connect`](Self::connect) does for stdio.
pub async fn connect_sse(
&mut self,
config: &McpSseServerConfig,
) -> Result<Vec<McpBridgedTool>, McpSseError> {
self.disconnect(&config.name).await;
let client = McpSseClient::connect(config).await.inspect_err(|error| {
self.errors.insert(config.name.clone(), error.to_string());
})?;
let tools = client.tools().to_vec();
let client = TransportClient::Sse(Arc::new(client));
Ok(self.register(config.name.clone(), client, tools))
}
/// Records a connected server and bridges its tools.
fn register(
&mut self,
name: String,
client: TransportClient,
tools: Vec<McpToolDefinition>,
) -> Vec<McpBridgedTool> {
let bridged = client.bridge(&name, &tools);
self.errors.remove(&name);
self.servers.insert(name, ConnectedServer { client, tools });
bridged
}
/// Disconnect a server by name.
pub async fn disconnect(&mut self, name: &str) {
if let Some(server) = self.servers.remove(name) {
server.client.shutdown().await;
}
}
/// Shut down all connected servers.
pub async fn shutdown_all(&mut self) {
let names: Vec<String> = self.servers.keys().cloned().collect();
for name in names {
self.disconnect(&name).await;
}
}
/// List all server summaries.
pub fn list_servers(&self) -> Vec<McpServerSummary> {
let mut summaries: Vec<McpServerSummary> = self
.servers
.iter()
.map(|(name, server)| McpServerSummary {
name: name.clone(),
status: McpServerStatus::Connected,
server_version: server.client.server_version(),
tool_count: server.tools.len(),
error: None,
})
.collect();
// Include errored servers.
for (name, error) in &self.errors {
if !self.servers.contains_key(name) {
summaries.push(McpServerSummary {
name: name.clone(),
status: McpServerStatus::Error,
server_version: None,
tool_count: 0,
error: Some(error.clone()),
});
}
}
summaries.sort_by(|a, b| a.name.cmp(&b.name));
summaries
}
/// Get the namespaced tool names for all connected servers.
pub fn all_tool_names(&self) -> Vec<String> {
self.servers
.iter()
.flat_map(|(name, server)| {
server
.tools
.iter()
.map(move |tool| mcp_tool_name(name, &tool.name))
})
.collect()
}
/// Call a tool on a specific server, whichever transport it speaks.
///
/// Each transport reports failures with its own error type, so this returns
/// the message rather than widening the error into a shared enum.
pub async fn call_tool(
&self,
server_name: &str,
tool_name: &str,
arguments: Option<serde_json::Value>,
) -> Result<super::protocol::McpToolCallResult, String> {
let server = self
.servers
.get(server_name)
.ok_or_else(|| format!("MCP server '{server_name}' not connected"))?;
server.client.call_tool(tool_name, arguments).await
}
/// Check if a server is connected.
pub fn is_connected(&self, name: &str) -> bool {
self.servers.contains_key(name)
}
/// Number of connected servers.
pub fn connected_count(&self) -> usize {
self.servers.len()
}
}
impl Default for McpManager {
fn default() -> Self {
Self::new()
}
}

181
vendor/mentra/src/mcp/protocol.rs vendored Normal file
View File

@@ -0,0 +1,181 @@
//! JSON-RPC 2.0 and MCP protocol types.
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
// ---------------------------------------------------------------------------
// JSON-RPC 2.0 primitives
// ---------------------------------------------------------------------------
/// JSON-RPC 2.0 request identifier.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum JsonRpcId {
Number(u64),
String(String),
Null,
}
/// Outbound JSON-RPC 2.0 request.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcRequest<T = JsonValue> {
pub jsonrpc: String,
pub id: JsonRpcId,
pub method: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<T>,
}
impl<T> JsonRpcRequest<T> {
pub fn new(id: u64, method: impl Into<String>, params: Option<T>) -> Self {
Self {
jsonrpc: "2.0".to_string(),
id: JsonRpcId::Number(id),
method: method.into(),
params,
}
}
}
/// JSON-RPC 2.0 error object.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcError {
pub code: i64,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<JsonValue>,
}
impl std::fmt::Display for JsonRpcError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "JSON-RPC error {}: {}", self.code, self.message)
}
}
/// Inbound JSON-RPC 2.0 response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JsonRpcResponse<T = JsonValue> {
pub jsonrpc: String,
pub id: JsonRpcId,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<T>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<JsonRpcError>,
}
// ---------------------------------------------------------------------------
// MCP initialize
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpInitializeParams {
pub protocol_version: String,
pub capabilities: JsonValue,
pub client_info: McpClientInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpClientInfo {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpInitializeResult {
pub protocol_version: String,
pub capabilities: JsonValue,
pub server_info: McpServerInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServerInfo {
pub name: String,
#[serde(default)]
pub version: String,
}
// ---------------------------------------------------------------------------
// MCP tools/list
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpListToolsParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
/// A tool definition returned by the MCP server.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpToolDefinition {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input_schema: Option<JsonValue>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpListToolsResult {
pub tools: Vec<McpToolDefinition>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
// ---------------------------------------------------------------------------
// MCP tools/call
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolCallParams {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub arguments: Option<JsonValue>,
}
/// A single content block in a tool call result.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpToolCallContent {
#[serde(rename = "type")]
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<String>,
#[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")]
pub mime_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct McpToolCallResult {
pub content: Vec<McpToolCallContent>,
#[serde(default)]
pub is_error: bool,
}
// ---------------------------------------------------------------------------
// MCP server configuration
// ---------------------------------------------------------------------------
/// Configuration for a single MCP server.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct McpServerConfig {
/// Display name for the server.
pub name: String,
/// The command to spawn.
pub command: String,
/// Arguments to pass to the command.
#[serde(default)]
pub args: Vec<String>,
/// Extra environment variables.
#[serde(default)]
pub env: std::collections::HashMap<String, String>,
/// Working directory for the spawned process.
#[serde(skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
}

View File

@@ -0,0 +1,572 @@
//! Tests for MCP registration through the runtime builder.
use serde_json::json;
use crate::mcp::sse::testing::SseTestServer;
use crate::mcp::{McpManager, McpSseServerConfig, mcp_tool_name};
const REMOTE_CANARY: &str = "REMOTE_CANARY_MUST_NOT_SURFACE";
/// Scripts a fixture through the handshake, advertising the given tools.
///
/// Returns the manager alongside the bridged tools: the manager owns the
/// connection those tools call through, so dropping it would close the stream.
async fn connect_sse(
server: &SseTestServer,
tools: serde_json::Value,
) -> (Vec<crate::mcp::McpBridgedTool>, McpManager) {
let config = McpSseServerConfig::new("obs", server.sse_url());
let connecting = tokio::spawn(async move {
let mut manager = McpManager::new();
let bridged = manager.connect_sse(&config).await;
(bridged, manager)
});
server.wait_for_stream();
server.send_endpoint("/messages/?session_id=abc");
server.wait_for_posts(1);
server.send_message(&json!({
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "fixture", "version": "4.5.6"}
}
}));
server.wait_for_posts(3);
server.send_message(&json!({
"jsonrpc": "2.0",
"id": 2,
"result": {"tools": tools}
}));
let (bridged, manager) = connecting.await.expect("no panic");
(bridged.expect("the handshake should succeed"), manager)
}
#[tokio::test(flavor = "multi_thread")]
async fn the_manager_bridges_sse_tools_under_a_namespaced_name() {
let server = SseTestServer::start();
let (bridged, _manager) = connect_sse(
&server,
json!([
{"name": "search_logs", "description": "Search logs", "inputSchema": {"type": "object"}},
{"name": "list_alerts", "inputSchema": {"type": "object"}}
]),
)
.await;
use crate::tool::ToolDefinition;
let names: Vec<String> = bridged
.iter()
.map(|tool| tool.descriptor().name.to_string())
.collect();
assert_eq!(
names,
vec![
mcp_tool_name("obs", "search_logs"),
mcp_tool_name("obs", "list_alerts"),
],
"SSE tools must be namespaced exactly like stdio tools"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_bridged_sse_tool_carries_its_description_and_schema() {
let server = SseTestServer::start();
let (bridged, _manager) = connect_sse(
&server,
json!([{
"name": "search_logs",
"description": "Search the log corpus",
"inputSchema": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]
}
}]),
)
.await;
use crate::tool::ToolDefinition;
let descriptor = bridged[0].descriptor();
assert_eq!(
descriptor.description.as_deref(),
Some("Search the log corpus")
);
assert_eq!(
descriptor.input_schema["properties"]["query"]["type"], "string",
"the server's schema must reach the model unchanged"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn the_manager_reports_a_connected_sse_server() {
let server = SseTestServer::start();
let config = McpSseServerConfig::new("obs", server.sse_url());
let connecting = tokio::spawn(async move {
let mut manager = McpManager::new();
let result = manager.connect_sse(&config).await;
(result.map(|tools| tools.len()), manager)
});
server.wait_for_stream();
server.send_endpoint("/messages/?session_id=abc");
server.wait_for_posts(1);
server.send_message(&json!({
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"serverInfo": {"name": "fixture", "version": "4.5.6"}
}
}));
server.wait_for_posts(3);
server.send_message(&json!({
"jsonrpc": "2.0",
"id": 2,
"result": {"tools": [{"name": "search", "inputSchema": {"type": "object"}}]}
}));
let (count, manager) = connecting.await.expect("no panic");
assert_eq!(count.expect("the handshake should succeed"), 1);
assert!(manager.is_connected("obs"));
assert_eq!(manager.connected_count(), 1);
assert_eq!(
manager.all_tool_names(),
vec![mcp_tool_name("obs", "search")]
);
let summary = manager
.list_servers()
.into_iter()
.find(|summary| summary.name == "obs")
.expect("the server should be listed");
assert_eq!(summary.status, crate::mcp::McpServerStatus::Connected);
assert_eq!(summary.server_version.as_deref(), Some("4.5.6"));
assert_eq!(summary.tool_count, 1);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_failed_sse_connection_is_recorded_as_an_error() {
let server = SseTestServer::with_opening(crate::mcp::sse::testing::StreamOpening::Status {
code: 404,
body: "no such stream".to_string(),
});
let mut manager = McpManager::new();
let config = McpSseServerConfig::new("obs", server.sse_url());
manager
.connect_sse(&config)
.await
.expect_err("a 404 must not connect");
assert!(!manager.is_connected("obs"));
let summary = manager
.list_servers()
.into_iter()
.find(|summary| summary.name == "obs")
.expect("an errored server should still be listed");
assert_eq!(summary.status, crate::mcp::McpServerStatus::Error);
assert!(summary.error.is_some());
}
#[tokio::test(flavor = "multi_thread")]
async fn manager_error_summaries_do_not_retain_json_rpc_text() {
let server = SseTestServer::start();
let config = McpSseServerConfig::new("obs", server.sse_url());
let connecting = tokio::spawn(async move {
let mut manager = McpManager::new();
let error = manager
.connect_sse(&config)
.await
.expect_err("the initialize error must fail the connection");
(error, manager)
});
server.wait_for_stream();
server.send_endpoint("/messages/?session_id=abc");
server.wait_for_posts(1);
server.send_message(&json!({
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32001,
"message": REMOTE_CANARY,
"data": {"forged": REMOTE_CANARY}
}
}));
let (error, manager) = connecting.await.expect("no panic");
assert!(
matches!(error, crate::mcp::McpSseError::JsonRpc(ref rpc) if rpc.code == -32001),
"got {error:?}"
);
let summary = manager
.list_servers()
.into_iter()
.find(|summary| summary.name == "obs")
.expect("the failed server should be listed");
let rendered = format!("{summary:?}");
assert!(!rendered.contains(REMOTE_CANARY), "got {rendered}");
assert!(
summary
.error
.as_deref()
.is_some_and(|message| message.contains("-32001")),
"the safe summary should preserve the JSON-RPC code: {rendered}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn a_misconfigured_sse_server_fails_before_any_connection_is_opened() {
let mut manager = McpManager::new();
// A token over plaintext to a non-loopback host is refused at validation.
let config =
McpSseServerConfig::new("obs", "http://internal.corp/sse").with_bearer_token("secret");
let error = manager
.connect_sse(&config)
.await
.expect_err("validation must reject this before dialing");
assert!(
!error.to_string().contains("secret"),
"the error must not echo the credential: {error}"
);
}
/// `build_async` must actually register the MCP tools it connects, and a
/// runtime built without any MCP server must not gain namespaced tools.
///
/// Without this, disabling the whole registration arm in `build_async` leaves
/// the suite green: the runtime still builds, it just silently advertises
/// nothing. That failure is invisible until an agent cannot find its tools.
#[tokio::test(flavor = "multi_thread")]
async fn build_async_registers_the_tools_of_a_connected_sse_server() {
use crate::Runtime;
let server = SseTestServer::start();
let config = McpSseServerConfig::new("obs", server.sse_url());
let building = tokio::spawn(async move {
Runtime::empty_builder()
.with_provider_instance(StubProvider)
.with_mcp_sse_server(config)
.build_async()
.await
});
server.wait_for_stream();
server.send_endpoint("/messages/?session_id=abc");
server.wait_for_posts(1);
server.send_message(&json!({
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"serverInfo": {"name": "fixture", "version": "4.5.6"}
}
}));
server.wait_for_posts(3);
server.send_message(&json!({
"jsonrpc": "2.0",
"id": 2,
"result": {"tools": [
{"name": "search_logs", "inputSchema": {"type": "object"}},
{"name": "list_alerts", "inputSchema": {"type": "object"}}
]}
}));
let runtime = building
.await
.expect("no panic")
.expect("the runtime should build");
let registered: Vec<String> = runtime
.tools()
.into_iter()
.map(|tool| tool.name.to_string())
.filter(|name| name.starts_with("mcp__"))
.collect();
assert_eq!(
registered.len(),
2,
"build_async must register every tool the server advertised, got {registered:?}"
);
assert!(registered.contains(&mcp_tool_name("obs", "search_logs")));
assert!(registered.contains(&mcp_tool_name("obs", "list_alerts")));
assert!(
runtime
.tool_descriptor(&mcp_tool_name("obs", "search_logs"))
.is_some(),
"a registered MCP tool must be resolvable by name"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn build_async_registers_no_mcp_tools_without_a_configured_server() {
use crate::Runtime;
let runtime = Runtime::empty_builder()
.with_provider_instance(StubProvider)
.build_async()
.await
.expect("the runtime should build");
let registered: Vec<String> = runtime
.tools()
.into_iter()
.map(|tool| tool.name.to_string())
.filter(|name| name.starts_with("mcp__"))
.collect();
assert!(
registered.is_empty(),
"no MCP server was configured, got {registered:?}"
);
}
/// A provider that satisfies the builder's "at least one provider" check.
///
/// The registration tests never send a request, so it only needs to exist.
#[derive(Clone)]
struct StubProvider;
#[async_trait::async_trait]
impl crate::provider::Provider for StubProvider {
fn descriptor(&self) -> crate::provider::ProviderDescriptor {
crate::provider::ProviderDescriptor::new(crate::BuiltinProvider::Anthropic)
}
async fn list_models(&self) -> Result<Vec<crate::ModelInfo>, crate::provider::ProviderError> {
Ok(vec![crate::ModelInfo::new(
"stub-model",
crate::BuiltinProvider::Anthropic,
)])
}
async fn stream(
&self,
_request: crate::provider::Request<'_>,
) -> Result<crate::provider::ProviderEventStream, crate::provider::ProviderError> {
let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
Ok(rx)
}
}
/// An SSE-backed tool must reach the model through exactly the same result
/// limiter and paging path as a stdio tool or a custom tool. This runs a real
/// fixture server behind a bridged tool inside a scripted runtime and compares
/// its transcript entry byte for byte against a custom tool returning the same
/// text.
#[tokio::test(flavor = "multi_thread")]
async fn sse_tool_output_is_limited_exactly_like_a_custom_tool() {
use std::collections::BTreeMap;
use crate::{
ContentBlock,
runtime::RuntimePolicy,
test::{MockRuntime, MockToolCall},
tool::ToolDefinition,
};
let full_output = "one\ntwo\nthree";
let server = SseTestServer::start();
let (bridged, _manager) = connect_sse(
&server,
json!([{"name": "large_output", "inputSchema": {"type": "object"}}]),
)
.await;
let bridged_name = bridged[0].descriptor().name.to_string();
let mock = MockRuntime::builder()
.with_policy(
RuntimePolicy::permissive()
.with_max_tool_result_bytes(8)
.with_max_tool_result_lines(1)
.spill_full_tool_output(false),
)
.tool_calls([
MockToolCall::new(&bridged_name, json!({})).with_id("sse-call"),
MockToolCall::new("matching_custom_output", json!({})).with_id("custom-call"),
])
.text("done")
.build()
.expect("build mock runtime");
for tool in bridged {
mock.runtime().register_tool(tool);
}
mock.runtime().register_tool(EchoTool {
output: full_output.to_string(),
});
let mut agent = mock
.runtime()
.spawn("mcp-sse-truncation-test", mock.model())
.expect("spawn agent");
let running = tokio::spawn(async move {
let response = agent
.send(vec![ContentBlock::text("run both tools")])
.await
.expect("run agent");
(response, agent)
});
// Answer the bridged tool call once the runtime has posted it.
server.wait_for_posts(4);
server.send_message(&json!({
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [{"type": "text", "text": full_output}],
"isError": false
}
}));
let (response, _agent) = running.await.expect("no panic");
assert_eq!(response.text(), "done");
let requests = mock.recorded_requests().await;
let provider_results = requests[1]
.messages
.iter()
.flat_map(|message| &message.content)
.filter_map(|block| match block {
ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
} => Some((tool_use_id.as_str(), (content.as_str(), *is_error))),
_ => None,
})
.collect::<BTreeMap<_, _>>();
let sse_result = provider_results
.get("sse-call")
.expect("the SSE tool result should reach the provider");
let custom_result = provider_results
.get("custom-call")
.expect("the custom tool result should reach the provider");
assert_eq!(
sse_result, custom_result,
"an SSE tool must be limited identically to any other tool"
);
assert_eq!(
*sse_result,
(
"one\n[truncated: showing 1 of 3 lines; full output was not saved because spill-to-file is disabled by runtime policy]",
false,
)
);
}
#[tokio::test(flavor = "multi_thread")]
async fn bridged_sse_errors_do_not_put_json_rpc_text_in_model_context() {
use crate::{
ContentBlock,
test::{MockRuntime, MockToolCall},
tool::ToolDefinition,
};
let server = SseTestServer::start();
let (bridged, _manager) = connect_sse(
&server,
json!([{"name": "fail", "inputSchema": {"type": "object"}}]),
)
.await;
let bridged_name = bridged[0].descriptor().name.to_string();
let mock = MockRuntime::builder()
.tool_calls([MockToolCall::new(&bridged_name, json!({})).with_id("sse-error")])
.text("done")
.build()
.expect("build mock runtime");
for tool in bridged {
mock.runtime().register_tool(tool);
}
let mut agent = mock
.runtime()
.spawn("mcp-sse-error-redaction-test", mock.model())
.expect("spawn agent");
let running = tokio::spawn(async move {
agent
.send(vec![ContentBlock::text("call the failing tool")])
.await
});
server.wait_for_posts(4);
server.send_message(&json!({
"jsonrpc": "2.0",
"id": 3,
"error": {
"code": -32602,
"message": REMOTE_CANARY,
"data": {"forged": REMOTE_CANARY}
}
}));
let response = running
.await
.expect("no panic")
.expect("the agent should continue after the tool error");
assert_eq!(response.text(), "done");
let requests = mock.recorded_requests().await;
let (content, is_error) = requests[1]
.messages
.iter()
.flat_map(|message| &message.content)
.find_map(|block| match block {
ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
} if tool_use_id == "sse-error" => Some((content.as_str(), *is_error)),
_ => None,
})
.expect("the provider should receive the bridged error");
assert!(is_error);
assert!(!content.contains(REMOTE_CANARY), "got {content}");
assert!(content.contains("-32602"), "got {content}");
}
/// A custom tool returning a fixed string, used as the limiter baseline.
struct EchoTool {
output: String,
}
impl crate::tool::ToolDefinition for EchoTool {
fn descriptor(&self) -> crate::tool::ToolSpec {
crate::tool::ToolSpec::builder("matching_custom_output")
.description("Return the same output as the MCP test tool")
.input_schema(json!({ "type": "object", "properties": {} }))
.side_effect_level(crate::tool::ToolSideEffectLevel::External)
.build()
}
}
#[async_trait::async_trait]
impl crate::tool::ToolExecutor for EchoTool {
async fn execute(
&self,
_ctx: crate::tool::ParallelToolContext,
_input: serde_json::Value,
) -> crate::tool::ToolResult {
Ok(self.output.clone())
}
}

14
vendor/mentra/src/mcp/sse.rs vendored Normal file
View File

@@ -0,0 +1,14 @@
//! Legacy MCP HTTP+SSE transport (protocol revision 2024-11-05).
//!
//! This is the transport MCP defined in revision 2024-11-05, where the client
//! holds a long-lived `GET` stream open and posts JSON-RPC messages to a
//! separate URL that the server names. It is distinct from Streamable HTTP;
//! see [`client`](crate::mcp::sse::client) for the differences and the full
//! lifecycle.
pub mod client;
pub mod config;
pub mod endpoint;
#[cfg(test)]
pub(crate) mod testing;
pub(crate) mod wire;

790
vendor/mentra/src/mcp/sse/client.rs vendored Normal file
View File

@@ -0,0 +1,790 @@
//! MCP client for the legacy HTTP+SSE transport (protocol revision 2024-11-05).
//!
//! # The transport
//!
//! This is the *older* MCP HTTP transport, not Streamable HTTP. The two are
//! easy to confuse and are not interchangeable:
//!
//! | | legacy HTTP+SSE (this module) | Streamable HTTP |
//! |---|---|---|
//! | Endpoints | a `GET` stream plus a separate `POST` URL | one URL for both |
//! | POST target | named by the server in an `endpoint` event | the configured URL |
//! | Responses | always on the `GET` stream | in the POST response or a stream |
//! | Session | a query parameter in the endpoint URL | the `Mcp-Session-Id` header |
//!
//! Servers that answer `404` on `/mcp` but serve `/sse` require this transport.
//!
//! # Lifecycle
//!
//! 1. `GET` the configured URL with `Accept: text/event-stream`.
//! 2. Wait for an `event: endpoint` frame naming the `POST` URL, resolve it
//! against the configured URL, and require it to stay on the same origin.
//! 3. `POST` JSON-RPC requests as `application/json`. Any 2xx — including the
//! `202 Accepted` both reference servers return — means the message was
//! accepted for processing, not that it completed.
//! 4. Read JSON-RPC responses from `event: message` frames on the stream and
//! correlate them to requests by id.
//!
//! The handshake is `initialize`, then a `notifications/initialized`
//! notification, then a paginated `tools/list`.
//!
//! # Failure behavior
//!
//! The stream carries every response, so losing it ends the session. This
//! client fails closed: when the stream ends, every pending request resolves
//! with an error rather than hanging. It never reconnects and never re-sends a
//! `tools/call`, because an MCP tool may have side effects and a transparent
//! retry would execute it twice with no caller involvement.
//!
//! A `tools/call` whose `POST` may have reached the server but whose response
//! never arrived is reported as [`McpSseError::RequestIndeterminate`] rather
//! than as a plain failure, so a caller can tell "may have run" apart from
//! "definitely did not".
#[cfg(test)]
mod tests;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Duration;
use futures_util::StreamExt;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde::de::DeserializeOwned;
use serde_json::Value as JsonValue;
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use url::Url;
use super::config::{McpSseConfigError, McpSseLimits, McpSseServerConfig};
use super::endpoint::{EndpointError, resolve_endpoint};
use super::wire::{SseParser, SseWireError};
use crate::mcp::protocol::*;
/// The protocol revision this transport implements.
const PROTOCOL_VERSION: &str = "2024-11-05";
/// Bound on how much of an HTTP error body is read for diagnostics.
///
/// The body is attacker-controlled, so it is never included in an error; this
/// bound exists only so that reading and discarding it cannot be turned into a
/// memory-exhaustion primitive.
const MAX_DIAGNOSTIC_BODY_BYTES: usize = 8 * 1024;
/// Errors from the MCP SSE client.
///
/// No variant produced from a server response carries a response body, an SSE
/// payload, a server-controlled free-form metadata value, a JSON-RPC message or
/// data value, a tool argument, or a tool result. Server text is never
/// interpolated into an error, because a malicious server would otherwise be
/// able to write arbitrary content — including forged log lines and terminal
/// escape sequences — into an operator's logs or a model's context. Fixed
/// metadata such as an HTTP status or JSON-RPC code remains available.
#[derive(Debug, thiserror::Error)]
pub enum McpSseError {
#[error("invalid MCP SSE configuration: {0}")]
Config(#[from] McpSseConfigError),
#[error("invalid MCP SSE endpoint: {0}")]
Endpoint(#[from] EndpointError),
#[error("failed to reach the MCP SSE server: {0}")]
Transport(String),
#[error("MCP SSE server answered the {method} request with HTTP {status}")]
HttpStatus {
method: &'static str,
status: reqwest::StatusCode,
},
#[error(
"MCP SSE server answered with a redirect, which is not followed because it would send \
credentials to an unvalidated origin"
)]
RedirectRefused,
#[error(
"MCP SSE server answered with content type '{content_type}', expected text/event-stream"
)]
UnexpectedContentType { content_type: String },
#[error("MCP SSE stream framing error: {0}")]
Wire(#[from] SseWireError),
#[error("MCP SSE endpoint event exceeded the {limit} byte limit")]
EndpointTooLarge { limit: usize },
#[error("MCP SSE server kept paginating tools/list past {limit} pages")]
TooManyToolPages { limit: usize },
#[error("MCP SSE server returned JSON-RPC error: {0}")]
JsonRpc(JsonRpcError),
#[error("failed to parse the MCP SSE response: {0}")]
ParseError(String),
#[error("timed out after {0:?} waiting for the MCP SSE server")]
Timeout(Duration),
#[error("the MCP SSE stream closed before the request completed")]
StreamClosed,
/// The request may have reached the server, but no response arrived before
/// the stream ended or the deadline passed.
///
/// The call may have executed. The `POST` and the response travel on
/// different connections, so a server can accept and run a tool while the
/// stream dies, and the client cannot tell that apart from the tool never
/// starting. Callers must not retry automatically: an MCP tool can send
/// mail, charge a card, or write a file.
///
#[error(
"the MCP SSE server may have received the '{method}' request but never answered it; \
the call may have executed and must not be retried automatically"
)]
RequestIndeterminate { method: String },
#[error("the MCP SSE client is shut down")]
Shutdown,
}
/// The outcome of a JSON-RPC request, kept in the pending map.
type PendingReply = Result<JsonValue, McpSseError>;
/// Correlates in-flight requests to the responses arriving on the stream.
///
/// Lookups remove the entry, so the first response for an id wins and a
/// malicious server cannot deliver a second result for a call the caller has
/// already observed.
#[derive(Default)]
struct Pending {
waiters: HashMap<u64, PendingWaiter>,
/// Set once the stream ends so late requests fail immediately rather than
/// waiting out their timeout.
closed: bool,
}
struct PendingWaiter {
reply: oneshot::Sender<PendingReply>,
method: String,
}
/// Removes one pending waiter if its request future is dropped.
///
/// Request futures are cancellation points while sending the POST, draining
/// its response body, and waiting on the SSE stream. A synchronous mutex keeps
/// this cleanup available to `Drop`, where awaiting a Tokio mutex is
/// impossible. The critical sections only mutate the in-memory map and never
/// perform I/O.
struct PendingRegistration {
pending: Arc<Mutex<Pending>>,
id: u64,
}
impl Drop for PendingRegistration {
fn drop(&mut self) {
lock_pending(&self.pending).waiters.remove(&self.id);
}
}
/// A connected MCP server speaking the legacy HTTP+SSE transport.
///
/// This is the low-level client. It performs the handshake, exposes the
/// server's advertised tools, and calls one selected tool. It deliberately does
/// not register anything with the runtime, so a host can apply its own
/// allowlists, redaction, and evidence policy over the top. Use
/// `RuntimeBuilder::with_mcp_sse_server` when the generic bridging behavior is
/// what you want.
pub struct McpSseClient {
http: reqwest::Client,
/// The `POST` target named by the server, validated to the configured origin.
endpoint: Url,
headers: HeaderMap,
limits: McpSseLimits,
next_id: AtomicU64,
pending: Arc<Mutex<Pending>>,
reader: JoinHandle<()>,
server_info: Option<McpServerInfo>,
tools: Vec<McpToolDefinition>,
server_name: String,
stream_url: Url,
}
impl McpSseClient {
/// Opens the SSE stream, performs the MCP handshake, and discovers tools.
pub async fn connect(config: &McpSseServerConfig) -> Result<Self, McpSseError> {
let stream_url = config.validate()?;
let headers = build_headers(config)?;
let http = build_http_client(&config.limits)?;
let response = tokio::time::timeout(
config.limits.connect_timeout,
http.get(stream_url.clone())
.header(reqwest::header::ACCEPT, "text/event-stream")
.headers(headers.clone())
.send(),
)
.await
.map_err(|_| McpSseError::Timeout(config.limits.connect_timeout))?
.map_err(transport_error)?;
check_stream_response(&response)?;
let pending: Arc<Mutex<Pending>> = Arc::new(Mutex::new(Pending::default()));
let (endpoint_tx, endpoint_rx) = oneshot::channel();
let reader = tokio::spawn(read_stream(
response,
Arc::clone(&pending),
endpoint_tx,
config.limits.clone(),
));
// The endpoint event must arrive before anything can be sent. Bound the
// wait: a buffering proxy is a common cause of it never arriving.
//
// Every failure from here on must abort the reader before returning, or
// the task and the connection it holds outlive the failed connect.
let endpoint = match tokio::time::timeout(config.limits.connect_timeout, endpoint_rx).await
{
Ok(Ok(Ok(raw))) => match resolve_endpoint(&stream_url, &raw) {
Ok(endpoint) => endpoint,
Err(error) => {
reader.abort();
return Err(error.into());
}
},
Ok(Ok(Err(error))) => {
reader.abort();
return Err(error);
}
Ok(Err(_)) => {
reader.abort();
return Err(McpSseError::StreamClosed);
}
Err(_) => {
reader.abort();
return Err(McpSseError::Timeout(config.limits.connect_timeout));
}
};
let mut client = Self {
http,
endpoint,
headers,
limits: config.limits.clone(),
next_id: AtomicU64::new(1),
pending,
reader,
server_info: None,
tools: Vec::new(),
server_name: config.name.clone(),
stream_url,
};
// A failure here returns `client` by value, so its `Drop` aborts the
// reader; there is no separate cleanup path to keep in sync.
client.initialize().await?;
client.discover_tools().await?;
Ok(client)
}
/// The configured name of this server, used to namespace its tools.
pub fn server_name(&self) -> &str {
&self.server_name
}
/// The configured SSE stream URL.
pub fn stream_url(&self) -> &Url {
&self.stream_url
}
/// Server information returned by the `initialize` handshake.
pub fn server_info(&self) -> Option<&McpServerInfo> {
self.server_info.as_ref()
}
/// The tools this server advertised.
pub fn tools(&self) -> &[McpToolDefinition] {
&self.tools
}
/// Calls one tool on this server.
pub async fn call_tool(
&self,
tool_name: &str,
arguments: Option<JsonValue>,
) -> Result<McpToolCallResult, McpSseError> {
let params = McpToolCallParams {
name: tool_name.to_string(),
arguments,
};
self.request("tools/call", Some(params), self.limits.call_tool_timeout)
.await
}
/// Closes the stream and fails every request still in flight.
pub async fn shutdown(&self) {
self.reader.abort();
let mut pending = lock_pending(&self.pending);
pending.closed = true;
drain_pending(&mut pending);
}
/// Sends a JSON-RPC request and waits for its correlated response.
async fn request<P: serde::Serialize, R: DeserializeOwned>(
&self,
method: &'static str,
params: Option<P>,
timeout: Duration,
) -> Result<R, McpSseError> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let params = params
.map(|params| serde_json::to_value(params))
.transpose()
.map_err(|error| McpSseError::ParseError(error.to_string()))?
.filter(|params| !params.is_null());
let request = JsonRpcRequest::new(id, method, params);
let (reply_tx, reply_rx) = oneshot::channel();
let _registration = {
// Register before sending. The server answers the POST before it
// processes the message, so the response can reach the stream
// before the POST future resolves.
let mut pending = lock_pending(&self.pending);
if pending.closed {
return Err(McpSseError::StreamClosed);
}
pending.waiters.insert(
id,
PendingWaiter {
reply: reply_tx,
method: method.to_string(),
},
);
PendingRegistration {
pending: Arc::clone(&self.pending),
id,
}
};
// One deadline covers the complete operation. In particular, a peer
// cannot evade `call_tool_timeout` by accepting the TCP connection and
// withholding either the HTTP response head or its declared body.
let operation = async {
self.post(&request)
.await
.map_err(|error| classify_post_failure(method, error))?;
match reply_rx.await {
Ok(result) => result,
// The reader dropped the sender, which only happens on
// teardown. A tools/call may already have executed.
Err(_) => Err(indeterminate(method)),
}
};
let result = match tokio::time::timeout(timeout, operation).await {
Ok(result) => result?,
Err(_) => return Err(request_timeout(method, timeout)),
};
serde_json::from_value(result)
.map_err(|_| McpSseError::ParseError("response shape did not match MCP".to_string()))
}
/// Sends a JSON-RPC notification, which expects no response.
async fn notify(&self, method: &str, timeout: Duration) -> Result<(), McpSseError> {
let notification = serde_json::json!({"jsonrpc": "2.0", "method": method});
tokio::time::timeout(timeout, self.post(&notification))
.await
.map_err(|_| McpSseError::Timeout(timeout))?
}
/// `POST`s one JSON-RPC message to the validated endpoint.
async fn post<T: serde::Serialize>(&self, message: &T) -> Result<(), McpSseError> {
let response = self
.http
.post(self.endpoint.clone())
.headers(self.headers.clone())
.json(message)
.send()
.await
.map_err(transport_error)?;
let status = response.status();
if status.is_redirection() {
return Err(McpSseError::RedirectRefused);
}
if !status.is_success() {
// Read a bounded prefix so the connection returns to the pool, then
// discard it: the body is attacker-controlled and never surfaces.
drain_bounded(response).await;
return Err(McpSseError::HttpStatus {
method: "POST",
status,
});
}
// The JSON-RPC result never arrives here — it comes back on the stream.
// Drain anyway so the connection is reusable.
drain_bounded(response).await;
Ok(())
}
/// Performs the `initialize` handshake and the follow-up notification.
async fn initialize(&mut self) -> Result<(), McpSseError> {
let params = McpInitializeParams {
protocol_version: PROTOCOL_VERSION.to_string(),
capabilities: serde_json::json!({}),
client_info: McpClientInfo {
name: "mentra".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
},
};
let result: McpInitializeResult = self
.request("initialize", Some(params), self.limits.initialize_timeout)
.await?;
self.server_info = Some(result.server_info);
self.notify("notifications/initialized", self.limits.initialize_timeout)
.await
}
/// Walks the paginated `tools/list` cursor to the end.
async fn discover_tools(&mut self) -> Result<(), McpSseError> {
let mut tools = Vec::new();
let mut cursor: Option<String> = None;
let mut pages = 0_usize;
loop {
let params = McpListToolsParams {
cursor: cursor.clone(),
};
let page: McpListToolsResult = self
.request("tools/list", Some(params), self.limits.list_tools_timeout)
.await?;
tools.extend(page.tools);
pages += 1;
if pages >= self.limits.max_tool_pages {
// A server that keeps handing back a cursor would otherwise
// loop forever, growing the tool list without bound. The
// cursor is opaque, so a repeat cannot be detected by value.
return Err(McpSseError::TooManyToolPages {
limit: self.limits.max_tool_pages,
});
}
match page.next_cursor {
// A missing or empty cursor means the last page.
Some(next) if !next.is_empty() => cursor = Some(next),
_ => break,
}
}
self.tools = tools;
Ok(())
}
}
impl Drop for McpSseClient {
fn drop(&mut self) {
// Cancel the reader so the task and its connection do not outlive the
// client that owns them.
self.reader.abort();
}
}
impl std::fmt::Debug for McpSseClient {
/// Renders without the header map, which holds credentials.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("McpSseClient")
.field("server_name", &self.server_name)
.field("stream_url", &self.stream_url.as_str())
.field("tools", &self.tools.len())
.finish_non_exhaustive()
}
}
/// Reads the SSE stream until it ends, routing frames to waiting callers.
async fn read_stream(
response: reqwest::Response,
pending: Arc<Mutex<Pending>>,
endpoint_tx: oneshot::Sender<Result<String, McpSseError>>,
limits: McpSseLimits,
) {
let mut parser = SseParser::new(limits.max_event_bytes);
let mut body = response.bytes_stream();
let mut endpoint_tx = Some(endpoint_tx);
loop {
let next = tokio::time::timeout(limits.stream_idle_timeout, body.next()).await;
let chunk = match next {
Ok(Some(Ok(chunk))) => chunk,
// Any stream error is terminal. reqwest's `is_body` does not
// reliably identify body errors, so it is not consulted.
Ok(Some(Err(_))) | Ok(None) => break,
Err(_) => break,
};
let events = match parser.feed(&chunk) {
Ok(events) => events,
Err(error) => {
// Framing can no longer be trusted, so the stream is torn down
// rather than resynchronized at an attacker-chosen boundary.
notify_endpoint(&mut endpoint_tx, Err(McpSseError::Wire(error)));
break;
}
};
for event in events {
match event.event.as_str() {
"endpoint" => {
if event.data.len() > limits.max_endpoint_bytes {
notify_endpoint(
&mut endpoint_tx,
Err(McpSseError::EndpointTooLarge {
limit: limits.max_endpoint_bytes,
}),
);
// Fail closed: without a usable endpoint nothing can be sent.
break;
}
// Only the first endpoint event is honored. A later one
// would silently redirect in-flight traffic.
notify_endpoint(&mut endpoint_tx, Ok(event.data));
}
"message" => deliver_message(&pending, &event.data),
// Unknown event names, including the `ping` frames older
// sse-starlette versions emit, are ignored rather than fatal.
_ => {}
}
}
}
// Whatever ended the stream, no further response can arrive.
notify_endpoint(&mut endpoint_tx, Err(McpSseError::StreamClosed));
let mut pending = lock_pending(&pending);
pending.closed = true;
drain_pending(&mut pending);
}
/// Routes one `message` frame to the caller waiting on its id.
fn deliver_message(pending: &Arc<Mutex<Pending>>, data: &str) {
let Ok(response) = serde_json::from_str::<JsonRpcResponse>(data) else {
// Malformed JSON, or a server-initiated request such as `ping`. Neither
// is a response, so neither is correlated. Dropping it keeps a hostile
// server from turning unsolicited frames into per-id state.
return;
};
// A response carries a result or an error; anything else is a request.
if response.result.is_none() && response.error.is_none() {
return;
}
let JsonRpcId::Number(id) = response.id else {
return;
};
// Removing the entry means the first response wins and a repeated id
// cannot deliver a second result for an already-observed call.
let Some(waiter) = lock_pending(pending).waiters.remove(&id) else {
return;
};
let reply = match response.error {
Some(error) => Err(McpSseError::JsonRpc(JsonRpcError {
code: error.code,
message: "server message omitted".to_string(),
data: None,
})),
None => Ok(response.result.unwrap_or(JsonValue::Null)),
};
let _ = waiter.reply.send(reply);
}
/// Sends the endpoint outcome exactly once.
fn notify_endpoint(
endpoint_tx: &mut Option<oneshot::Sender<Result<String, McpSseError>>>,
outcome: Result<String, McpSseError>,
) {
if let Some(tx) = endpoint_tx.take() {
let _ = tx.send(outcome);
}
}
/// Fails every pending request.
///
/// A request that is still registered may already have been sent. A
/// `tools/call` here may therefore have executed, and saying so is what lets a
/// caller avoid re-running a non-idempotent action.
fn drain_pending(pending: &mut Pending) {
for (_, waiter) in pending.waiters.drain() {
let _ = waiter.reply.send(Err(indeterminate(&waiter.method)));
}
}
/// Reports a sent-but-unanswered request in the terms a caller needs.
///
/// Only `tools/call` is reported as indeterminate: the handshake methods are
/// idempotent, so an unanswered one is simply a closed stream.
fn indeterminate(method: &str) -> McpSseError {
if method == "tools/call" {
McpSseError::RequestIndeterminate {
method: method.to_string(),
}
} else {
McpSseError::StreamClosed
}
}
/// Converts a POST failure into the method-level certainty the caller needs.
///
/// Once a `tools/call` POST future has begun, neither a transport error nor an
/// HTTP response proves that the application did not process its body first.
/// HTTP status describes the response, not an atomic absence of server-side
/// effects, so every POST failure is indeterminate for a potentially mutating
/// tool call. Handshake methods retain the underlying diagnostic because they
/// are safe to establish again in a new session.
fn classify_post_failure(method: &str, error: McpSseError) -> McpSseError {
if method == "tools/call" {
indeterminate(method)
} else {
error
}
}
/// Reports expiration of the whole request operation.
fn request_timeout(method: &str, timeout: Duration) -> McpSseError {
if method == "tools/call" {
indeterminate(method)
} else {
McpSseError::Timeout(timeout)
}
}
/// Acquires the pending map even if another task panicked while holding it.
///
/// Losing the map on poison would strand unrelated requests forever. No map
/// mutation runs user code, so recovering the contained value is safe.
fn lock_pending(pending: &Mutex<Pending>) -> MutexGuard<'_, Pending> {
pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// Builds the HTTP client shared by the stream and the message endpoint.
fn build_http_client(limits: &McpSseLimits) -> Result<reqwest::Client, McpSseError> {
reqwest::Client::builder()
// Redirects are never followed. reqwest strips `Authorization` only
// across hosts and compares host and port without scheme, so an
// https->http redirect on the same host would keep the credential and
// send it in the clear. The request body is never stripped at all, so a
// followed redirect would also hand tool arguments to the new target.
.redirect(reqwest::redirect::Policy::none())
// Reqwest retries selected protocol-level rejections on its own once
// the negotiated protocol can signal them (HTTP/2 REFUSED_STREAM and
// kin). A tools/call POST may already have executed by the time such
// a signal arrives, so an automatic resend would replay a
// side-effecting call with no caller involvement — the exact
// double-execution this client's request path refuses. Today's
// feature set negotiates HTTP/1.1 only, where no such signal exists;
// this pin keeps the no-replay guarantee structural rather than an
// accident of the current feature graph.
.retry(reqwest::retry::never())
.connect_timeout(limits.connect_timeout)
// Deliberately no `.timeout()`: that is a total deadline covering the
// response body, which would kill the long-lived stream on a fixed
// interval. The request and notification paths apply their own total
// deadlines around each finite POST operation.
.build()
.map_err(|error| McpSseError::Transport(error.to_string()))
}
/// Converts configured headers into a map, marking every value sensitive.
fn build_headers(config: &McpSseServerConfig) -> Result<HeaderMap, McpSseError> {
let mut headers = HeaderMap::new();
for (name, value) in &config.headers {
let name = HeaderName::try_from(name.as_str()).map_err(|_| {
McpSseConfigError::InvalidHeaderName {
name: name.to_string(),
}
})?;
let mut value = HeaderValue::try_from(value.expose_secret()).map_err(|_| {
McpSseConfigError::InvalidHeaderValue {
name: name.to_string(),
}
})?;
// A plain HeaderValue prints its contents in Debug output. Marking it
// sensitive redacts it there and tells HTTP/2 not to index it.
value.set_sensitive(true);
headers.insert(name, value);
}
Ok(headers)
}
/// Requires a 200 response carrying an event stream.
fn check_stream_response(response: &reqwest::Response) -> Result<(), McpSseError> {
let status = response.status();
if status.is_redirection() {
return Err(McpSseError::RedirectRefused);
}
if !status.is_success() {
return Err(McpSseError::HttpStatus {
method: "GET",
status,
});
}
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
// Prefix match: servers commonly send `text/event-stream; charset=utf-8`.
if !content_type
.trim_start()
.to_ascii_lowercase()
.starts_with("text/event-stream")
{
return Err(McpSseError::UnexpectedContentType {
content_type: "[server value omitted]".to_string(),
});
}
Ok(())
}
/// Reads and discards a bounded prefix of a response body.
async fn drain_bounded(response: reqwest::Response) {
let mut body = response.bytes_stream();
let mut seen = 0_usize;
while let Some(Ok(chunk)) = body.next().await {
seen += chunk.len();
if seen >= MAX_DIAGNOSTIC_BODY_BYTES {
break;
}
}
}
/// Renders a transport failure without echoing server-controlled text.
fn transport_error(error: reqwest::Error) -> McpSseError {
let reason = if error.is_timeout() {
"timed out"
} else if error.is_connect() {
"could not connect"
} else if error.is_request() {
"the request could not be sent"
} else {
"the connection failed"
};
McpSseError::Transport(reason.to_string())
}

1504
vendor/mentra/src/mcp/sse/client/tests.rs vendored Normal file

File diff suppressed because it is too large Load Diff

280
vendor/mentra/src/mcp/sse/config.rs vendored Normal file
View File

@@ -0,0 +1,280 @@
//! Configuration for the legacy MCP HTTP+SSE transport.
#[cfg(test)]
mod tests;
use std::collections::BTreeMap;
use std::time::Duration;
use serde::Deserialize;
use url::Url;
use super::endpoint::{EndpointError, validate_stream_url};
/// Default timeout for opening the SSE stream and reading its response head.
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
/// Default timeout for the MCP `initialize` handshake, matching the stdio client.
pub const DEFAULT_INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10);
/// Default timeout for `tools/list`, matching the stdio client.
pub const DEFAULT_LIST_TOOLS_TIMEOUT: Duration = Duration::from_secs(30);
/// Default timeout for `tools/call`, matching the stdio client.
pub const DEFAULT_CALL_TOOL_TIMEOUT: Duration = Duration::from_secs(120);
/// Default idle timeout between stream reads.
///
/// Servers built on `sse-starlette` — which covers most Python MCP servers —
/// emit a comment heartbeat every 15 seconds, so five minutes of silence means
/// the stream is dead rather than quiet.
pub const DEFAULT_STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(300);
/// Default cap on the bytes buffered for a single SSE event.
///
/// The largest legitimate event is a `tools/call` result carrying base64
/// content; 4 MiB of base64 is roughly 3 MB of binary, which is generous for a
/// tool result while bounding worst-case memory per connection.
pub const DEFAULT_MAX_EVENT_BYTES: usize = 4 * 1024 * 1024;
/// Default cap on the bytes buffered for the `endpoint` event specifically.
///
/// The endpoint event is processed before any request correlation exists, so it
/// is the earliest attacker-reachable allocation in the connection lifecycle.
/// Its payload is a single URL, and common proxy header limits sit near 2 KiB.
pub const DEFAULT_MAX_ENDPOINT_BYTES: usize = 8 * 1024;
/// Default cap on how many `tools/list` pages are followed.
///
/// Cursors are opaque, so a server repeating one cannot be detected by value;
/// only a page bound stops the walk. A server needing more pages than this to
/// describe its tools is malfunctioning.
pub const DEFAULT_MAX_TOOL_PAGES: usize = 1_000;
/// A header value that is never rendered by `Debug` or `Display`.
///
/// Redaction is a property of this type rather than of each container, so every
/// struct that derives `Debug` inherits it without a rule for contributors to
/// remember.
///
/// This type deliberately does **not** implement [`serde::Serialize`]. Adding
/// `#[derive(Serialize)]` to any struct holding one is therefore a compile
/// error rather than a silent credential leak into a config dump, a state
/// snapshot, or a session-persistence layer.
#[derive(Clone, PartialEq, Eq, Deserialize)]
#[serde(transparent)]
pub struct SecretString(String);
impl SecretString {
/// Wraps a value that must not be logged.
pub fn new(value: impl Into<String>) -> Self {
Self(value.into())
}
/// Returns the wrapped value.
///
/// This is the single grep-able point at which a secret becomes visible.
pub fn expose_secret(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for SecretString {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SecretString([redacted])")
}
}
impl<T: Into<String>> From<T> for SecretString {
fn from(value: T) -> Self {
Self::new(value)
}
}
/// Timeouts and size limits for one SSE connection.
///
/// These are separated from the operator-facing fields of
/// [`McpSseServerConfig`] because they are tuning knobs for the host rather
/// than something an operator writes in a configuration file.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpSseLimits {
/// Bound on opening the stream and reading its response head.
pub connect_timeout: Duration,
/// Bound on the `initialize` handshake.
pub initialize_timeout: Duration,
/// Bound on each `tools/list` page.
pub list_tools_timeout: Duration,
/// Bound on each `tools/call`.
pub call_tool_timeout: Duration,
/// Bound on silence between reads on the SSE stream.
pub stream_idle_timeout: Duration,
/// Bound on the bytes buffered for a single SSE event.
pub max_event_bytes: usize,
/// Bound on the bytes buffered for the `endpoint` event.
pub max_endpoint_bytes: usize,
/// Bound on how many `tools/list` pages are followed.
pub max_tool_pages: usize,
}
impl Default for McpSseLimits {
fn default() -> Self {
Self {
connect_timeout: DEFAULT_CONNECT_TIMEOUT,
initialize_timeout: DEFAULT_INITIALIZE_TIMEOUT,
list_tools_timeout: DEFAULT_LIST_TOOLS_TIMEOUT,
call_tool_timeout: DEFAULT_CALL_TOOL_TIMEOUT,
stream_idle_timeout: DEFAULT_STREAM_IDLE_TIMEOUT,
max_event_bytes: DEFAULT_MAX_EVENT_BYTES,
max_endpoint_bytes: DEFAULT_MAX_ENDPOINT_BYTES,
max_tool_pages: DEFAULT_MAX_TOOL_PAGES,
}
}
}
/// Configuration for an MCP server reachable over the legacy HTTP+SSE
/// transport.
///
/// This is the SSE counterpart to [`McpServerConfig`](crate::mcp::McpServerConfig),
/// which remains the stdio configuration type.
///
/// # Example
///
/// ```rust
/// use mentra::mcp::McpSseServerConfig;
///
/// let config = McpSseServerConfig::new("observability", "https://mcp.example.com/sse")
/// .with_header("authorization", "Bearer <token>");
/// ```
///
/// # Security
///
/// Header values are stored as [`SecretString`] and never appear in `Debug`
/// output, error messages, or logs. Configuring a header on a plaintext `http://`
/// URL is rejected unless the host is loopback, because the credential would
/// otherwise cross the network in the clear; see
/// [`allow_plaintext_credentials`](Self::allow_plaintext_credentials) to
/// override that deliberately.
#[derive(Debug, Clone, Deserialize)]
pub struct McpSseServerConfig {
/// Display name for the server, used to namespace its bridged tools.
pub name: String,
/// The operator-configured SSE stream URL, opened with a long-lived `GET`.
pub url: String,
/// Headers sent on both the SSE `GET` and every JSON-RPC `POST`.
#[serde(default)]
pub headers: BTreeMap<String, SecretString>,
/// Permits sending configured headers over plaintext `http://` to a
/// non-loopback host.
///
/// This exists so the refusal is overridable but never accidental.
#[serde(default)]
pub allow_plaintext_credentials: bool,
/// Timeouts and size limits, defaulted rather than deserialized.
#[serde(skip)]
pub limits: McpSseLimits,
}
/// Errors from validating an [`McpSseServerConfig`].
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum McpSseConfigError {
#[error("invalid MCP SSE stream URL: {0}")]
Url(#[from] EndpointError),
#[error("MCP SSE server name must not be empty")]
EmptyName,
#[error("invalid MCP SSE header name '{name}'")]
InvalidHeaderName { name: String },
/// Rendered without the value so a malformed credential never reaches a log.
#[error("MCP SSE header '{name}' has a value that is not valid for HTTP")]
InvalidHeaderValue { name: String },
#[error(
"refusing to send configured headers to '{url}' over plaintext http; \
use https, a loopback host, or set allow_plaintext_credentials"
)]
PlaintextCredentials { url: String },
}
impl McpSseServerConfig {
/// Creates a configuration with default timeouts and limits.
pub fn new(name: impl Into<String>, url: impl Into<String>) -> Self {
Self {
name: name.into(),
url: url.into(),
headers: BTreeMap::new(),
allow_plaintext_credentials: false,
limits: McpSseLimits::default(),
}
}
/// Adds a header sent on both the SSE `GET` and every JSON-RPC `POST`.
pub fn with_header(mut self, name: impl Into<String>, value: impl Into<SecretString>) -> Self {
self.headers.insert(name.into(), value.into());
self
}
/// Adds a bearer `Authorization` header.
pub fn with_bearer_token(self, token: impl Into<String>) -> Self {
self.with_header(
"authorization",
SecretString::new(format!("Bearer {}", token.into())),
)
}
/// Replaces the timeouts and size limits.
pub fn with_limits(mut self, limits: McpSseLimits) -> Self {
self.limits = limits;
self
}
/// Permits sending configured headers over plaintext `http://`.
pub fn allowing_plaintext_credentials(mut self) -> Self {
self.allow_plaintext_credentials = true;
self
}
/// Validates the configuration and returns the parsed stream URL.
///
/// Runs before any connection is opened so a bad configuration fails at the
/// boundary rather than mid-handshake.
/// Checks the URL, header names, and credential handling without
/// connecting, so a host can reject a bad configuration at its own
/// boundary rather than discovering it mid-build.
pub fn validate(&self) -> Result<Url, McpSseConfigError> {
if self.name.trim().is_empty() {
return Err(McpSseConfigError::EmptyName);
}
let url = validate_stream_url(&self.url)?;
for (name, value) in &self.headers {
if reqwest::header::HeaderName::try_from(name.as_str()).is_err() {
return Err(McpSseConfigError::InvalidHeaderName {
name: name.to_string(),
});
}
if reqwest::header::HeaderValue::try_from(value.expose_secret()).is_err() {
return Err(McpSseConfigError::InvalidHeaderValue {
name: name.to_string(),
});
}
}
if !self.headers.is_empty()
&& url.scheme() == "http"
&& !self.allow_plaintext_credentials
&& !is_loopback(&url)
{
return Err(McpSseConfigError::PlaintextCredentials {
url: self.url.clone(),
});
}
Ok(url)
}
}
/// Reports whether a URL addresses the loopback interface.
fn is_loopback(url: &Url) -> bool {
match url.host() {
Some(url::Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"),
Some(url::Host::Ipv4(address)) => address.is_loopback(),
Some(url::Host::Ipv6(address)) => address.is_loopback(),
None => false,
}
}

View File

@@ -0,0 +1,243 @@
//! Tests for SSE server configuration and secret redaction.
use super::{McpSseConfigError, McpSseServerConfig, SecretString};
// ---------------------------------------------------------------------------
// Secret redaction
// ---------------------------------------------------------------------------
#[test]
fn secret_debug_output_hides_the_value() {
let secret = SecretString::new("Bearer super-secret-token");
let rendered = format!("{secret:?}");
assert!(!rendered.contains("super-secret-token"), "got {rendered}");
assert_eq!(rendered, "SecretString([redacted])");
}
#[test]
fn secret_alternate_debug_output_hides_the_value() {
let secret = SecretString::new("Bearer super-secret-token");
let rendered = format!("{secret:#?}");
assert!(!rendered.contains("super-secret-token"), "got {rendered}");
}
#[test]
fn exposing_a_secret_returns_the_original_value() {
let secret = SecretString::new("Bearer token");
assert_eq!(secret.expose_secret(), "Bearer token");
}
#[test]
fn config_debug_redacts_header_values_but_keeps_names() {
let config = McpSseServerConfig::new("obs", "https://mcp.example.com/sse")
.with_header("authorization", "Bearer super-secret-token")
.with_header("x-tenant", "acme");
let rendered = format!("{config:?}");
assert!(
!rendered.contains("super-secret-token"),
"the token must not appear: {rendered}"
);
assert!(
!rendered.contains("acme"),
"no header value may appear: {rendered}"
);
assert!(
rendered.contains("authorization"),
"header names stay visible for diagnosis: {rendered}"
);
assert!(rendered.contains("x-tenant"), "got {rendered}");
assert!(rendered.contains("mcp.example.com"), "got {rendered}");
}
#[test]
fn config_alternate_debug_redacts_header_values() {
let config = McpSseServerConfig::new("obs", "https://mcp.example.com/sse")
.with_bearer_token("super-secret-token");
let rendered = format!("{config:#?}");
assert!(!rendered.contains("super-secret-token"), "got {rendered}");
}
#[test]
fn a_bearer_token_is_stored_as_an_authorization_header() {
let config =
McpSseServerConfig::new("obs", "https://mcp.example.com/sse").with_bearer_token("abc123");
let value = config
.headers
.get("authorization")
.expect("bearer token sets the authorization header");
assert_eq!(value.expose_secret(), "Bearer abc123");
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
#[test]
fn accepts_an_https_url_with_headers() {
let config =
McpSseServerConfig::new("obs", "https://mcp.example.com/sse").with_bearer_token("abc123");
let url = config.validate().expect("https with headers is allowed");
assert_eq!(url.host_str(), Some("mcp.example.com"));
}
#[test]
fn accepts_a_plain_http_url_without_headers() {
let config = McpSseServerConfig::new("local", "http://internal.corp:8080/sse");
config
.validate()
.expect("plaintext without credentials is allowed");
}
#[test]
fn rejects_plaintext_credentials_to_a_remote_host() {
let config =
McpSseServerConfig::new("obs", "http://internal.corp/sse").with_bearer_token("abc123");
let error = config
.validate()
.expect_err("a token must not cross the network in the clear");
assert!(matches!(
error,
McpSseConfigError::PlaintextCredentials { .. }
));
}
#[test]
fn allows_plaintext_credentials_to_localhost() {
let config =
McpSseServerConfig::new("local", "http://localhost:3000/sse").with_bearer_token("abc123");
config
.validate()
.expect("loopback never leaves the machine");
}
#[test]
fn allows_plaintext_credentials_to_the_ipv4_loopback_address() {
let config =
McpSseServerConfig::new("local", "http://127.0.0.1:3000/sse").with_bearer_token("abc");
config.validate().expect("127.0.0.1 is loopback");
}
#[test]
fn allows_plaintext_credentials_to_the_ipv6_loopback_address() {
let config = McpSseServerConfig::new("local", "http://[::1]:3000/sse").with_bearer_token("abc");
config.validate().expect("::1 is loopback");
}
#[test]
fn allows_plaintext_credentials_when_explicitly_opted_in() {
let config = McpSseServerConfig::new("obs", "http://internal.corp/sse")
.with_bearer_token("abc123")
.allowing_plaintext_credentials();
config
.validate()
.expect("the operator may override deliberately");
}
#[test]
fn rejects_an_empty_server_name() {
let config = McpSseServerConfig::new(" ", "https://mcp.example.com/sse");
let error = config.validate().expect_err("a name is required");
assert!(matches!(error, McpSseConfigError::EmptyName));
}
#[test]
fn rejects_an_unsupported_url_scheme() {
let config = McpSseServerConfig::new("obs", "ws://mcp.example.com/sse");
let error = config
.validate()
.expect_err("only http and https are allowed");
assert!(matches!(error, McpSseConfigError::Url(_)));
}
#[test]
fn rejects_a_url_with_embedded_credentials() {
let config = McpSseServerConfig::new("obs", "https://user:pass@mcp.example.com/sse");
let error = config
.validate()
.expect_err("credentials belong in headers, not the URL");
assert!(matches!(error, McpSseConfigError::Url(_)));
}
#[test]
fn rejects_a_header_name_that_is_not_valid_for_http() {
let config = McpSseServerConfig::new("obs", "https://mcp.example.com/sse")
.with_header("bad header", "value");
let error = config.validate().expect_err("header names are validated");
assert!(matches!(error, McpSseConfigError::InvalidHeaderName { .. }));
}
#[test]
fn rejects_a_header_value_that_is_not_valid_for_http() {
let config = McpSseServerConfig::new("obs", "https://mcp.example.com/sse")
.with_header("authorization", "Bearer \nInjected: header");
let error = config.validate().expect_err("header values are validated");
assert!(matches!(
error,
McpSseConfigError::InvalidHeaderValue { .. }
));
}
#[test]
fn the_invalid_header_value_error_does_not_echo_the_value() {
let config = McpSseServerConfig::new("obs", "https://mcp.example.com/sse")
.with_header("authorization", "Bearer \nsuper-secret");
let error = config.validate().expect_err("header values are validated");
let rendered = error.to_string();
assert!(
!rendered.contains("super-secret"),
"the error must not echo a secret: {rendered}"
);
assert!(rendered.contains("authorization"), "got {rendered}");
}
// ---------------------------------------------------------------------------
// Defaults
// ---------------------------------------------------------------------------
#[test]
fn default_limits_match_the_stdio_client_where_the_concept_is_shared() {
let limits = McpSseServerConfig::new("obs", "https://mcp.example.com/sse").limits;
assert_eq!(
limits.initialize_timeout,
std::time::Duration::from_secs(10)
);
assert_eq!(
limits.list_tools_timeout,
std::time::Duration::from_secs(30)
);
assert_eq!(
limits.call_tool_timeout,
std::time::Duration::from_secs(120)
);
}
#[test]
fn the_endpoint_limit_is_tighter_than_the_general_event_limit() {
let limits = McpSseServerConfig::new("obs", "https://mcp.example.com/sse").limits;
assert!(
limits.max_endpoint_bytes < limits.max_event_bytes,
"the pre-correlation surface must be smaller"
);
}
#[test]
fn a_config_deserializes_from_json_without_limits() {
let config: McpSseServerConfig = serde_json::from_value(serde_json::json!({
"name": "obs",
"url": "https://mcp.example.com/sse",
"headers": {"authorization": "Bearer abc123"}
}))
.expect("deserialize");
assert_eq!(config.name, "obs");
assert_eq!(
config
.headers
.get("authorization")
.expect("header")
.expose_secret(),
"Bearer abc123"
);
assert_eq!(config.limits, super::McpSseLimits::default());
}

134
vendor/mentra/src/mcp/sse/endpoint.rs vendored Normal file
View File

@@ -0,0 +1,134 @@
//! Endpoint URL resolution and same-origin enforcement.
//!
//! The legacy transport lets the *server* name the URL that the client will
//! POST JSON-RPC requests to, by sending it in an `endpoint` event. That makes
//! the endpoint value attacker-controlled whenever the server is compromised,
//! so it is validated before any request — and therefore any configured
//! `Authorization` header — is sent to it.
//!
//! The rule is deliberately strict: the resolved endpoint must share the
//! configured stream URL's scheme, host, and effective port. Anything else is
//! refused rather than normalized, because every relaxation here is a way to
//! redirect credentials to a host the operator never configured.
#[cfg(test)]
mod tests;
use url::Url;
/// Schemes this transport is willing to speak.
const ALLOWED_SCHEMES: [&str; 2] = ["http", "https"];
/// Errors from validating a stream URL or a server-supplied endpoint.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum EndpointError {
#[error("the MCP server sent an empty endpoint event")]
Empty,
#[error("could not parse the MCP endpoint URL: {0}")]
Malformed(String),
#[error("unsupported MCP endpoint scheme '{scheme}': only http and https are allowed")]
UnsupportedScheme { scheme: String },
#[error("the MCP endpoint URL has no host")]
MissingHost,
/// Rendered without the credentials themselves so a password in a
/// misconfigured URL never reaches a log.
#[error("the MCP endpoint URL must not embed credentials")]
CredentialsInUrl,
#[error(
"the MCP server directed requests to '{endpoint}', which is not the configured origin '{configured}'"
)]
CrossOrigin {
endpoint: String,
configured: String,
},
}
/// Validates an operator-configured SSE stream URL.
///
/// This runs before any connection is opened so that a bad configuration fails
/// at the boundary rather than mid-handshake.
pub(crate) fn validate_stream_url(raw: &str) -> Result<Url, EndpointError> {
let url = Url::parse(raw.trim())
.map_err(|_| EndpointError::Malformed("invalid URL syntax".to_string()))?;
check_scheme(&url)?;
check_no_credentials(&url)?;
if url.host_str().is_none() {
return Err(EndpointError::MissingHost);
}
Ok(url)
}
/// Resolves a server-supplied endpoint against the stream URL and enforces that
/// it stays on the same origin.
///
/// `raw` is the `data` payload of the `endpoint` event. It is commonly a
/// relative path such as `/messages/?session_id=abc`, but the specification
/// also permits an absolute URL, so both are resolved through [`Url::join`].
pub(crate) fn resolve_endpoint(stream_url: &Url, raw: &str) -> Result<Url, EndpointError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(EndpointError::Empty);
}
let endpoint = stream_url
.join(trimmed)
.map_err(|_| EndpointError::Malformed("invalid URL syntax".to_string()))?;
check_scheme(&endpoint)?;
check_no_credentials(&endpoint)?;
check_same_origin(stream_url, &endpoint)?;
Ok(endpoint)
}
/// Rejects any scheme outside the allowlist.
///
/// This also covers `javascript:`, `data:`, and `file:`, which [`Url::join`]
/// happily produces from an absolute URL in the event payload.
fn check_scheme(url: &Url) -> Result<(), EndpointError> {
if ALLOWED_SCHEMES.contains(&url.scheme()) {
return Ok(());
}
Err(EndpointError::UnsupportedScheme {
scheme: "[value omitted]".to_string(),
})
}
/// Rejects a URL carrying userinfo.
///
/// [`Url::origin`] ignores userinfo, so without this check a server could send
/// `https://attacker@configured-host/` and pass the origin comparison while
/// changing what the client transmits.
fn check_no_credentials(url: &Url) -> Result<(), EndpointError> {
if url.username().is_empty() && url.password().is_none() {
return Ok(());
}
Err(EndpointError::CredentialsInUrl)
}
/// Requires an exact match on scheme, host, and effective port.
///
/// The comparison uses [`Url::port_or_known_default`] so that an explicit
/// default port (`https://host:443`) and an implicit one (`https://host`) are
/// treated as the same origin, and [`Url::host`] rather than the raw string so
/// that equivalent IP literal spellings compare equal. Host names are compared
/// exactly: a trailing dot or a punycode homograph is a different origin.
fn check_same_origin(stream_url: &Url, endpoint: &Url) -> Result<(), EndpointError> {
let same = stream_url.scheme() == endpoint.scheme()
&& stream_url.host() == endpoint.host()
&& stream_url.port_or_known_default() == endpoint.port_or_known_default();
if same {
return Ok(());
}
Err(EndpointError::CrossOrigin {
endpoint: "[server-supplied origin omitted]".to_string(),
configured: "[configured origin]".to_string(),
})
}

View File

@@ -0,0 +1,337 @@
//! Tests for endpoint URL resolution and same-origin enforcement.
use url::Url;
use super::{EndpointError, resolve_endpoint, validate_stream_url};
/// The configured SSE URL every test resolves against.
const BASE: &str = "https://good-host.example/sse";
fn base() -> Url {
Url::parse(BASE).expect("base URL should parse")
}
fn resolve(raw: &str) -> Result<Url, EndpointError> {
resolve_endpoint(&base(), raw)
}
// ---------------------------------------------------------------------------
// Accepted endpoints
// ---------------------------------------------------------------------------
#[test]
fn resolves_an_absolute_path_against_the_stream_url() {
let endpoint = resolve("/messages/?session_id=abc").expect("same-origin path is allowed");
assert_eq!(
endpoint.as_str(),
"https://good-host.example/messages/?session_id=abc"
);
}
#[test]
fn resolves_a_relative_path_against_the_stream_url() {
let base = Url::parse("https://good-host.example/mcp/sse").expect("base should parse");
let endpoint = resolve_endpoint(&base, "messages?session_id=abc").expect("relative is allowed");
assert_eq!(
endpoint.as_str(),
"https://good-host.example/mcp/messages?session_id=abc"
);
}
#[test]
fn accepts_an_absolute_url_on_the_same_origin() {
let endpoint =
resolve("https://good-host.example/messages/").expect("same-origin absolute is allowed");
assert_eq!(endpoint.as_str(), "https://good-host.example/messages/");
}
#[test]
fn accepts_an_explicit_default_port_matching_the_implicit_one() {
// https://host and https://host:443 are the same origin.
let endpoint = resolve("https://good-host.example:443/messages/")
.expect("default port is the same origin");
assert_eq!(endpoint.as_str(), "https://good-host.example/messages/");
}
#[test]
fn accepts_an_implicit_default_port_matching_an_explicit_one() {
let base = Url::parse("https://good-host.example:443/sse").expect("base should parse");
let endpoint = resolve_endpoint(&base, "https://good-host.example/messages/")
.expect("implicit port is the same origin");
assert_eq!(endpoint.as_str(), "https://good-host.example/messages/");
}
#[test]
fn accepts_a_matching_non_default_port() {
let base = Url::parse("http://127.0.0.1:8080/sse").expect("base should parse");
let endpoint =
resolve_endpoint(&base, "/messages/?session_id=abc").expect("same port is allowed");
assert_eq!(
endpoint.as_str(),
"http://127.0.0.1:8080/messages/?session_id=abc"
);
}
#[test]
fn accepts_a_host_differing_only_by_case() {
// Host comparison is case-insensitive because the parser normalizes it.
let endpoint =
resolve("https://GOOD-HOST.EXAMPLE/messages/").expect("host case is not significant");
assert_eq!(endpoint.as_str(), "https://good-host.example/messages/");
}
#[test]
fn accepts_a_plain_http_origin_when_the_stream_is_plain_http() {
let base = Url::parse("http://localhost:3000/sse").expect("base should parse");
let endpoint = resolve_endpoint(&base, "/messages/").expect("http is an allowed scheme");
assert_eq!(endpoint.as_str(), "http://localhost:3000/messages/");
}
#[test]
fn preserves_the_query_string_carrying_the_session_id() {
let endpoint = resolve("/messages/?session_id=6c8f2a&foo=bar").expect("query is preserved");
assert_eq!(endpoint.query(), Some("session_id=6c8f2a&foo=bar"));
}
// ---------------------------------------------------------------------------
// Rejected endpoints — cross-origin
// ---------------------------------------------------------------------------
#[test]
fn rejects_an_absolute_url_on_a_different_host() {
let error = resolve("https://evil.example/steal").expect_err("cross-host must be rejected");
assert!(matches!(error, EndpointError::CrossOrigin { .. }));
}
#[test]
fn rejects_a_protocol_relative_url_that_replaces_the_authority() {
// `//evil.example/x` inherits only the scheme; url::join gives it a NEW host.
let error = resolve("//evil.example/steal").expect_err("protocol-relative must be rejected");
assert!(matches!(error, EndpointError::CrossOrigin { .. }));
}
#[test]
fn rejects_a_backslash_authority_that_url_normalizes_to_a_new_host() {
// url normalizes leading backslashes the way browsers do, yielding a new host.
let error = resolve("/\\evil.example/steal").expect_err("backslash authority must be rejected");
assert!(matches!(error, EndpointError::CrossOrigin { .. }));
}
#[test]
fn rejects_a_scheme_downgrade_to_plain_http() {
let error =
resolve("http://good-host.example/messages/").expect_err("downgrade must be rejected");
assert!(matches!(error, EndpointError::CrossOrigin { .. }));
}
#[test]
fn rejects_a_scheme_upgrade_to_https() {
let base = Url::parse("http://good-host.example/sse").expect("base should parse");
let error = resolve_endpoint(&base, "https://good-host.example/messages/")
.expect_err("scheme change must be rejected");
assert!(matches!(error, EndpointError::CrossOrigin { .. }));
}
#[test]
fn rejects_a_different_explicit_port() {
let error =
resolve("https://good-host.example:8443/messages/").expect_err("port change is rejected");
assert!(matches!(error, EndpointError::CrossOrigin { .. }));
}
#[test]
fn rejects_a_trailing_dot_host_that_resolves_to_the_same_name() {
// `good-host.example.` is a distinct host string; treat it as cross-origin
// rather than guessing at DNS equivalence.
let error =
resolve("https://good-host.example./messages/").expect_err("trailing dot is rejected");
assert!(matches!(error, EndpointError::CrossOrigin { .. }));
}
#[test]
fn rejects_a_punycode_homograph_host() {
let error = resolve("https://g\u{f6}\u{f6}d-host.example/messages/")
.expect_err("homograph host is rejected");
assert!(matches!(error, EndpointError::CrossOrigin { .. }));
}
#[test]
fn rejects_a_subdomain_of_the_configured_host() {
let error = resolve("https://evil.good-host.example/messages/")
.expect_err("subdomains are a different origin");
assert!(matches!(error, EndpointError::CrossOrigin { .. }));
}
#[test]
fn rejects_a_suffix_extension_of_the_configured_host() {
let error = resolve("https://good-host.example.evil.test/messages/")
.expect_err("suffix extension is a different origin");
assert!(matches!(error, EndpointError::CrossOrigin { .. }));
}
// ---------------------------------------------------------------------------
// Rejected endpoints — credentials and schemes
// ---------------------------------------------------------------------------
#[test]
fn rejects_userinfo_even_on_the_matching_origin() {
// url::Origin ignores userinfo, so an explicit check is required: credentials
// in the URL would be sent to the server alongside the configured headers.
let error = resolve("https://attacker@good-host.example/messages/")
.expect_err("userinfo must be rejected");
assert!(matches!(error, EndpointError::CredentialsInUrl));
}
#[test]
fn rejects_a_password_in_the_endpoint_url() {
let error = resolve("https://user:secret@good-host.example/messages/")
.expect_err("password must be rejected");
assert!(matches!(error, EndpointError::CredentialsInUrl));
}
#[test]
fn rejects_a_javascript_scheme() {
let error = resolve("javascript:alert(1)").expect_err("javascript must be rejected");
assert!(matches!(error, EndpointError::UnsupportedScheme { .. }));
}
#[test]
fn rejects_a_data_scheme() {
let error = resolve("data:text/plain,hi").expect_err("data must be rejected");
assert!(matches!(error, EndpointError::UnsupportedScheme { .. }));
}
#[test]
fn rejects_a_file_scheme() {
let error = resolve("file:///etc/passwd").expect_err("file must be rejected");
assert!(matches!(error, EndpointError::UnsupportedScheme { .. }));
}
// ---------------------------------------------------------------------------
// Rejected endpoints — malformed
// ---------------------------------------------------------------------------
#[test]
fn rejects_an_empty_endpoint_payload() {
let error = resolve("").expect_err("an empty endpoint must be rejected");
assert!(matches!(error, EndpointError::Empty));
}
#[test]
fn rejects_a_whitespace_only_endpoint_payload() {
let error = resolve(" ").expect_err("a blank endpoint must be rejected");
assert!(matches!(error, EndpointError::Empty));
}
#[test]
fn rejects_an_unparseable_endpoint() {
let error = resolve("http://[not-an-address/x").expect_err("garbage must be rejected");
assert!(matches!(error, EndpointError::Malformed(_)));
}
#[test]
fn trims_surrounding_whitespace_before_resolving() {
// Servers occasionally pad the data field; trimming must happen before the
// origin check so it cannot be used to smuggle a different authority.
let endpoint = resolve(" /messages/?session_id=abc ").expect("padding is trimmed");
assert_eq!(
endpoint.as_str(),
"https://good-host.example/messages/?session_id=abc"
);
}
#[test]
fn rejects_a_padded_cross_origin_endpoint() {
let error =
resolve(" https://evil.example/steal ").expect_err("padding does not bypass the check");
assert!(matches!(error, EndpointError::CrossOrigin { .. }));
}
// ---------------------------------------------------------------------------
// Stream URL validation
// ---------------------------------------------------------------------------
#[test]
fn accepts_an_https_stream_url() {
let url = validate_stream_url("https://good-host.example/sse").expect("https is allowed");
assert_eq!(url.scheme(), "https");
}
#[test]
fn accepts_an_http_stream_url() {
let url = validate_stream_url("http://127.0.0.1:9000/sse").expect("http is allowed");
assert_eq!(url.scheme(), "http");
}
#[test]
fn rejects_a_stream_url_with_an_unsupported_scheme() {
let error = validate_stream_url("ws://good-host.example/sse").expect_err("ws is rejected");
assert!(matches!(error, EndpointError::UnsupportedScheme { .. }));
}
#[test]
fn rejects_a_stream_url_with_embedded_credentials() {
let error = validate_stream_url("https://user:pass@good-host.example/sse")
.expect_err("credentials are rejected");
assert!(matches!(error, EndpointError::CredentialsInUrl));
}
#[test]
fn rejects_a_stream_url_without_a_host() {
let error = validate_stream_url("file:///tmp/sse").expect_err("a hostless URL is rejected");
assert!(matches!(
error,
EndpointError::UnsupportedScheme { .. } | EndpointError::MissingHost
));
}
#[test]
fn rejects_an_unparseable_stream_url() {
let error = validate_stream_url("not a url").expect_err("garbage is rejected");
assert!(matches!(error, EndpointError::Malformed(_)));
}
// ---------------------------------------------------------------------------
// Error reporting
// ---------------------------------------------------------------------------
#[test]
fn the_cross_origin_error_does_not_retain_either_origin() {
let error =
resolve("https://remote-canary.invalid/steal").expect_err("cross-origin is rejected");
let rendered = error.to_string();
let debug = format!("{error:?}");
for origin in ["remote-canary.invalid", "good-host.example"] {
assert!(!rendered.contains(origin), "got {rendered}");
assert!(!debug.contains(origin), "got {debug}");
}
}
#[test]
fn unsupported_scheme_errors_do_not_retain_the_scheme() {
let error = resolve("remote-canary:payload").expect_err("the scheme is unsupported");
let rendered = error.to_string();
let debug = format!("{error:?}");
assert!(!rendered.contains("remote-canary"), "got {rendered}");
assert!(!debug.contains("remote-canary"), "got {debug}");
}
#[test]
fn malformed_endpoint_errors_do_not_retain_the_payload() {
let error = resolve("http://[remote-canary.invalid").expect_err("the endpoint is malformed");
let rendered = error.to_string();
let debug = format!("{error:?}");
assert!(!rendered.contains("remote-canary"), "got {rendered}");
assert!(!debug.contains("remote-canary"), "got {debug}");
}
#[test]
fn the_credentials_error_does_not_echo_the_credentials() {
let error = resolve("https://user:hunter2@good-host.example/messages/")
.expect_err("credentials are rejected");
let rendered = error.to_string();
assert!(
!rendered.contains("hunter2"),
"the error must not echo a secret: {rendered}"
);
}

497
vendor/mentra/src/mcp/sse/testing.rs vendored Normal file
View File

@@ -0,0 +1,497 @@
//! A deterministic local HTTP+SSE server for transport tests.
//!
//! The transport needs a fixture that keeps one connection parked on a
//! long-lived `GET` while serving `POST` requests on other connections, and
//! that lets a test decide exactly when each SSE event reaches the client. A
//! raw [`TcpListener`] driven from [`std::thread`] gives that control with no
//! new dependencies, matching the fixtures already used in `mentra-provider`.
//!
//! Two properties keep the resulting tests deterministic rather than
//! timing-dependent:
//!
//! - **A thread per connection.** One accept loop hands each connection to its
//! own thread, so a blocking read on the parked `GET` cannot stop a `POST`
//! from being answered.
//! - **Chunked framing with a flush per event.** Chunked encoding is used
//! rather than read-until-close so that a clean end (`0\r\n\r\n`) and an
//! abrupt truncation are distinguishable by the client. Without the explicit
//! flush the operating system coalesces writes and the test would pass even
//! for a client that buffered the whole body.
use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Condvar, Mutex, mpsc};
use std::thread;
use std::time::Duration;
/// How a fixture answers one JSON-RPC `POST`.
#[derive(Debug, Clone)]
pub(crate) enum PostReply {
/// Answer `202 Accepted`, the reference servers' behavior.
Accepted,
/// Answer `200 OK` with an empty body.
Ok,
/// Answer with the given status and body.
Status { code: u16, body: String },
/// Answer `307` pointing at another origin, which must not be followed.
Redirect { location: String },
/// Close the connection without answering.
Drop,
/// Read the complete request, then withhold the response headers.
StallBeforeHeaders,
/// Send a successful response head, then withhold its declared body.
StallAfterHeaders,
}
/// A request captured by the fixture.
#[derive(Debug, Clone)]
pub(crate) struct CapturedRequest {
pub(crate) method: String,
pub(crate) target: String,
pub(crate) headers: HashMap<String, String>,
pub(crate) body: String,
}
impl CapturedRequest {
/// Returns the JSON-RPC method of the captured body, if it has one.
pub(crate) fn rpc_method(&self) -> Option<String> {
serde_json::from_str::<serde_json::Value>(&self.body)
.ok()?
.get("method")?
.as_str()
.map(str::to_string)
}
/// Returns the JSON-RPC id of the captured body, if it has one.
pub(crate) fn rpc_id(&self) -> Option<u64> {
serde_json::from_str::<serde_json::Value>(&self.body)
.ok()?
.get("id")?
.as_u64()
}
/// Returns a header value by its lowercase name.
pub(crate) fn header(&self, name: &str) -> Option<&str> {
self.headers.get(name).map(String::as_str)
}
}
/// What the fixture should do when the SSE `GET` arrives.
#[derive(Debug, Clone)]
pub(crate) enum StreamOpening {
/// Accept the stream and serve events on demand.
Accept,
/// Answer `200` with a content type that is not `text/event-stream`.
WrongContentType,
/// Answer with the given status and body.
Status { code: u16, body: String },
/// Answer `307` pointing elsewhere, which must not be followed.
Redirect { location: String },
}
/// Shared state between the test and the fixture's threads.
struct Shared {
requests: Mutex<Vec<CapturedRequest>>,
replies: Mutex<Vec<PostReply>>,
stream_opened: (Mutex<bool>, Condvar),
posts_seen: (Mutex<usize>, Condvar),
post_headers_sent: (Mutex<usize>, Condvar),
stalled_posts_released: (Mutex<bool>, Condvar),
connections: AtomicUsize,
}
/// Instruction sent to the thread holding the SSE connection open.
enum StreamCommand {
/// Write raw bytes as one chunk and flush.
Write(String),
/// End the stream cleanly with a terminal chunk.
Close,
/// Drop the connection without a terminal chunk, simulating a truncation.
Abort,
}
/// A running local MCP HTTP+SSE server.
pub(crate) struct SseTestServer {
base_url: String,
shared: Arc<Shared>,
commands: mpsc::Sender<StreamCommand>,
}
impl SseTestServer {
/// Starts a fixture that accepts the SSE stream and answers every `POST`
/// with `202 Accepted`.
pub(crate) fn start() -> Self {
Self::with_opening(StreamOpening::Accept)
}
/// Starts a fixture whose SSE `GET` is answered as described.
pub(crate) fn with_opening(opening: StreamOpening) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind the fixture listener");
let addr = listener.local_addr().expect("read the fixture address");
let shared = Arc::new(Shared {
requests: Mutex::new(Vec::new()),
replies: Mutex::new(Vec::new()),
stream_opened: (Mutex::new(false), Condvar::new()),
posts_seen: (Mutex::new(0), Condvar::new()),
post_headers_sent: (Mutex::new(0), Condvar::new()),
stalled_posts_released: (Mutex::new(false), Condvar::new()),
connections: AtomicUsize::new(0),
});
let (commands, command_rx) = mpsc::channel();
let accept_shared = Arc::clone(&shared);
let command_rx = Arc::new(Mutex::new(command_rx));
thread::spawn(move || {
for incoming in listener.incoming() {
let Ok(stream) = incoming else { break };
accept_shared.connections.fetch_add(1, Ordering::SeqCst);
let shared = Arc::clone(&accept_shared);
let command_rx = Arc::clone(&command_rx);
let opening = opening.clone();
thread::spawn(move || serve_connection(stream, shared, command_rx, opening));
}
});
Self {
base_url: format!("http://{addr}"),
shared,
commands,
}
}
/// The URL of the SSE stream endpoint.
pub(crate) fn sse_url(&self) -> String {
format!("{}/sse", self.base_url)
}
/// The fixture's origin, for building cross-origin cases.
pub(crate) fn base_url(&self) -> &str {
&self.base_url
}
/// Queues the reply used for the next `POST`.
///
/// Replies are consumed in order; once the queue is empty every `POST` is
/// answered `202 Accepted`.
pub(crate) fn queue_post_reply(&self, reply: PostReply) {
self.shared
.replies
.lock()
.expect("lock the reply queue")
.push(reply);
}
/// Blocks until the client has opened the SSE stream.
pub(crate) fn wait_for_stream(&self) {
let (lock, condvar) = &self.shared.stream_opened;
let mut opened = lock.lock().expect("lock the stream flag");
while !*opened {
let (guard, timeout) = condvar
.wait_timeout(opened, Duration::from_secs(10))
.expect("wait for the stream");
opened = guard;
assert!(!timeout.timed_out(), "the client never opened the stream");
}
}
/// Blocks until at least `count` `POST` requests have been captured.
///
/// Tests synchronize on this rather than on a sleep so they stay
/// deterministic under load.
pub(crate) fn wait_for_posts(&self, count: usize) {
let (lock, condvar) = &self.shared.posts_seen;
let mut seen = lock.lock().expect("lock the post counter");
while *seen < count {
let (guard, timeout) = condvar
.wait_timeout(seen, Duration::from_secs(10))
.expect("wait for posts");
seen = guard;
assert!(
!timeout.timed_out(),
"expected {count} POSTs, saw {}",
*lock.lock().expect("lock the post counter")
);
}
}
/// Blocks until at least `count` `POST` response heads have been flushed.
pub(crate) fn wait_for_post_response_headers(&self, count: usize) {
let (lock, condvar) = &self.shared.post_headers_sent;
let mut seen = lock.lock().expect("lock the POST response-head counter");
while *seen < count {
let (guard, timeout) = condvar
.wait_timeout(seen, Duration::from_secs(10))
.expect("wait for POST response headers");
seen = guard;
assert!(
!timeout.timed_out(),
"expected {count} POST response heads, saw {}",
*lock.lock().expect("lock the POST response-head counter")
);
}
}
/// Releases every fixture connection deliberately stalled while replying.
pub(crate) fn release_stalled_posts(&self) {
let (lock, condvar) = &self.shared.stalled_posts_released;
*lock.lock().expect("lock the stalled-POST gate") = true;
condvar.notify_all();
}
/// Writes raw bytes to the SSE stream as one chunk.
pub(crate) fn send_raw(&self, payload: impl Into<String>) {
let _ = self.commands.send(StreamCommand::Write(payload.into()));
}
/// Writes an `endpoint` event naming the given POST target.
pub(crate) fn send_endpoint(&self, target: &str) {
self.send_raw(format!("event: endpoint\ndata: {target}\n\n"));
}
/// Writes a `message` event carrying the given JSON-RPC payload.
pub(crate) fn send_message(&self, payload: &serde_json::Value) {
self.send_raw(format!("event: message\ndata: {payload}\n\n"));
}
/// Ends the stream cleanly.
pub(crate) fn close_stream(&self) {
let _ = self.commands.send(StreamCommand::Close);
}
/// Drops the stream connection without a terminal chunk.
pub(crate) fn abort_stream(&self) {
let _ = self.commands.send(StreamCommand::Abort);
}
/// Every request the fixture has captured, in arrival order.
pub(crate) fn requests(&self) -> Vec<CapturedRequest> {
self.shared
.requests
.lock()
.expect("lock the request log")
.clone()
}
/// Only the `POST` requests captured so far.
pub(crate) fn posts(&self) -> Vec<CapturedRequest> {
self.requests()
.into_iter()
.filter(|request| request.method == "POST")
.collect()
}
}
/// Serves one accepted connection until the peer goes away.
fn serve_connection(
mut stream: TcpStream,
shared: Arc<Shared>,
commands: Arc<Mutex<mpsc::Receiver<StreamCommand>>>,
opening: StreamOpening,
) {
while let Some(request) = read_request(&mut stream) {
let is_stream_request = request.method == "GET";
if !is_stream_request {
let (lock, condvar) = &shared.posts_seen;
let mut seen = lock.lock().expect("lock the post counter");
*seen += 1;
condvar.notify_all();
}
shared
.requests
.lock()
.expect("lock the request log")
.push(request);
if is_stream_request {
serve_stream(&mut stream, &shared, &commands, &opening);
return;
}
let reply = {
let mut replies = shared.replies.lock().expect("lock the reply queue");
if replies.is_empty() {
PostReply::Accepted
} else {
replies.remove(0)
}
};
if !write_post_reply(&mut stream, reply, &shared) {
return;
}
}
}
/// Answers the SSE `GET` and then streams events on command.
fn serve_stream(
stream: &mut TcpStream,
shared: &Arc<Shared>,
commands: &Arc<Mutex<mpsc::Receiver<StreamCommand>>>,
opening: &StreamOpening,
) {
let head = match opening {
StreamOpening::Accept => "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream; charset=utf-8\r\ntransfer-encoding: chunked\r\n\r\n".to_string(),
StreamOpening::WrongContentType => {
"HTTP/1.1 200 OK\r\ncontent-type: application/x-remote-canary\r\ncontent-length: 2\r\n\r\n{}".to_string()
}
StreamOpening::Status { code, body } => format!(
"HTTP/1.1 {code} Status\r\ncontent-type: text/plain\r\ncontent-length: {}\r\n\r\n{body}",
body.len()
),
StreamOpening::Redirect { location } => format!(
"HTTP/1.1 307 Temporary Redirect\r\nlocation: {location}\r\ncontent-length: 0\r\n\r\n"
),
};
if stream.write_all(head.as_bytes()).is_err() {
return;
}
let _ = stream.flush();
if !matches!(opening, StreamOpening::Accept) {
return;
}
// Only signal readiness once the client can actually receive events.
let (lock, condvar) = &shared.stream_opened;
*lock.lock().expect("lock the stream flag") = true;
condvar.notify_all();
let commands = commands.lock().expect("lock the command channel");
while let Ok(command) = commands.recv() {
match command {
StreamCommand::Write(payload) => {
let framed = format!("{:X}\r\n{payload}\r\n", payload.len());
if stream.write_all(framed.as_bytes()).is_err() {
return;
}
// Flush per event, or the OS coalesces writes and the test
// would pass for a client that buffered the whole body.
let _ = stream.flush();
}
StreamCommand::Close => {
let _ = stream.write_all(b"0\r\n\r\n");
let _ = stream.flush();
return;
}
StreamCommand::Abort => return,
}
}
}
/// Writes one `POST` reply, reporting whether the connection may be reused.
fn write_post_reply(stream: &mut TcpStream, reply: PostReply, shared: &Shared) -> bool {
let stall_before_headers = matches!(&reply, PostReply::StallBeforeHeaders);
let stall_after_headers = matches!(&reply, PostReply::StallAfterHeaders);
if stall_before_headers {
wait_for_stalled_post_release(shared);
return false;
}
let response = match reply {
PostReply::Accepted => "HTTP/1.1 202 Accepted\r\ncontent-length: 0\r\n\r\n".to_string(),
PostReply::Ok => "HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n".to_string(),
PostReply::Status { code, body } => format!(
"HTTP/1.1 {code} Status\r\ncontent-type: text/plain\r\ncontent-length: {}\r\n\r\n{body}",
body.len()
),
PostReply::Redirect { location } => format!(
"HTTP/1.1 307 Temporary Redirect\r\nlocation: {location}\r\ncontent-length: 0\r\n\r\n"
),
PostReply::Drop => return false,
PostReply::StallAfterHeaders => "HTTP/1.1 200 OK\r\ncontent-length: 4\r\n\r\n".to_string(),
PostReply::StallBeforeHeaders => unreachable!("handled before building the response"),
};
if stream.write_all(response.as_bytes()).is_err() {
return false;
}
if stream.flush().is_err() {
return false;
}
let (lock, condvar) = &shared.post_headers_sent;
*lock.lock().expect("lock the POST response-head counter") += 1;
condvar.notify_all();
if stall_after_headers {
wait_for_stalled_post_release(shared);
return false;
}
true
}
/// Waits until the test explicitly releases a deliberately stalled reply.
fn wait_for_stalled_post_release(shared: &Shared) {
let (lock, condvar) = &shared.stalled_posts_released;
let mut released = lock.lock().expect("lock the stalled-POST gate");
while !*released {
released = condvar
.wait(released)
.expect("wait for the stalled POST to be released");
}
}
/// Reads one HTTP request, honoring keep-alive by returning `None` at EOF.
fn read_request(stream: &mut TcpStream) -> Option<CapturedRequest> {
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
let mut header_end = None;
let mut content_length = 0_usize;
loop {
let read = match stream.read(&mut chunk) {
Ok(0) | Err(_) => return None,
Ok(read) => read,
};
buffer.extend_from_slice(&chunk[..read]);
if header_end.is_none() {
let Some(index) = buffer.windows(4).position(|window| window == b"\r\n\r\n") else {
continue;
};
let end = index + 4;
header_end = Some(end);
content_length = String::from_utf8_lossy(&buffer[..end])
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().unwrap_or_default())
})
.unwrap_or_default();
}
if header_end.is_some_and(|end| buffer.len() >= end + content_length) {
break;
}
}
let end = header_end?;
let head = String::from_utf8_lossy(&buffer[..end]).to_string();
let body = String::from_utf8_lossy(&buffer[end..end + content_length]).to_string();
let mut lines = head.lines();
let mut request_line = lines.next()?.split_whitespace();
let method = request_line.next()?.to_string();
let target = request_line.next()?.to_string();
let headers = lines
.filter_map(|line| {
let (name, value) = line.split_once(':')?;
Some((name.trim().to_ascii_lowercase(), value.trim().to_string()))
})
.collect();
Some(CapturedRequest {
method,
target,
headers,
body,
})
}

207
vendor/mentra/src/mcp/sse/wire.rs vendored Normal file
View File

@@ -0,0 +1,207 @@
//! Incremental Server-Sent Events wire parser.
//!
//! This is a byte-oriented push parser: callers feed arbitrary byte chunks as
//! they arrive from the transport and receive whole dispatched events back. It
//! deliberately buffers bytes rather than strings so that a UTF-8 sequence or a
//! CRLF pair split across two network chunks is reassembled correctly.
//!
//! The framing rules follow the WHATWG `text/event-stream` interpretation:
//!
//! - lines end with `\n`, `\r\n`, or a lone `\r`;
//! - a line beginning with `:` is a comment (servers use these as heartbeats);
//! - `field: value` strips at most one space after the colon;
//! - a line with no colon is a field name with an empty value;
//! - repeated `data` fields are joined with `\n`;
//! - a blank line dispatches the buffered event, and an event with no `data`
//! field is discarded rather than dispatched;
//! - a leading UTF-8 byte order mark is ignored.
//!
//! Every buffered event is bounded by a caller-supplied limit so a hostile or
//! malfunctioning server cannot force unbounded memory growth. Mentra's tool
//! result limiter runs far too late to protect this parser.
#[cfg(test)]
mod tests;
/// Byte order mark that may prefix the very first line of a stream.
const UTF8_BOM: &str = "\u{feff}";
/// A dispatched Server-Sent Event.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SseEvent {
/// The `event:` field, defaulting to `message` when the server omits it.
pub(crate) event: String,
/// The joined `data:` field values, without the trailing newline.
pub(crate) data: String,
}
/// Errors produced while decoding the SSE byte stream.
///
/// Public because it is reachable through
/// [`McpSseError::Wire`](crate::mcp::McpSseError::Wire); the parser itself
/// stays internal.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum SseWireError {
#[error("SSE event exceeded the {limit} byte limit (buffered at least {observed} bytes)")]
EventTooLarge { limit: usize, observed: usize },
#[error("SSE stream contained invalid UTF-8")]
InvalidUtf8,
}
/// Incremental parser over the `text/event-stream` framing.
#[derive(Debug)]
pub(crate) struct SseParser {
/// Bytes of the line currently being accumulated.
line: Vec<u8>,
/// Joined `data:` values for the event currently being accumulated.
data: String,
/// The `event:` value for the event currently being accumulated.
event: Option<String>,
/// Whether the previous byte was a carriage return whose companion line
/// feed may still arrive in a later chunk.
pending_cr: bool,
/// Whether the next completed line is the first of the stream and may
/// therefore carry a byte order mark.
at_stream_start: bool,
/// Maximum bytes buffered for a single event.
max_event_bytes: usize,
}
impl SseParser {
/// Creates a parser that rejects any single event larger than
/// `max_event_bytes`.
pub(crate) fn new(max_event_bytes: usize) -> Self {
Self {
line: Vec::new(),
data: String::new(),
event: None,
pending_cr: false,
at_stream_start: true,
max_event_bytes,
}
}
/// Feeds the next chunk of stream bytes and returns every event completed
/// by it.
///
/// An error leaves the parser poisoned by contract: the caller must tear
/// the stream down rather than continue feeding it, because a size or
/// encoding violation means the framing can no longer be trusted.
pub(crate) fn feed(&mut self, bytes: &[u8]) -> Result<Vec<SseEvent>, SseWireError> {
let mut events = Vec::new();
for byte in bytes {
let byte = *byte;
// A carriage return already ended the previous line. If its
// companion line feed arrives now, it is part of that same
// terminator and must not end an additional (empty) line.
if self.pending_cr {
self.pending_cr = false;
if byte == b'\n' {
continue;
}
}
match byte {
b'\n' => self.end_line(&mut events)?,
b'\r' => {
self.pending_cr = true;
self.end_line(&mut events)?;
}
_ => {
self.line.push(byte);
self.check_bounds()?;
}
}
}
Ok(events)
}
/// Rejects an event whose buffered bytes exceed the configured limit.
fn check_bounds(&self) -> Result<(), SseWireError> {
let observed = self
.event
.as_ref()
.map_or(0, String::len)
.saturating_add(self.data.len())
.saturating_add(self.line.len());
if observed > self.max_event_bytes {
return Err(SseWireError::EventTooLarge {
limit: self.max_event_bytes,
observed,
});
}
Ok(())
}
/// Consumes the accumulated line, applying it to the pending event.
fn end_line(&mut self, events: &mut Vec<SseEvent>) -> Result<(), SseWireError> {
let line = std::mem::take(&mut self.line);
let line = std::str::from_utf8(&line).map_err(|_| SseWireError::InvalidUtf8)?;
// Only the very first line of the stream may carry a byte order mark.
let line = if std::mem::take(&mut self.at_stream_start) {
line.strip_prefix(UTF8_BOM).unwrap_or(line)
} else {
line
};
// A blank line dispatches whatever has been accumulated.
if line.is_empty() {
if let Some(event) = self.take_event() {
events.push(event);
}
return Ok(());
}
// A leading colon marks a comment, which servers use as a heartbeat.
if line.starts_with(':') {
return Ok(());
}
let (field, value) = match line.split_once(':') {
Some((field, value)) => (field, value.strip_prefix(' ').unwrap_or(value)),
// A line with no colon is a field name with an empty value.
None => (line, ""),
};
match field {
"event" => self.event = Some(value.to_string()),
"data" => {
self.data.push_str(value);
self.data.push('\n');
self.check_bounds()?;
}
// `id` and `retry` belong to reconnection, which this transport
// does not implement; every other field is undefined and ignored.
_ => {}
}
Ok(())
}
/// Takes the accumulated event, resetting the per-event state.
///
/// Returns `None` when no `data` field was seen, which the specification
/// requires be discarded rather than dispatched.
fn take_event(&mut self) -> Option<SseEvent> {
let event = self.event.take();
let mut data = std::mem::take(&mut self.data);
if data.is_empty() {
return None;
}
// The dispatch step drops the single trailing newline added by the
// last `data` field.
data.pop();
Some(SseEvent {
event: event.unwrap_or_else(|| "message".to_string()),
data,
})
}
}

334
vendor/mentra/src/mcp/sse/wire/tests.rs vendored Normal file
View File

@@ -0,0 +1,334 @@
//! Tests for the incremental Server-Sent Events wire parser.
use super::{SseEvent, SseParser, SseWireError};
/// Feed a whole payload as one chunk and collect every dispatched event.
fn parse_all(payload: &str) -> Vec<SseEvent> {
let mut parser = SseParser::new(64 * 1024);
parser
.feed(payload.as_bytes())
.expect("payload should parse")
}
#[test]
fn dispatches_a_simple_message_event() {
let events = parse_all("event: message\ndata: hello\n\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].event, "message");
assert_eq!(events[0].data, "hello");
}
#[test]
fn defaults_the_event_name_to_message_when_absent() {
let events = parse_all("data: hello\n\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].event, "message");
assert_eq!(events[0].data, "hello");
}
#[test]
fn parses_the_endpoint_event_name() {
let events = parse_all("event: endpoint\ndata: /messages/?session_id=abc\n\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].event, "endpoint");
assert_eq!(events[0].data, "/messages/?session_id=abc");
}
#[test]
fn accepts_crlf_line_terminators() {
let events = parse_all("event: message\r\ndata: hello\r\n\r\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].event, "message");
assert_eq!(events[0].data, "hello");
}
#[test]
fn accepts_lone_cr_line_terminators() {
let events = parse_all("event: message\rdata: hello\r\r");
assert_eq!(events.len(), 1);
assert_eq!(events[0].event, "message");
assert_eq!(events[0].data, "hello");
}
#[test]
fn joins_multiple_data_lines_with_newlines() {
let events = parse_all("event: message\ndata: first\ndata: second\ndata: third\n\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data, "first\nsecond\nthird");
}
#[test]
fn preserves_embedded_json_across_multiple_data_lines() {
let events = parse_all("event: message\ndata: {\"jsonrpc\":\"2.0\",\ndata: \"id\":1}\n\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data, "{\"jsonrpc\":\"2.0\",\n\"id\":1}");
}
#[test]
fn ignores_comment_and_heartbeat_lines() {
let events = parse_all(": ping\n: keep-alive\nevent: message\ndata: hello\n\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data, "hello");
}
#[test]
fn ignores_a_standalone_heartbeat_without_dispatching() {
let events = parse_all(": heartbeat\n\n");
assert!(events.is_empty());
}
#[test]
fn strips_only_one_leading_space_from_a_field_value() {
let events = parse_all("data: two-spaces\n\n");
assert_eq!(events[0].data, " two-spaces");
}
#[test]
fn accepts_a_data_field_with_no_space_after_the_colon() {
let events = parse_all("data:hello\n\n");
assert_eq!(events[0].data, "hello");
}
#[test]
fn treats_a_bare_field_name_as_an_empty_value() {
let events = parse_all("data\ndata: hello\n\n");
assert_eq!(events[0].data, "\nhello");
}
#[test]
fn ignores_unknown_fields() {
let events = parse_all("id: 42\nretry: 3000\nfoo: bar\ndata: hello\n\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data, "hello");
}
#[test]
fn does_not_dispatch_an_event_without_data() {
let events = parse_all("event: message\n\n");
assert!(events.is_empty());
}
#[test]
fn resets_the_event_name_between_dispatches() {
let events = parse_all("event: endpoint\ndata: /messages\n\ndata: hello\n\n");
assert_eq!(events.len(), 2);
assert_eq!(events[0].event, "endpoint");
assert_eq!(events[1].event, "message");
}
#[test]
fn dispatches_several_events_from_one_chunk() {
let events = parse_all("data: one\n\ndata: two\n\ndata: three\n\n");
assert_eq!(events.len(), 3);
assert_eq!(events[0].data, "one");
assert_eq!(events[1].data, "two");
assert_eq!(events[2].data, "three");
}
#[test]
fn strips_a_leading_utf8_byte_order_mark() {
let mut parser = SseParser::new(64 * 1024);
let mut payload = vec![0xEF, 0xBB, 0xBF];
payload.extend_from_slice(b"data: hello\n\n");
let events = parser.feed(&payload).expect("payload should parse");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data, "hello");
}
#[test]
fn reassembles_an_event_split_across_arbitrary_chunks() {
let payload = "event: message\ndata: {\"jsonrpc\":\"2.0\",\"id\":1}\n\n";
let bytes = payload.as_bytes();
// Split at every possible byte boundary; the parse must be identical.
for split in 1..bytes.len() {
let mut parser = SseParser::new(64 * 1024);
let mut events = parser
.feed(&bytes[..split])
.expect("first half should parse");
events.extend(
parser
.feed(&bytes[split..])
.expect("second half should parse"),
);
assert_eq!(
events.len(),
1,
"split at {split} should dispatch one event"
);
assert_eq!(events[0].event, "message");
assert_eq!(events[0].data, "{\"jsonrpc\":\"2.0\",\"id\":1}");
}
}
#[test]
fn reassembles_a_crlf_event_split_between_the_cr_and_the_lf() {
let payload = "data: hello\r\n\r\n";
let bytes = payload.as_bytes();
let split = payload.find('\r').expect("payload has a CR") + 1;
let mut parser = SseParser::new(64 * 1024);
let mut events = parser
.feed(&bytes[..split])
.expect("first half should parse");
assert!(events.is_empty(), "a dangling CR must not dispatch yet");
events.extend(
parser
.feed(&bytes[split..])
.expect("second half should parse"),
);
assert_eq!(events.len(), 1);
assert_eq!(events[0].data, "hello");
}
#[test]
fn reassembles_a_multibyte_character_split_across_chunks() {
let payload = "data: caf\u{e9}\n\n";
let bytes = payload.as_bytes();
// The 'é' is two bytes; split between them.
let split = bytes
.iter()
.position(|byte| *byte == 0xC3)
.expect("payload has a two-byte character")
+ 1;
let mut parser = SseParser::new(64 * 1024);
let mut events = parser
.feed(&bytes[..split])
.expect("first half should parse");
events.extend(
parser
.feed(&bytes[split..])
.expect("second half should parse"),
);
assert_eq!(events.len(), 1);
assert_eq!(events[0].data, "caf\u{e9}");
}
#[test]
fn feeding_one_byte_at_a_time_matches_a_single_chunk() {
let payload = "event: endpoint\r\ndata: /messages/?session_id=abc\r\n\r\ndata: tail\n\n";
let mut parser = SseParser::new(64 * 1024);
let mut events = Vec::new();
for byte in payload.as_bytes() {
events.extend(
parser
.feed(std::slice::from_ref(byte))
.expect("byte should parse"),
);
}
assert_eq!(events.len(), 2);
assert_eq!(events[0].event, "endpoint");
assert_eq!(events[0].data, "/messages/?session_id=abc");
assert_eq!(events[1].event, "message");
assert_eq!(events[1].data, "tail");
}
#[test]
fn rejects_an_event_larger_than_the_configured_limit() {
let mut parser = SseParser::new(64);
let oversized = format!("data: {}\n\n", "x".repeat(512));
let error = parser
.feed(oversized.as_bytes())
.expect_err("oversized event must be rejected");
assert!(matches!(
error,
SseWireError::EventTooLarge { limit: 64, .. }
));
}
#[test]
fn rejects_an_unterminated_line_larger_than_the_configured_limit() {
let mut parser = SseParser::new(64);
// No terminator at all: the parser must not buffer without bound.
let error = parser
.feed("x".repeat(512).as_bytes())
.expect_err("oversized line must be rejected");
assert!(matches!(
error,
SseWireError::EventTooLarge { limit: 64, .. }
));
}
#[test]
fn rejects_an_event_that_only_exceeds_the_limit_across_several_data_lines() {
let mut parser = SseParser::new(128);
let line = format!("data: {}\n", "x".repeat(60));
let mut error = None;
for _ in 0..10 {
if let Err(e) = parser.feed(line.as_bytes()) {
error = Some(e);
break;
}
}
assert!(
matches!(error, Some(SseWireError::EventTooLarge { limit: 128, .. })),
"accumulated data across lines must be bounded, got {error:?}"
);
}
#[test]
fn counts_the_stored_event_name_toward_the_event_limit() {
let mut parser = SseParser::new(64);
let event_name = "x".repeat(57);
parser
.feed(format!("event: {event_name}\n").as_bytes())
.expect("the event line exactly fills the limit");
let error = parser
.feed(b"data: xx")
.expect_err("stored event name and current data line must share the limit");
assert_eq!(
error,
SseWireError::EventTooLarge {
limit: 64,
observed: 65,
}
);
}
#[test]
fn accepts_an_event_whose_total_buffered_size_exactly_matches_the_limit() {
let mut parser = SseParser::new(64);
let event_name = "x".repeat(57);
let payload = format!("event: {event_name}\ndata: x\n\n");
let events = parser
.feed(payload.as_bytes())
.expect("an event at the exact byte limit should parse");
assert_eq!(
events,
vec![SseEvent {
event: event_name,
data: "x".to_string(),
}]
);
}
#[test]
fn accounts_size_per_event_rather_than_per_stream() {
let mut parser = SseParser::new(64);
// Each event is small; many of them in sequence must not trip the limit.
for _ in 0..50 {
let events = parser
.feed(b"data: small\n\n")
.expect("each small event should parse");
assert_eq!(events.len(), 1);
}
}
#[test]
fn rejects_invalid_utf8_in_the_stream() {
let mut parser = SseParser::new(64 * 1024);
let error = parser
.feed(&[b'd', b'a', b't', b'a', b':', b' ', 0xFF, 0xFE, b'\n', b'\n'])
.expect_err("invalid UTF-8 must be rejected");
assert!(matches!(error, SseWireError::InvalidUtf8));
}
#[test]
fn does_not_dispatch_a_trailing_event_without_a_blank_line() {
// A stream that ends mid-event must not yield a truncated event.
let events = parse_all("data: complete\n\ndata: incomplete\n");
assert_eq!(events.len(), 1);
assert_eq!(events[0].data, "complete");
}

298
vendor/mentra/src/mcp/tests.rs vendored Normal file
View File

@@ -0,0 +1,298 @@
use std::{collections::BTreeMap, sync::Arc};
use async_trait::async_trait;
use serde_json::{Value, json};
use crate::{
ContentBlock,
mcp::{
bridge::{McpBridgedTool, McpToolClient, mcp_tool_name, parse_mcp_tool_name},
protocol::*,
},
runtime::RuntimePolicy,
test::{MockRuntime, MockToolCall},
tool::{
ParallelToolContext, ToolDefinition, ToolExecutor, ToolResult, ToolSideEffectLevel,
ToolSpec,
},
};
pub(crate) struct SuccessfulMcpClient {
pub(crate) output: String,
}
#[async_trait]
impl McpToolClient for SuccessfulMcpClient {
async fn call_tool(
&self,
_tool_name: &str,
_arguments: Option<Value>,
) -> Result<McpToolCallResult, String> {
Ok(McpToolCallResult {
content: vec![McpToolCallContent {
kind: "text".to_string(),
text: Some(self.output.clone()),
data: None,
mime_type: None,
}],
is_error: false,
})
}
}
struct MatchingCustomTool {
output: String,
}
impl ToolDefinition for MatchingCustomTool {
fn descriptor(&self) -> ToolSpec {
ToolSpec::builder("matching_custom_output")
.description("Return the same output as the MCP test tool")
.input_schema(json!({ "type": "object", "properties": {} }))
.side_effect_level(ToolSideEffectLevel::External)
.build()
}
}
#[async_trait]
impl ToolExecutor for MatchingCustomTool {
async fn execute(&self, _ctx: ParallelToolContext, _input: Value) -> ToolResult {
Ok(self.output.clone())
}
}
#[test]
fn mcp_tool_name_namespacing() {
assert_eq!(mcp_tool_name("filesystem", "read"), "mcp__filesystem__read");
assert_eq!(
mcp_tool_name("my-server", "do_thing"),
"mcp__my-server__do_thing"
);
}
/// The bridge accepts either transport's client without a signature change at
/// the call site, which is what keeps `McpBridgedTool::new` source compatible
/// for existing stdio callers.
#[test]
fn bridging_compiles_for_both_transport_clients() {
fn accepts_stdio(client: Arc<crate::mcp::McpStdioClient>) -> McpBridgedTool {
McpBridgedTool::new(
"stdio-server".to_string(),
McpToolDefinition {
name: "read".to_string(),
description: None,
input_schema: None,
},
client,
)
}
fn accepts_sse(client: Arc<crate::mcp::McpSseClient>) -> McpBridgedTool {
McpBridgedTool::new(
"sse-server".to_string(),
McpToolDefinition {
name: "search".to_string(),
description: None,
input_schema: None,
},
client,
)
}
// Building a real client of either transport needs a live server, so this
// asserts the signatures rather than the behavior.
let _ = accepts_stdio;
let _ = accepts_sse;
}
#[test]
fn parse_mcp_tool_name_roundtrip() {
let name = mcp_tool_name("filesystem", "read");
let (server, tool) = parse_mcp_tool_name(&name).expect("should parse");
assert_eq!(server, "filesystem");
assert_eq!(tool, "read");
}
#[test]
fn parse_mcp_tool_name_rejects_non_mcp() {
assert!(parse_mcp_tool_name("regular_tool").is_none());
assert!(parse_mcp_tool_name("mcp_no_double_underscore").is_none());
}
#[tokio::test]
async fn bridged_output_is_truncated_before_the_next_provider_request() {
let full_output = "one\ntwo\nthree";
let bridged_name = mcp_tool_name("fake", "large_output");
let mock = MockRuntime::builder()
.with_policy(
RuntimePolicy::permissive()
.with_max_tool_result_bytes(8)
.with_max_tool_result_lines(1)
.spill_full_tool_output(false),
)
.tool_calls([
MockToolCall::new(&bridged_name, json!({})).with_id("mcp-call"),
MockToolCall::new("matching_custom_output", json!({})).with_id("custom-call"),
])
.text("done")
.build()
.expect("build mock runtime");
mock.runtime().register_tool(McpBridgedTool::new_for_test(
"fake".to_string(),
McpToolDefinition {
name: "large_output".to_string(),
description: Some("Return an oversized result".to_string()),
input_schema: Some(json!({ "type": "object", "properties": {} })),
},
Arc::new(SuccessfulMcpClient {
output: full_output.to_string(),
}),
));
mock.runtime().register_tool(MatchingCustomTool {
output: full_output.to_string(),
});
let mut agent = mock
.runtime()
.spawn("mcp-truncation-test", mock.model())
.expect("spawn agent");
let response = agent
.send(vec![ContentBlock::text("run both tools")])
.await
.expect("run agent");
assert_eq!(response.text(), "done");
let requests = mock.recorded_requests().await;
assert_eq!(requests.len(), 2);
let provider_results = requests[1]
.messages
.iter()
.flat_map(|message| &message.content)
.filter_map(|block| match block {
ContentBlock::ToolResult {
tool_use_id,
content,
is_error,
} => Some((tool_use_id.as_str(), (content.as_str(), *is_error))),
_ => None,
})
.collect::<BTreeMap<_, _>>();
let mcp_result = provider_results
.get("mcp-call")
.expect("provider request should contain the MCP result");
let custom_result = provider_results
.get("custom-call")
.expect("provider request should contain the custom-tool result");
assert_eq!(mcp_result, custom_result);
assert_eq!(
*mcp_result,
(
"one\n[truncated: showing 1 of 3 lines; full output was not saved because spill-to-file is disabled by runtime policy]",
false,
)
);
}
#[test]
fn json_rpc_request_serialization() {
let req = JsonRpcRequest::new(1, "initialize", Some(json!({"key": "value"})));
let serialized = serde_json::to_string(&req).expect("serialize");
assert!(serialized.contains("\"jsonrpc\":\"2.0\""));
assert!(serialized.contains("\"id\":1"));
assert!(serialized.contains("\"method\":\"initialize\""));
}
#[test]
fn json_rpc_response_deserialization() {
let json = r#"{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}"#;
let resp: JsonRpcResponse = serde_json::from_str(json).expect("deserialize");
assert_eq!(resp.id, JsonRpcId::Number(1));
assert!(resp.result.is_some());
assert!(resp.error.is_none());
}
#[test]
fn json_rpc_error_response_deserialization() {
let json = r#"{"jsonrpc":"2.0","id":2,"error":{"code":-32600,"message":"Invalid Request"}}"#;
let resp: JsonRpcResponse = serde_json::from_str(json).expect("deserialize");
assert_eq!(resp.id, JsonRpcId::Number(2));
let err = resp.error.expect("should have error");
assert_eq!(err.code, -32600);
assert_eq!(err.message, "Invalid Request");
}
#[test]
fn mcp_tool_definition_deserialization() {
let json = json!({
"name": "read_file",
"description": "Read a file from disk",
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string"}
},
"required": ["path"]
}
});
let tool: McpToolDefinition = serde_json::from_value(json).expect("deserialize");
assert_eq!(tool.name, "read_file");
assert_eq!(tool.description.as_deref(), Some("Read a file from disk"));
assert!(tool.input_schema.is_some());
}
#[test]
fn mcp_tool_call_result_deserialization() {
let json = json!({
"content": [
{"type": "text", "text": "Hello, world!"},
{"type": "text", "text": "Second block"}
],
"isError": false
});
let result: McpToolCallResult = serde_json::from_value(json).expect("deserialize");
assert_eq!(result.content.len(), 2);
assert!(!result.is_error);
assert_eq!(result.content[0].text.as_deref(), Some("Hello, world!"));
}
#[test]
fn mcp_tool_call_error_result() {
let json = json!({
"content": [{"type": "text", "text": "Something went wrong"}],
"isError": true
});
let result: McpToolCallResult = serde_json::from_value(json).expect("deserialize");
assert!(result.is_error);
}
#[test]
fn mcp_server_config_deserialization() {
let json = json!({
"name": "filesystem",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"],
"env": {"DEBUG": "1"},
"cwd": "/home/user"
});
let config: McpServerConfig = serde_json::from_value(json).expect("deserialize");
assert_eq!(config.name, "filesystem");
assert_eq!(config.command, "npx");
assert_eq!(config.args.len(), 3);
assert_eq!(config.env.get("DEBUG").map(String::as_str), Some("1"));
assert_eq!(config.cwd.as_deref(), Some("/home/user"));
}
#[test]
fn mcp_initialize_params_serialization() {
let params = McpInitializeParams {
protocol_version: "2024-11-05".to_string(),
capabilities: json!({}),
client_info: McpClientInfo {
name: "mentra".to_string(),
version: "0.6.0".to_string(),
},
};
let json = serde_json::to_value(&params).expect("serialize");
assert_eq!(json["protocolVersion"], "2024-11-05");
assert_eq!(json["clientInfo"]["name"], "mentra");
}

16
vendor/mentra/src/memory.rs vendored Normal file
View File

@@ -0,0 +1,16 @@
mod compaction;
mod engine;
mod hybrid_store;
pub(crate) mod journal;
pub(crate) use compaction::{
estimated_request_tokens, micro_compact_history, required_tail_start_for_continuation,
};
pub use engine::{
IngestOutcome, IngestRequest, MAX_MEMORY_LIST_PAGE_SIZE, MemoryCursor, MemoryEngine, MemoryHit,
MemoryListCursor, MemoryListFilter, MemoryListPage, MemoryListRequest, MemoryListSort,
MemoryRecord, MemoryRecordKind, MemorySearchMode, MemorySearchRequest, MemoryStore,
SearchRequest,
};
pub(crate) use engine::{build_search_query, recalled_memory_message};
pub use hybrid_store::SqliteHybridMemoryStore;

107
vendor/mentra/src/memory/compaction.rs vendored Normal file
View File

@@ -0,0 +1,107 @@
use std::collections::HashMap;
use crate::{ContentBlock, Message, Role};
const MICRO_COMPACT_MIN_CONTENT_LEN: usize = 100;
pub(crate) fn micro_compact_history(history: &[Message], keep_recent: usize) -> Vec<Message> {
if keep_recent == usize::MAX {
return history.to_vec();
}
let mut compacted = history.to_vec();
let tool_names = tool_name_index(&compacted);
let mut tool_results = Vec::new();
for (message_index, message) in compacted.iter().enumerate() {
if message.role != Role::User {
continue;
}
for (block_index, block) in message.content.iter().enumerate() {
if matches!(block, ContentBlock::ToolResult { .. }) {
tool_results.push((message_index, block_index));
}
}
}
if tool_results.len() <= keep_recent {
return compacted;
}
let compact_count = tool_results.len() - keep_recent;
for (message_index, block_index) in tool_results.into_iter().take(compact_count) {
let Some(ContentBlock::ToolResult {
tool_use_id,
content,
..
}) = compacted[message_index].content.get_mut(block_index)
else {
continue;
};
if content.len() <= MICRO_COMPACT_MIN_CONTENT_LEN {
continue;
}
let tool_name = tool_names
.get(tool_use_id.as_str())
.map(String::as_str)
.unwrap_or("tool");
content.clear();
content.push_str(&format!("[Previous: used {tool_name}]"));
}
compacted
}
pub(crate) fn estimated_request_tokens(messages: &[Message], system: Option<&str>) -> usize {
let mut estimated =
estimated_tokens_for_str(&serde_json::to_string(messages).unwrap_or_default());
if let Some(system) = system {
estimated += estimated_tokens_for_str(system);
}
estimated
}
pub(crate) fn required_tail_start_for_continuation(history: &[Message]) -> usize {
let Some(last_index) = history.len().checked_sub(1) else {
return 0;
};
let last_message = &history[last_index];
if last_message.role == Role::User
&& last_message
.content
.iter()
.any(|block| matches!(block, ContentBlock::ToolResult { .. }))
&& last_index > 0
&& history[last_index - 1].role == Role::Assistant
&& history[last_index - 1]
.content
.iter()
.any(|block| matches!(block, ContentBlock::ToolUse { .. }))
{
last_index - 1
} else {
last_index
}
}
fn tool_name_index(history: &[Message]) -> HashMap<String, String> {
let mut tool_names = HashMap::new();
for message in history {
for block in &message.content {
if let ContentBlock::ToolUse { id, name, .. } = block {
tool_names.insert(id.clone(), name.clone());
}
}
}
tool_names
}
fn estimated_tokens_for_str(text: &str) -> usize {
text.chars().count().div_ceil(4)
}

667
vendor/mentra/src/memory/engine.rs vendored Normal file
View File

@@ -0,0 +1,667 @@
use std::{
collections::HashSet,
sync::Arc,
time::{SystemTime, UNIX_EPOCH},
};
use serde::{Deserialize, Serialize};
use crate::{
Message,
provider::ContentBlock,
runtime::{RuntimeError, RuntimeHookEvent, RuntimeHooks, RuntimeStore, TaskItem},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum MemoryRecordKind {
Episode,
Summary,
Fact,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MemoryRecord {
pub record_id: String,
pub agent_id: String,
pub kind: MemoryRecordKind,
pub content: String,
pub source_revision: u64,
pub created_at: i64,
pub metadata_json: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub pinned: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score: Option<f64>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemoryCursor {
pub last_ingested_revision: u64,
}
#[derive(Debug, Clone)]
pub struct SearchRequest {
pub agent_id: String,
pub query: String,
pub limit: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MemorySearchMode {
#[default]
Automatic,
Tool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemorySearchRequest {
pub agent_id: String,
pub query: String,
pub limit: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub char_budget: Option<usize>,
#[serde(default)]
pub mode: MemorySearchMode,
#[serde(default)]
pub filter: MemoryListFilter,
}
impl From<SearchRequest> for MemorySearchRequest {
fn from(value: SearchRequest) -> Self {
Self {
agent_id: value.agent_id,
query: value.query,
limit: value.limit,
char_budget: None,
mode: MemorySearchMode::Automatic,
filter: MemoryListFilter::default(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct MemoryHit {
pub record_id: String,
pub kind: MemoryRecordKind,
pub content: String,
pub source_revision: u64,
pub created_at: i64,
pub metadata_json: String,
pub source: Option<String>,
pub why_retrieved: Option<String>,
pub score: Option<f64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum MemoryListSort {
#[default]
Newest,
Oldest,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemoryListFilter {
pub kind: Option<MemoryRecordKind>,
pub pinned: Option<bool>,
pub source: Option<String>,
pub created_from: Option<i64>,
pub created_to: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemoryListCursor {
pub created_at: i64,
pub record_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemoryListRequest {
pub agent_id: String,
pub cursor: Option<MemoryListCursor>,
pub limit: usize,
pub filter: MemoryListFilter,
pub sort: MemoryListSort,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MemoryListPage {
pub records: Vec<MemoryRecord>,
pub next_cursor: Option<MemoryListCursor>,
}
pub const MAX_MEMORY_LIST_PAGE_SIZE: usize = 100;
#[derive(Debug, Clone)]
pub struct IngestRequest {
pub agent_id: String,
pub source_revision: u64,
pub messages: Vec<Message>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IngestOutcome {
pub stored_records: usize,
pub skipped: bool,
}
pub trait MemoryStore: Send + Sync {
fn upsert_records(&self, records: &[MemoryRecord]) -> Result<(), RuntimeError>;
fn search_records_with_options(
&self,
request: &MemorySearchRequest,
) -> Result<Vec<MemoryRecord>, RuntimeError>;
fn search_records(
&self,
agent_id: &str,
query: &str,
limit: usize,
) -> Result<Vec<MemoryRecord>, RuntimeError> {
self.search_records_with_options(&MemorySearchRequest {
agent_id: agent_id.to_string(),
query: query.to_string(),
limit,
char_budget: None,
mode: MemorySearchMode::Automatic,
filter: MemoryListFilter::default(),
})
}
fn list_records(&self, request: &MemoryListRequest) -> Result<MemoryListPage, RuntimeError>;
fn get_record(
&self,
agent_id: &str,
record_id: &str,
) -> Result<Option<MemoryRecord>, RuntimeError>;
fn count_records(&self, agent_id: &str) -> Result<usize, RuntimeError>;
fn delete_records(&self, record_ids: &[String]) -> Result<(), RuntimeError>;
fn tombstone_records(
&self,
agent_id: &str,
record_ids: &[String],
) -> Result<usize, RuntimeError>;
fn load_agent_memory_cursor(
&self,
agent_id: &str,
) -> Result<Option<MemoryCursor>, RuntimeError>;
fn save_agent_memory_cursor(
&self,
agent_id: &str,
cursor: &MemoryCursor,
) -> Result<(), RuntimeError>;
}
#[derive(Clone)]
pub struct MemoryEngine {
store: Arc<dyn RuntimeStore>,
hooks: RuntimeHooks,
}
impl MemoryEngine {
pub fn new(store: Arc<dyn RuntimeStore>, hooks: RuntimeHooks) -> Self {
Self { store, hooks }
}
pub async fn search(
&self,
request: impl Into<MemorySearchRequest>,
) -> Result<Vec<MemoryHit>, RuntimeError> {
let request = request.into();
let _ = self.hooks.emit_runtime(
self.store.as_ref(),
&RuntimeHookEvent::MemorySearchStarted {
agent_id: request.agent_id.clone(),
limit: request.limit,
query_preview: preview_text(&request.query, 120),
},
);
let records = match self.store.search_records_with_options(&request) {
Ok(records) => records,
Err(error) => {
let _ = self.hooks.emit_runtime(
self.store.as_ref(),
&RuntimeHookEvent::MemorySearchFinished {
agent_id: request.agent_id,
success: false,
result_count: 0,
error: Some(error.to_string()),
},
);
return Err(error);
}
};
let mut hits = records
.into_iter()
.map(|record| {
let why_retrieved = build_why_retrieved(&request.query, &record);
MemoryHit {
record_id: record.record_id,
kind: record.kind,
content: record.content,
source_revision: record.source_revision,
created_at: record.created_at,
metadata_json: record.metadata_json,
source: record.source,
why_retrieved,
score: record.score,
}
})
.collect::<Vec<_>>();
if let Some(char_budget) = request.char_budget {
trim_hits_to_char_budget(&mut hits, char_budget);
}
let _ = self.hooks.emit_runtime(
self.store.as_ref(),
&RuntimeHookEvent::MemorySearchFinished {
agent_id: request.agent_id,
success: true,
result_count: hits.len(),
error: None,
},
);
Ok(hits)
}
pub fn schedule_ingest(&self, request: IngestRequest) {
let engine = self.clone();
tokio::spawn(async move {
let _ = engine.ingest(request).await;
});
}
pub async fn ingest(&self, request: IngestRequest) -> Result<IngestOutcome, RuntimeError> {
let _ = self.hooks.emit_runtime(
self.store.as_ref(),
&RuntimeHookEvent::MemoryIngestStarted {
agent_id: request.agent_id.clone(),
source_revision: request.source_revision,
},
);
let cursor = match self.store.load_agent_memory_cursor(&request.agent_id) {
Ok(cursor) => cursor.unwrap_or_default(),
Err(error) => {
let _ = self.hooks.emit_runtime(
self.store.as_ref(),
&RuntimeHookEvent::MemoryIngestFinished {
agent_id: request.agent_id,
source_revision: request.source_revision,
success: false,
stored_records: 0,
error: Some(error.to_string()),
},
);
return Err(error);
}
};
if cursor.last_ingested_revision >= request.source_revision {
let _ = self.hooks.emit_runtime(
self.store.as_ref(),
&RuntimeHookEvent::MemoryIngestFinished {
agent_id: request.agent_id,
source_revision: request.source_revision,
success: true,
stored_records: 0,
error: None,
},
);
return Ok(IngestOutcome {
stored_records: 0,
skipped: true,
});
}
let episode = summarize_episode(&request.messages);
if episode.is_empty() {
if let Err(error) = self.store.save_agent_memory_cursor(
&request.agent_id,
&MemoryCursor {
last_ingested_revision: request.source_revision,
},
) {
let _ = self.hooks.emit_runtime(
self.store.as_ref(),
&RuntimeHookEvent::MemoryIngestFinished {
agent_id: request.agent_id,
source_revision: request.source_revision,
success: false,
stored_records: 0,
error: Some(error.to_string()),
},
);
return Err(error);
}
let _ = self.hooks.emit_runtime(
self.store.as_ref(),
&RuntimeHookEvent::MemoryIngestFinished {
agent_id: request.agent_id,
source_revision: request.source_revision,
success: true,
stored_records: 0,
error: None,
},
);
return Ok(IngestOutcome {
stored_records: 0,
skipped: false,
});
}
let record = MemoryRecord {
record_id: format!("episode:{}:{}", request.agent_id, request.source_revision),
agent_id: request.agent_id.clone(),
kind: MemoryRecordKind::Episode,
content: episode,
source_revision: request.source_revision,
created_at: now_secs(),
metadata_json: "{}".to_string(),
source: Some("auto_ingest".to_string()),
pinned: false,
score: None,
};
if let Err(error) = self.store.upsert_records(&[record]) {
let _ = self.hooks.emit_runtime(
self.store.as_ref(),
&RuntimeHookEvent::MemoryIngestFinished {
agent_id: request.agent_id,
source_revision: request.source_revision,
success: false,
stored_records: 0,
error: Some(error.to_string()),
},
);
return Err(error);
}
if let Err(error) = self.store.save_agent_memory_cursor(
&request.agent_id,
&MemoryCursor {
last_ingested_revision: request.source_revision,
},
) {
let _ = self.hooks.emit_runtime(
self.store.as_ref(),
&RuntimeHookEvent::MemoryIngestFinished {
agent_id: request.agent_id,
source_revision: request.source_revision,
success: false,
stored_records: 0,
error: Some(error.to_string()),
},
);
return Err(error);
}
let _ = self.hooks.emit_runtime(
self.store.as_ref(),
&RuntimeHookEvent::MemoryIngestFinished {
agent_id: request.agent_id,
source_revision: request.source_revision,
success: true,
stored_records: 1,
error: None,
},
);
Ok(IngestOutcome {
stored_records: 1,
skipped: false,
})
}
pub fn store_compaction_summary(
&self,
agent_id: &str,
source_revision: u64,
summary: &str,
) -> Result<(), RuntimeError> {
let record = MemoryRecord {
record_id: format!("summary:{agent_id}:{source_revision}"),
agent_id: agent_id.to_string(),
kind: MemoryRecordKind::Summary,
content: summary.to_string(),
source_revision,
created_at: now_secs(),
metadata_json: "{}".to_string(),
source: Some("auto_compaction".to_string()),
pinned: false,
score: None,
};
self.store.upsert_records(&[record])
}
pub fn pin(
&self,
agent_id: &str,
source_revision: u64,
content: &str,
) -> Result<MemoryRecord, RuntimeError> {
let record = MemoryRecord {
record_id: format!("fact:{agent_id}:manual:{}", now_nanos()),
agent_id: agent_id.to_string(),
kind: MemoryRecordKind::Fact,
content: content.trim().to_string(),
source_revision,
created_at: now_secs(),
metadata_json: r#"{"origin":"manual_pin"}"#.to_string(),
source: Some("manual_pin".to_string()),
pinned: true,
score: None,
};
self.store.upsert_records(std::slice::from_ref(&record))?;
Ok(record)
}
pub fn forget(&self, agent_id: &str, record_id: &str) -> Result<bool, RuntimeError> {
self.store
.tombstone_records(agent_id, &[record_id.to_string()])
.map(|count| count > 0)
}
}
pub(crate) fn build_search_query(history: &[Message], tasks: &[TaskItem]) -> String {
let mut parts = history.iter().rev().take(6).collect::<Vec<_>>();
parts.reverse();
let mut query = parts
.into_iter()
.flat_map(message_to_lines)
.collect::<Vec<_>>()
.join("\n");
let unfinished = tasks
.iter()
.filter(|task| !matches!(task.status, crate::runtime::TaskStatus::Completed))
.map(|task| {
let description = task.description.trim();
if description.is_empty() {
task.subject.clone()
} else {
format!("{}: {}", task.subject, description)
}
})
.collect::<Vec<_>>();
if !unfinished.is_empty() {
if !query.is_empty() {
query.push('\n');
}
query.push_str("Tasks:\n");
query.push_str(&unfinished.join("\n"));
}
query
}
pub(crate) fn recalled_memory_message(hits: &[MemoryHit], char_limit: usize) -> Option<Message> {
let mut seen = HashSet::new();
let mut entries = Vec::new();
let mut used = 0usize;
for hit in hits {
if !seen.insert(hit.record_id.clone()) {
continue;
}
let line = format!(
"[{} rev={}{}{}] {}",
kind_label(hit.kind),
hit.source_revision,
hit.source
.as_deref()
.map(|source| format!(" source={source}"))
.unwrap_or_default(),
hit.why_retrieved
.as_deref()
.map(|why| format!(" why={why}"))
.unwrap_or_default(),
hit.content.trim()
);
if line.trim().is_empty() {
continue;
}
if !entries.is_empty() && used + line.len() + 1 > char_limit {
break;
}
used += if entries.is_empty() {
line.len()
} else {
line.len() + 1
};
entries.push(line);
}
if entries.is_empty() {
return None;
}
Some(Message::user(ContentBlock::text(format!(
"<recalled-memory>\n{}\n</recalled-memory>",
entries.join("\n")
))))
}
fn summarize_episode(messages: &[Message]) -> String {
let mut lines = Vec::new();
for message in messages {
let label = match message.role {
crate::Role::User => "user",
crate::Role::Assistant => "assistant",
crate::Role::Unknown(_) => "unknown",
};
for line in message_to_lines(message) {
lines.push(format!("{label}: {line}"));
}
}
lines.join("\n")
}
fn message_to_lines(message: &Message) -> Vec<String> {
message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text } => Some(text.trim().to_string()),
ContentBlock::ToolUse { name, input, .. } => Some(format!("tool use {name} {input}")),
ContentBlock::ToolResult { content, .. } => Some(format!("tool result {content}")),
ContentBlock::Image { .. }
| ContentBlock::Thinking { .. }
| ContentBlock::HostedToolSearch { .. }
| ContentBlock::HostedWebSearch { .. }
| ContentBlock::ImageGeneration { .. } => None,
})
.filter(|text| !text.is_empty())
.collect()
}
fn kind_label(kind: MemoryRecordKind) -> &'static str {
match kind {
MemoryRecordKind::Episode => "episode",
MemoryRecordKind::Summary => "summary",
MemoryRecordKind::Fact => "fact",
}
}
fn preview_text(text: &str, limit: usize) -> String {
truncate_to_char_boundary(text.trim(), limit).to_string()
}
fn truncate_to_char_boundary(input: &str, max_chars: usize) -> &str {
if input.chars().count() <= max_chars {
return input;
}
let mut end = input.len();
for (index, _) in input.char_indices().take(max_chars + 1) {
end = index;
}
&input[..end]
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
fn now_nanos() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos()
}
fn trim_hits_to_char_budget(hits: &mut Vec<MemoryHit>, char_budget: usize) {
if char_budget == 0 {
hits.clear();
return;
}
let mut kept = Vec::with_capacity(hits.len());
let mut used = 0usize;
for hit in hits.drain(..) {
let line_len = hit.content.len()
+ hit.source.as_deref().map_or(0, str::len)
+ hit.why_retrieved.as_deref().map_or(0, str::len);
if !kept.is_empty() && used + line_len > char_budget {
break;
}
used += line_len;
kept.push(hit);
}
*hits = kept;
}
fn build_why_retrieved(query: &str, record: &MemoryRecord) -> Option<String> {
let mut reasons = Vec::new();
let matched = query
.split(|ch: char| !ch.is_alphanumeric())
.filter(|token| !token.is_empty())
.filter(|token| {
record
.content
.to_lowercase()
.contains(&token.to_lowercase())
})
.take(2)
.map(ToString::to_string)
.collect::<Vec<_>>();
if !matched.is_empty() {
reasons.push(format!("matched {}", matched.join(",")));
}
match record.kind {
MemoryRecordKind::Fact => reasons.push("fact".to_string()),
MemoryRecordKind::Summary => reasons.push("summary".to_string()),
MemoryRecordKind::Episode => {}
}
if record.pinned || record.source.as_deref() == Some("manual_pin") {
reasons.push("manual".to_string());
}
if reasons.is_empty() {
None
} else {
Some(reasons.join("; "))
}
}
fn is_false(value: &bool) -> bool {
!*value
}

861
vendor/mentra/src/memory/hybrid_store.rs vendored Normal file
View File

@@ -0,0 +1,861 @@
use std::{
path::{Path, PathBuf},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
use crate::{
memory::{
MemoryCursor, MemoryListCursor, MemoryListPage, MemoryListRequest, MemoryListSort,
MemoryRecord, MemoryRecordKind, MemorySearchRequest, MemoryStore,
},
runtime::RuntimeError,
};
#[derive(Clone)]
/// SQLite-backed hybrid memory store with provenance, pinning, and tombstoning support.
pub struct SqliteHybridMemoryStore {
path: PathBuf,
}
impl SqliteHybridMemoryStore {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
self.path.as_path()
}
fn open(&self) -> Result<Connection, RuntimeError> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| RuntimeError::Store(error.to_string()))?;
}
let conn = Connection::open(&self.path).map_err(sqlite_error)?;
conn.busy_timeout(Duration::from_secs(5))
.map_err(sqlite_error)?;
conn.pragma_update(None, "journal_mode", "WAL")
.map_err(sqlite_error)?;
self.ensure_schema(&conn)?;
Ok(conn)
}
fn ensure_schema(&self, conn: &Connection) -> Result<(), RuntimeError> {
conn.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS memory_records (
record_id TEXT PRIMARY KEY,
agent_id TEXT NOT NULL,
kind TEXT NOT NULL,
content TEXT NOT NULL,
source_revision INTEGER NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
metadata_json TEXT NOT NULL,
source_json TEXT,
pinned INTEGER NOT NULL DEFAULT 0,
tombstoned_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_memory_records_agent_created
ON memory_records (agent_id, created_at DESC);
CREATE VIRTUAL TABLE IF NOT EXISTS memory_records_fts USING fts5(
record_id UNINDEXED,
agent_id UNINDEXED,
content
);
CREATE TABLE IF NOT EXISTS memory_cursor (
agent_id TEXT PRIMARY KEY,
cursor_json TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
"#,
)
.map_err(sqlite_error)
}
fn search_records_raw(
&self,
request: &MemorySearchRequest,
) -> Result<Vec<MemoryRecord>, RuntimeError> {
if request.query.trim().is_empty() || request.limit == 0 {
return Ok(Vec::new());
}
let Some(query) = fts_query(&request.query) else {
return Ok(Vec::new());
};
let conn = self.open()?;
let kind = request.filter.kind.map(kind_name);
let source = encode_source(request.filter.source.as_deref())?;
let mut stmt = conn
.prepare(
r#"
SELECT
record.record_id,
record.agent_id,
record.kind,
record.content,
record.source_revision,
record.created_at,
record.metadata_json,
record.source_json,
record.pinned,
bm25(memory_records_fts) AS rank
FROM memory_records_fts
JOIN memory_records AS record ON record.record_id = memory_records_fts.record_id
WHERE memory_records_fts.agent_id = ?1
AND memory_records_fts.content MATCH ?2
AND record.tombstoned_at IS NULL
AND (?3 IS NULL OR record.kind = ?3)
AND (?4 IS NULL OR record.pinned = ?4)
AND (?5 IS NULL OR record.source_json = ?5)
AND (?6 IS NULL OR record.created_at >= ?6)
AND (?7 IS NULL OR record.created_at <= ?7)
LIMIT ?8
"#,
)
.map_err(sqlite_error)?;
let candidate_limit = request.limit.saturating_mul(5).clamp(10, 500) as i64;
let mut records = stmt
.query_map(
params![
request.agent_id,
query,
kind,
request.filter.pinned.map(i64::from),
source,
request.filter.created_from,
request.filter.created_to,
candidate_limit,
],
|row| {
let kind = row.get::<_, String>(2)?;
let source_json = row.get::<_, Option<String>>(7)?;
let pinned = row.get::<_, i64>(8)? != 0;
let raw_rank = row.get::<_, Option<f64>>(9)?.unwrap_or(0.0);
let created_at = row.get::<_, i64>(5)?;
let score = rank_score(parse_memory_kind(&kind), pinned, created_at, raw_rank);
Ok(MemoryRecord {
record_id: row.get(0)?,
agent_id: row.get(1)?,
kind: parse_memory_kind(&kind),
content: row.get(3)?,
source_revision: row.get::<_, i64>(4)? as u64,
created_at,
metadata_json: row.get(6)?,
source: decode_source(source_json),
pinned,
score: Some(score),
})
},
)
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(sqlite_error)?;
records.sort_by(|left, right| {
right
.score
.partial_cmp(&left.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| right.created_at.cmp(&left.created_at))
});
records.truncate(request.limit);
Ok(records)
}
}
impl MemoryStore for SqliteHybridMemoryStore {
fn upsert_records(&self, records: &[MemoryRecord]) -> Result<(), RuntimeError> {
if records.is_empty() {
return Ok(());
}
let mut conn = self.open()?;
let tx = conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(sqlite_error)?;
let now = now_secs();
for record in records {
tx.execute(
r#"
INSERT INTO memory_records (
record_id, agent_id, kind, content, source_revision, created_at, updated_at,
metadata_json, source_json, pinned, tombstoned_at
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, NULL)
ON CONFLICT(record_id) DO UPDATE SET
agent_id = excluded.agent_id,
kind = excluded.kind,
content = excluded.content,
source_revision = excluded.source_revision,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
metadata_json = excluded.metadata_json,
source_json = excluded.source_json,
pinned = excluded.pinned,
tombstoned_at = NULL
"#,
params![
record.record_id,
record.agent_id,
kind_name(record.kind),
record.content,
record.source_revision as i64,
record.created_at,
now,
record.metadata_json,
encode_source(record.source.as_deref())?,
if record.pinned { 1 } else { 0 },
],
)
.map_err(sqlite_error)?;
tx.execute(
"DELETE FROM memory_records_fts WHERE record_id = ?1",
params![record.record_id],
)
.map_err(sqlite_error)?;
tx.execute(
"INSERT INTO memory_records_fts (record_id, agent_id, content) VALUES (?1, ?2, ?3)",
params![record.record_id, record.agent_id, record.content],
)
.map_err(sqlite_error)?;
}
tx.commit().map_err(sqlite_error)
}
fn search_records_with_options(
&self,
request: &MemorySearchRequest,
) -> Result<Vec<MemoryRecord>, RuntimeError> {
self.search_records_raw(request)
}
fn list_records(&self, request: &MemoryListRequest) -> Result<MemoryListPage, RuntimeError> {
let limit = request.limit.min(crate::memory::MAX_MEMORY_LIST_PAGE_SIZE);
if limit == 0 {
return Ok(MemoryListPage {
records: Vec::new(),
next_cursor: None,
});
}
let conn = self.open()?;
let kind = request.filter.kind.map(kind_name);
let source = encode_source(request.filter.source.as_deref())?;
let cursor_time = request.cursor.as_ref().map(|cursor| cursor.created_at);
let cursor_id = request
.cursor
.as_ref()
.map(|cursor| cursor.record_id.as_str());
let direction = match request.sort {
MemoryListSort::Newest => "<",
MemoryListSort::Oldest => ">",
};
let order = match request.sort {
MemoryListSort::Newest => "DESC",
MemoryListSort::Oldest => "ASC",
};
let sql = format!(
r#"
SELECT record_id, agent_id, kind, content, source_revision, created_at,
metadata_json, source_json, pinned
FROM memory_records
WHERE agent_id = ?1 AND tombstoned_at IS NULL
AND (?2 IS NULL OR kind = ?2)
AND (?3 IS NULL OR pinned = ?3)
AND (?4 IS NULL OR source_json = ?4)
AND (?5 IS NULL OR created_at >= ?5)
AND (?6 IS NULL OR created_at <= ?6)
AND (?7 IS NULL OR created_at {direction} ?7
OR (created_at = ?7 AND record_id {direction} ?8))
ORDER BY created_at {order}, record_id {order}
LIMIT ?9
"#
);
let mut stmt = conn.prepare(&sql).map_err(sqlite_error)?;
let mut records = stmt
.query_map(
params![
request.agent_id,
kind,
request.filter.pinned.map(i64::from),
source,
request.filter.created_from,
request.filter.created_to,
cursor_time,
cursor_id,
limit.saturating_add(1) as i64,
],
memory_record_from_row,
)
.map_err(sqlite_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(sqlite_error)?;
let has_more = records.len() > limit;
records.truncate(limit);
let next_cursor = if has_more {
records.last().map(|record| MemoryListCursor {
created_at: record.created_at,
record_id: record.record_id.clone(),
})
} else {
None
};
Ok(MemoryListPage {
records,
next_cursor,
})
}
fn get_record(
&self,
agent_id: &str,
record_id: &str,
) -> Result<Option<MemoryRecord>, RuntimeError> {
let conn = self.open()?;
conn.query_row(
r#"
SELECT record_id, agent_id, kind, content, source_revision, created_at,
metadata_json, source_json, pinned
FROM memory_records
WHERE agent_id = ?1 AND record_id = ?2 AND tombstoned_at IS NULL
"#,
params![agent_id, record_id],
memory_record_from_row,
)
.optional()
.map_err(sqlite_error)
}
fn count_records(&self, agent_id: &str) -> Result<usize, RuntimeError> {
let conn = self.open()?;
conn.query_row(
"SELECT COUNT(*) FROM memory_records WHERE agent_id = ?1 AND tombstoned_at IS NULL",
params![agent_id],
|row| row.get::<_, i64>(0),
)
.map(|count| count as usize)
.map_err(sqlite_error)
}
fn delete_records(&self, record_ids: &[String]) -> Result<(), RuntimeError> {
if record_ids.is_empty() {
return Ok(());
}
let mut conn = self.open()?;
let tx = conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(sqlite_error)?;
for record_id in record_ids {
tx.execute(
"DELETE FROM memory_records_fts WHERE record_id = ?1",
params![record_id],
)
.map_err(sqlite_error)?;
tx.execute(
"DELETE FROM memory_records WHERE record_id = ?1",
params![record_id],
)
.map_err(sqlite_error)?;
}
tx.commit().map_err(sqlite_error)
}
fn tombstone_records(
&self,
agent_id: &str,
record_ids: &[String],
) -> Result<usize, RuntimeError> {
if record_ids.is_empty() {
return Ok(0);
}
let mut conn = self.open()?;
let tx = conn
.transaction_with_behavior(TransactionBehavior::Immediate)
.map_err(sqlite_error)?;
let mut affected = 0usize;
let now = now_secs();
for record_id in record_ids {
let updated = tx
.execute(
r#"
UPDATE memory_records
SET tombstoned_at = ?3, updated_at = ?3
WHERE record_id = ?1 AND agent_id = ?2 AND tombstoned_at IS NULL
"#,
params![record_id, agent_id, now],
)
.map_err(sqlite_error)?;
if updated > 0 {
affected += updated;
tx.execute(
"DELETE FROM memory_records_fts WHERE record_id = ?1",
params![record_id],
)
.map_err(sqlite_error)?;
}
}
tx.commit().map_err(sqlite_error)?;
Ok(affected)
}
fn load_agent_memory_cursor(
&self,
agent_id: &str,
) -> Result<Option<MemoryCursor>, RuntimeError> {
let conn = self.open()?;
conn.query_row(
"SELECT cursor_json FROM memory_cursor WHERE agent_id = ?1",
params![agent_id],
|row| row.get::<_, String>(0),
)
.optional()
.map_err(sqlite_error)?
.map(|json| from_json(&json))
.transpose()
}
fn save_agent_memory_cursor(
&self,
agent_id: &str,
cursor: &MemoryCursor,
) -> Result<(), RuntimeError> {
let conn = self.open()?;
conn.execute(
r#"
INSERT INTO memory_cursor (agent_id, cursor_json, updated_at)
VALUES (?1, ?2, ?3)
ON CONFLICT(agent_id) DO UPDATE SET
cursor_json = excluded.cursor_json,
updated_at = excluded.updated_at
"#,
params![agent_id, to_json(cursor)?, now_secs()],
)
.map_err(sqlite_error)?;
Ok(())
}
}
fn memory_record_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<MemoryRecord> {
let kind = row.get::<_, String>(2)?;
Ok(MemoryRecord {
record_id: row.get(0)?,
agent_id: row.get(1)?,
kind: parse_memory_kind(&kind),
content: row.get(3)?,
source_revision: row.get::<_, i64>(4)? as u64,
created_at: row.get(5)?,
metadata_json: row.get(6)?,
source: decode_source(row.get(7)?),
pinned: row.get::<_, i64>(8)? != 0,
score: None,
})
}
fn parse_memory_kind(kind: &str) -> MemoryRecordKind {
match kind {
"summary" => MemoryRecordKind::Summary,
"fact" => MemoryRecordKind::Fact,
_ => MemoryRecordKind::Episode,
}
}
fn kind_name(kind: MemoryRecordKind) -> &'static str {
match kind {
MemoryRecordKind::Episode => "episode",
MemoryRecordKind::Summary => "summary",
MemoryRecordKind::Fact => "fact",
}
}
fn rank_score(kind: MemoryRecordKind, pinned: bool, created_at: i64, raw_rank: f64) -> f64 {
let kind_bonus = match kind {
MemoryRecordKind::Fact => 3.0,
MemoryRecordKind::Summary => 1.5,
MemoryRecordKind::Episode => 0.0,
};
let manual_bonus = if pinned { 2.0 } else { 0.0 };
let age_hours = ((now_secs() - created_at).max(0) as f64) / 3600.0;
let recency_bonus = 0.5 / (1.0 + age_hours / 24.0);
let text_bonus = 8.0 / (1.0 + raw_rank.abs());
text_bonus + kind_bonus + manual_bonus + recency_bonus
}
fn encode_source(source: Option<&str>) -> Result<Option<String>, RuntimeError> {
source
.map(|value| {
serde_json::to_string(value).map_err(|error| RuntimeError::Store(error.to_string()))
})
.transpose()
}
fn decode_source(source_json: Option<String>) -> Option<String> {
source_json.and_then(|json| serde_json::from_str::<String>(&json).ok())
}
fn to_json<T: serde::Serialize>(value: &T) -> Result<String, RuntimeError> {
serde_json::to_string(value).map_err(|error| RuntimeError::Store(error.to_string()))
}
fn from_json<T: serde::de::DeserializeOwned>(value: &str) -> Result<T, RuntimeError> {
serde_json::from_str(value).map_err(|error| RuntimeError::Store(error.to_string()))
}
fn sqlite_error(error: rusqlite::Error) -> RuntimeError {
RuntimeError::Store(error.to_string())
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
fn fts_query(query: &str) -> Option<String> {
let tokens = query
.split(|ch: char| !ch.is_alphanumeric())
.filter(|token| !token.is_empty())
.map(|token| format!("\"{token}\""))
.collect::<Vec<_>>();
if tokens.is_empty() {
None
} else {
Some(tokens.join(" OR "))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::memory::{MemoryListFilter, MemorySearchMode, MemoryStore};
#[test]
fn pinned_manual_facts_outrank_episodes() {
let store = SqliteHybridMemoryStore::new(
std::env::temp_dir().join(format!("mentra-hybrid-memory-{}.sqlite", now_secs())),
);
store
.upsert_records(&[
MemoryRecord {
record_id: "episode:1".to_string(),
agent_id: "agent-1".to_string(),
kind: MemoryRecordKind::Episode,
content: "shared phrase alpha".to_string(),
source_revision: 1,
created_at: now_secs(),
metadata_json: "{}".to_string(),
source: Some("auto_ingest".to_string()),
pinned: false,
score: None,
},
MemoryRecord {
record_id: "fact:1".to_string(),
agent_id: "agent-1".to_string(),
kind: MemoryRecordKind::Fact,
content: "shared phrase alpha".to_string(),
source_revision: 2,
created_at: now_secs(),
metadata_json: "{}".to_string(),
source: Some("manual_pin".to_string()),
pinned: true,
score: None,
},
])
.expect("seed records");
let records = store
.search_records_with_options(&MemorySearchRequest {
agent_id: "agent-1".to_string(),
query: "shared alpha".to_string(),
limit: 2,
char_budget: None,
mode: MemorySearchMode::Tool,
filter: MemoryListFilter {
kind: Some(MemoryRecordKind::Fact),
..MemoryListFilter::default()
},
})
.expect("search");
assert_eq!(records[0].record_id, "fact:1");
assert!(
records
.iter()
.all(|record| record.kind == MemoryRecordKind::Fact)
);
}
#[test]
fn tombstoned_records_are_excluded_from_reads() {
let store = SqliteHybridMemoryStore::new(
std::env::temp_dir().join(format!("mentra-hybrid-tombstone-{}.sqlite", now_secs())),
);
store
.upsert_records(&[MemoryRecord {
record_id: "fact:1".to_string(),
agent_id: "agent-1".to_string(),
kind: MemoryRecordKind::Fact,
content: "preferred editor is vim".to_string(),
source_revision: 1,
created_at: now_secs(),
metadata_json: "{}".to_string(),
source: Some("manual_pin".to_string()),
pinned: true,
score: None,
}])
.expect("seed records");
assert_eq!(
store
.tombstone_records("agent-1", &["fact:1".to_string()])
.expect("tombstone"),
1
);
let records = store.search_records("agent-1", "vim", 5).expect("search");
assert!(records.is_empty());
}
#[test]
fn punctuation_heavy_queries_still_return_results() {
let store = SqliteHybridMemoryStore::new(
std::env::temp_dir().join(format!("mentra-hybrid-punct-{}.sqlite", now_secs())),
);
store
.upsert_records(&[MemoryRecord {
record_id: "episode:1".to_string(),
agent_id: "agent-1".to_string(),
kind: MemoryRecordKind::Episode,
content: "shared phrase alpha".to_string(),
source_revision: 1,
created_at: now_secs(),
metadata_json: "{}".to_string(),
source: Some("auto_ingest".to_string()),
pinned: false,
score: None,
}])
.expect("seed records");
let records = store
.search_records("agent-1", "(shared) alpha!!!", 5)
.expect("search");
assert_eq!(records.len(), 1);
}
#[test]
fn compatibility_search_wrapper_matches_options_search() {
let store = SqliteHybridMemoryStore::new(
std::env::temp_dir().join(format!("mentra-hybrid-compat-{}.sqlite", now_secs())),
);
store
.upsert_records(&[MemoryRecord {
record_id: "episode:1".to_string(),
agent_id: "agent-1".to_string(),
kind: MemoryRecordKind::Episode,
content: "shared phrase alpha".to_string(),
source_revision: 1,
created_at: now_secs(),
metadata_json: "{}".to_string(),
source: Some("auto_ingest".to_string()),
pinned: false,
score: None,
}])
.expect("seed records");
let compat = store
.search_records("agent-1", "shared alpha", 5)
.expect("compat search");
let explicit = store
.search_records_with_options(&MemorySearchRequest {
agent_id: "agent-1".to_string(),
query: "shared alpha".to_string(),
limit: 5,
char_budget: None,
mode: MemorySearchMode::Automatic,
filter: MemoryListFilter::default(),
})
.expect("explicit search");
assert_eq!(compat.len(), explicit.len());
for (compat_record, explicit_record) in compat.iter().zip(explicit.iter()) {
assert_eq!(compat_record.record_id, explicit_record.record_id);
assert_eq!(compat_record.agent_id, explicit_record.agent_id);
assert_eq!(compat_record.kind, explicit_record.kind);
assert_eq!(compat_record.content, explicit_record.content);
assert_eq!(
compat_record.source_revision,
explicit_record.source_revision
);
assert_eq!(compat_record.created_at, explicit_record.created_at);
assert_eq!(compat_record.metadata_json, explicit_record.metadata_json);
assert_eq!(compat_record.source, explicit_record.source);
assert_eq!(compat_record.pinned, explicit_record.pinned);
let compat_score = compat_record.score.expect("compat score");
let explicit_score = explicit_record.score.expect("explicit score");
assert!(
(compat_score - explicit_score).abs() < 1e-5,
"expected comparable ranking scores, got {compat_score} vs {explicit_score}"
);
}
}
#[test]
fn stable_pages_filters_and_tombstones_survive_restart() {
let path = std::env::temp_dir().join(format!(
"mentra-hybrid-list-{}-{}.sqlite",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let store = SqliteHybridMemoryStore::new(&path);
let make = |id: &str, agent: &str, kind, pinned, source: &str, created_at| MemoryRecord {
record_id: id.to_owned(),
agent_id: agent.to_owned(),
kind,
content: format!("content {id}"),
source_revision: 1,
created_at,
metadata_json: "{}".to_owned(),
source: Some(source.to_owned()),
pinned,
score: None,
};
store
.upsert_records(&[
make(
"fact:c",
"agent-1",
MemoryRecordKind::Fact,
true,
"manual",
20,
),
make(
"fact:b",
"agent-1",
MemoryRecordKind::Fact,
true,
"manual",
20,
),
make(
"episode:a",
"agent-1",
MemoryRecordKind::Episode,
false,
"auto",
10,
),
make(
"fact:other",
"agent-2",
MemoryRecordKind::Fact,
true,
"manual",
30,
),
])
.unwrap();
let bulk = (0..105)
.map(|index| {
make(
&format!("episode:bulk:{index:03}"),
"agent-bulk",
MemoryRecordKind::Episode,
false,
"auto",
index,
)
})
.collect::<Vec<_>>();
store.upsert_records(&bulk).unwrap();
let bounded = store
.list_records(&MemoryListRequest {
agent_id: "agent-bulk".to_owned(),
cursor: None,
limit: usize::MAX,
filter: MemoryListFilter::default(),
sort: MemoryListSort::Newest,
})
.unwrap();
assert_eq!(bounded.records.len(), 100);
assert!(bounded.next_cursor.is_some());
let request = MemoryListRequest {
agent_id: "agent-1".to_owned(),
cursor: None,
limit: 1,
filter: MemoryListFilter {
kind: Some(MemoryRecordKind::Fact),
pinned: Some(true),
source: Some("manual".to_owned()),
..MemoryListFilter::default()
},
sort: MemoryListSort::Newest,
};
let first = store.list_records(&request).unwrap();
assert_eq!(first.records[0].record_id, "fact:c");
store
.upsert_records(&[make(
"fact:d",
"agent-1",
MemoryRecordKind::Fact,
true,
"manual",
30,
)])
.unwrap();
let second = store
.list_records(&MemoryListRequest {
cursor: first.next_cursor,
..request
})
.unwrap();
assert_eq!(second.records[0].record_id, "fact:b");
assert_eq!(store.count_records("agent-1").unwrap(), 4);
assert!(store.get_record("agent-2", "fact:b").unwrap().is_none());
assert_eq!(
store
.tombstone_records("agent-1", &["fact:b".to_owned()])
.unwrap(),
1
);
drop(store);
let reopened = SqliteHybridMemoryStore::new(&path);
assert!(reopened.get_record("agent-1", "fact:b").unwrap().is_none());
assert_eq!(reopened.count_records("agent-1").unwrap(), 3);
let after_restart = reopened
.list_records(&MemoryListRequest {
agent_id: "agent-1".to_owned(),
cursor: None,
limit: 100,
filter: MemoryListFilter::default(),
sort: MemoryListSort::Newest,
})
.unwrap();
assert!(
after_restart
.records
.iter()
.all(|record| record.record_id != "fact:b")
);
assert!(
reopened
.search_records("agent-1", "fact b", 100)
.unwrap()
.iter()
.all(|record| record.record_id != "fact:b")
);
let _ = std::fs::remove_file(path);
}
}

10
vendor/mentra/src/memory/journal.rs vendored Normal file
View File

@@ -0,0 +1,10 @@
mod ops;
mod recovery;
mod snapshot;
mod state;
mod store;
#[cfg(test)]
mod tests;
pub(crate) use ops::{AgentMemory, CompactionOutcome};
pub(crate) use state::{AgentMemoryState, PendingTurnState};

232
vendor/mentra/src/memory/journal/ops.rs vendored Normal file
View File

@@ -0,0 +1,232 @@
use std::{collections::BTreeMap, path::PathBuf, sync::Arc};
use crate::{
Message,
error::RuntimeError,
runtime::RuntimeStore,
transcript::{AgentTranscript, EntryId, TranscriptItem, transcript_item_from_message},
};
use super::{
recovery::RecoveryOutcome,
snapshot::AgentSnapshotMemoryView,
state::{AgentMemoryState, PendingTurnState, RunMemoryState},
store::AgentMemoryStore,
};
#[derive(Debug, Clone)]
pub(crate) struct CompactionOutcome {
pub transcript_path: PathBuf,
pub transcript: AgentTranscript,
}
pub(crate) struct AgentMemory {
agent_id: String,
store: Arc<dyn RuntimeStore>,
state: AgentMemoryState,
history_cache: Vec<Message>,
}
impl AgentMemory {
pub fn new(
agent_id: impl Into<String>,
store: Arc<dyn RuntimeStore>,
state: AgentMemoryState,
) -> Self {
let history_cache = state.transcript.to_messages();
Self {
agent_id: agent_id.into(),
store,
state,
history_cache,
}
}
pub fn begin_run(&mut self, run_id: String, user_message: Message) -> Result<(), RuntimeError> {
self.state.run = Some(RunMemoryState {
run_id,
baseline_transcript: self.state.transcript.clone(),
assistant_committed: false,
});
self.state.pending_turn = None;
self.state.resumable_user_message = Some(user_message.clone());
self.state
.transcript
.push(transcript_item_from_message(user_message));
self.sync_history_cache();
self.persist()
}
pub fn append_message(&mut self, message: Message) -> Result<(), RuntimeError> {
self.append_transcript_item(transcript_item_from_message(message))
}
/// Additive counterpart to [`Self::append_message`] that also attaches
/// opaque per-call host metadata (keyed by `tool_use_id`) to the
/// resulting transcript item, so it survives persistence and replay
/// without mentra interpreting it (ADR-0001 §4).
pub fn append_message_with_details(
&mut self,
message: Message,
details: BTreeMap<String, serde_json::Value>,
) -> Result<(), RuntimeError> {
self.append_transcript_item(transcript_item_from_message(message).with_details(details))
}
pub fn append_transcript_item(&mut self, item: TranscriptItem) -> Result<(), RuntimeError> {
self.state.transcript.push(item);
self.sync_history_cache();
self.persist()
}
pub fn update_pending_turn(&mut self, pending: PendingTurnState) -> Result<(), RuntimeError> {
self.state.pending_turn = Some(pending);
self.persist()
}
pub fn clear_pending_turn(&mut self) -> Result<(), RuntimeError> {
self.state.pending_turn = None;
self.persist()
}
pub fn commit_assistant_message(&mut self, message: Message) -> Result<(), RuntimeError> {
self.state
.transcript
.push(transcript_item_from_message(message));
self.sync_history_cache();
self.state.pending_turn = None;
if let Some(run) = &mut self.state.run {
run.assistant_committed = true;
}
self.persist()
}
#[cfg(test)]
pub fn compact(&mut self, outcome: CompactionOutcome) -> Result<(), RuntimeError> {
self.state.transcript = outcome.transcript;
self.sync_history_cache();
let _ = outcome.transcript_path;
self.persist()
}
pub fn rollback_failed_run(&mut self) -> Result<(), RuntimeError> {
if let Some(run) = self.state.run.take() {
self.state.transcript = run.baseline_transcript;
}
self.sync_history_cache();
self.state.pending_turn = None;
self.persist()
}
pub fn finish_run(&mut self) -> Result<(), RuntimeError> {
self.state.pending_turn = None;
self.state.run = None;
self.state.resumable_user_message = None;
self.persist()
}
pub fn recover(&mut self) -> Result<RecoveryOutcome, RuntimeError> {
let Some(run) = self.state.run.take() else {
return Ok(RecoveryOutcome::default());
};
let had_pending_turn = self.state.pending_turn.take().is_some();
if had_pending_turn || !run.assistant_committed {
self.state.transcript = run.baseline_transcript;
self.sync_history_cache();
} else {
self.state.resumable_user_message = None;
}
self.persist()?;
Ok(RecoveryOutcome {
interrupted: true,
interrupted_run_id: Some(run.run_id),
})
}
/// Moves the transcript leaf back to `id` and re-derives history.
pub fn branch_from(&mut self, id: &EntryId) -> Result<usize, RuntimeError> {
let moved = self
.state
.transcript
.branch_from(id)
.map_err(RuntimeError::Branch)?;
self.sync_history_cache();
self.persist()?;
Ok(moved)
}
pub fn transcript(&self) -> &AgentTranscript {
&self.state.transcript
}
pub fn history(&self) -> &[Message] {
&self.history_cache
}
pub fn revision(&self) -> u64 {
self.state.revision
}
pub fn last_message(&self) -> Option<&Message> {
self.history_cache.last()
}
pub fn resumable_user_message(&self) -> Option<&Message> {
self.state.resumable_user_message.as_ref()
}
pub fn snapshot_view(&self) -> AgentSnapshotMemoryView {
AgentSnapshotMemoryView::from(&self.state)
}
pub fn state(&self) -> &AgentMemoryState {
&self.state
}
pub fn current_run_delta(&self) -> Option<Vec<Message>> {
let run = self.state.run.as_ref()?;
let start = run.baseline_transcript.len();
if start >= self.state.transcript.len() {
return Some(self.history_cache.clone());
}
Some(self.state.transcript.projected_messages_from(start))
}
pub fn try_apply_compaction(
&mut self,
base_revision: u64,
outcome: CompactionOutcome,
) -> Result<bool, RuntimeError> {
if self.state.revision != base_revision {
return Ok(false);
}
self.state.transcript = outcome.transcript;
self.sync_history_cache();
let _ = outcome.transcript_path;
self.persist()?;
Ok(true)
}
fn sync_history_cache(&mut self) {
self.history_cache = self.state.transcript.to_messages();
}
fn persist(&mut self) -> Result<(), RuntimeError> {
self.state.revision = self.state.revision.saturating_add(1);
self.store.save_memory(&self.agent_id, &self.state)
}
}
impl PendingTurnState {
pub fn new(
current_text: String,
pending_tool_uses: Vec<crate::agent::PendingToolUseSummary>,
) -> Self {
Self {
current_text,
pending_tool_uses,
}
}
}

View File

@@ -0,0 +1,5 @@
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct RecoveryOutcome {
pub interrupted: bool,
pub interrupted_run_id: Option<String>,
}

View File

@@ -0,0 +1,28 @@
use crate::agent::PendingToolUseSummary;
use super::state::AgentMemoryState;
#[derive(Debug, Clone, Default)]
pub struct AgentSnapshotMemoryView {
pub history_len: usize,
pub current_text: String,
pub pending_tool_uses: Vec<PendingToolUseSummary>,
}
impl From<&AgentMemoryState> for AgentSnapshotMemoryView {
fn from(state: &AgentMemoryState) -> Self {
Self {
history_len: state.transcript.len(),
current_text: state
.pending_turn
.as_ref()
.map(|pending| pending.current_text.clone())
.unwrap_or_default(),
pending_tool_uses: state
.pending_turn
.as_ref()
.map(|pending| pending.pending_tool_uses.clone())
.unwrap_or_default(),
}
}
}

View File

@@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
use crate::{Message, agent::PendingToolUseSummary, transcript::AgentTranscript};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AgentMemoryState {
#[serde(default, deserialize_with = "deserialize_transcript")]
pub transcript: AgentTranscript,
pub pending_turn: Option<PendingTurnState>,
pub resumable_user_message: Option<Message>,
pub compaction: CompactionState,
pub revision: u64,
pub run: Option<RunMemoryState>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct PendingTurnState {
pub current_text: String,
pub pending_tool_uses: Vec<PendingToolUseSummary>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CompactionState;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunMemoryState {
pub run_id: String,
#[serde(default, deserialize_with = "deserialize_transcript")]
pub baseline_transcript: AgentTranscript,
pub assistant_committed: bool,
}
fn deserialize_transcript<'de, D>(deserializer: D) -> Result<AgentTranscript, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum TranscriptRepr {
Transcript(AgentTranscript),
Legacy(Vec<Message>),
}
Ok(match TranscriptRepr::deserialize(deserializer)? {
TranscriptRepr::Transcript(transcript) => transcript,
TranscriptRepr::Legacy(messages) => AgentTranscript::from_messages(messages),
})
}

View File

@@ -0,0 +1,16 @@
use crate::{error::RuntimeError, runtime::RuntimeStore};
use super::state::AgentMemoryState;
pub(crate) trait AgentMemoryStore: Send + Sync {
fn save_memory(&self, agent_id: &str, state: &AgentMemoryState) -> Result<(), RuntimeError>;
}
impl<T> AgentMemoryStore for T
where
T: RuntimeStore + ?Sized,
{
fn save_memory(&self, agent_id: &str, state: &AgentMemoryState) -> Result<(), RuntimeError> {
self.save_agent_memory(agent_id, state)
}
}

View File

@@ -0,0 +1,132 @@
use std::{collections::BTreeMap, sync::Arc};
use serde_json::json;
use crate::{
AgentTranscript, ContentBlock, Message, TranscriptKind,
memory::journal::{AgentMemory, AgentMemoryState, CompactionOutcome, PendingTurnState},
runtime::VolatileRuntimeStore,
};
#[test]
fn begin_run_commit_and_finish_persist_memory_state() {
let store = Arc::new(VolatileRuntimeStore::new());
let mut memory = AgentMemory::new("agent-test", store, AgentMemoryState::default());
memory
.begin_run(
"run-1".to_string(),
Message::user(ContentBlock::text("hello")),
)
.expect("begin run");
assert_eq!(memory.transcript().len(), 1);
assert_eq!(memory.state().revision, 1);
assert_eq!(
memory.resumable_user_message(),
Some(&Message::user(ContentBlock::text("hello")))
);
memory
.update_pending_turn(PendingTurnState::new("Hel".to_string(), Vec::new()))
.expect("update pending");
assert_eq!(memory.snapshot_view().current_text, "Hel");
memory
.commit_assistant_message(Message::assistant(ContentBlock::text("done")))
.expect("commit message");
assert_eq!(memory.transcript().len(), 2);
assert!(memory.snapshot_view().current_text.is_empty());
memory.finish_run().expect("finish run");
assert!(memory.state().run.is_none());
assert!(memory.resumable_user_message().is_none());
}
#[test]
fn rollback_and_compaction_update_memory_state() {
let store = Arc::new(VolatileRuntimeStore::new());
let mut memory = AgentMemory::new("agent-test", store, AgentMemoryState::default());
memory
.begin_run(
"run-1".to_string(),
Message::user(ContentBlock::text("hello")),
)
.expect("begin run");
memory
.update_pending_turn(PendingTurnState::new("partial".to_string(), Vec::new()))
.expect("pending");
memory.rollback_failed_run().expect("rollback");
assert!(memory.transcript().is_empty());
assert_eq!(
memory.resumable_user_message(),
Some(&Message::user(ContentBlock::text("hello")))
);
memory
.append_message(Message::user(ContentBlock::text("after")))
.expect("append");
let path = std::env::temp_dir().join("compacted.jsonl");
memory
.compact(CompactionOutcome {
transcript_path: path.clone(),
transcript: AgentTranscript::from_messages(vec![Message::user(ContentBlock::text(
"summary",
))]),
})
.expect("compact");
assert_eq!(memory.transcript().len(), 1);
let _ = path;
}
// M3: `append_message_with_details` is an additive counterpart to
// `append_message` that behaves identically except for attaching metadata —
// proven directly against the in-process transcript here. The full
// persist/reload round-trip through the SQLite store (which additionally
// requires a real `agents` row, written by `Runtime::spawn`/`create_agent`,
// not just `AgentMemory` in isolation) is covered end-to-end in
// `agent::tests::runtime_resume::resumed_agent_keeps_tool_result_details_after_restart`.
#[test]
fn append_message_with_details_attaches_metadata_keyed_by_tool_use_id() {
let store = Arc::new(VolatileRuntimeStore::new());
let mut memory = AgentMemory::new("agent-details", store, AgentMemoryState::default());
memory
.begin_run(
"run-1".to_string(),
Message::user(ContentBlock::text("run the details tool")),
)
.expect("begin run");
memory
.commit_assistant_message(Message::assistant(ContentBlock::ToolUse {
id: "call-1".to_string(),
name: "details_tool".to_string(),
input: json!({}),
}))
.expect("commit assistant tool call");
let details: BTreeMap<String, serde_json::Value> =
BTreeMap::from([("call-1".to_string(), json!({ "secret": "shh", "n": 42 }))]);
memory
.append_message_with_details(
Message::user(ContentBlock::ToolResult {
tool_use_id: "call-1".to_string(),
content: "tool output".to_string().into(),
is_error: false,
}),
details.clone(),
)
.expect("append with details");
let item = memory
.transcript()
.items()
.iter()
.find(|item| matches!(item.kind, TranscriptKind::ToolExchange { .. }))
.expect("transcript keeps the tool exchange item");
assert_eq!(item.details(), Some(&details));
assert_eq!(
item.detail("call-1"),
Some(&json!({ "secret": "shh", "n": 42 }))
);
}

890
vendor/mentra/src/provider.rs vendored Normal file
View File

@@ -0,0 +1,890 @@
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
pub use mentra_provider::AnthropicRequestOptions;
pub use mentra_provider::AuthScheme;
pub use mentra_provider::BuiltinProvider;
pub use mentra_provider::CompactionInputItem;
pub use mentra_provider::CompactionRequest;
pub use mentra_provider::CompactionResponse;
pub use mentra_provider::ContentBlock;
pub use mentra_provider::ContentBlockDelta;
pub use mentra_provider::ContentBlockStart;
pub use mentra_provider::EmbeddingData;
pub use mentra_provider::EmbeddingModelInfo;
pub use mentra_provider::EmbeddingProvider;
pub use mentra_provider::EmbeddingRequest;
pub use mentra_provider::EmbeddingResponse;
pub use mentra_provider::EmbeddingUsage;
pub use mentra_provider::GeminiRequestOptions;
pub use mentra_provider::ImageSource;
pub use mentra_provider::MemorySummarizeOutput;
pub use mentra_provider::MemorySummarizeRequest;
pub use mentra_provider::MemorySummarizeResponse;
pub use mentra_provider::Message;
pub use mentra_provider::ModelInfo;
pub use mentra_provider::ModelSelector;
pub use mentra_provider::OpenAIRequestOptions;
pub use mentra_provider::ProviderCapabilities;
pub use mentra_provider::ProviderCredentials;
pub use mentra_provider::ProviderDefinition;
pub use mentra_provider::ProviderDescriptor;
pub use mentra_provider::ProviderError;
pub use mentra_provider::ProviderEvent;
pub use mentra_provider::ProviderEventStream;
pub use mentra_provider::ProviderId;
pub use mentra_provider::ProviderRequestOptions;
pub use mentra_provider::RawMemory;
pub use mentra_provider::RawMemoryMetadata;
pub use mentra_provider::ReasoningEffort;
pub use mentra_provider::ReasoningFormat;
pub use mentra_provider::ReasoningOptions;
pub use mentra_provider::ReasoningProvenance;
pub use mentra_provider::Request;
pub use mentra_provider::Response;
pub use mentra_provider::ResponsesRequestOptions;
pub use mentra_provider::ResponsesStateMode;
pub use mentra_provider::ResponsesTransport;
pub use mentra_provider::RetryPolicy;
pub use mentra_provider::Role;
pub use mentra_provider::TokenUsage;
pub use mentra_provider::ToolChoice;
pub use mentra_provider::ToolSearchMode;
pub use mentra_provider::WireApi;
pub use mentra_provider::collect_response_from_stream;
pub use mentra_provider::provider_event_stream_from_response;
pub mod model {
pub use mentra_provider::AnthropicRequestOptions;
pub use mentra_provider::ContentBlock;
pub use mentra_provider::ContentBlockDelta;
pub use mentra_provider::ContentBlockStart;
pub use mentra_provider::ImageSource;
pub use mentra_provider::MemorySummarizeOutput;
pub use mentra_provider::MemorySummarizeRequest;
pub use mentra_provider::MemorySummarizeResponse;
pub use mentra_provider::Message;
pub use mentra_provider::ModelInfo;
pub use mentra_provider::OpenAIRequestOptions;
pub use mentra_provider::ProviderError;
pub use mentra_provider::ProviderEvent;
pub use mentra_provider::ProviderEventStream;
pub use mentra_provider::ProviderId;
pub use mentra_provider::ProviderRequestOptions;
pub use mentra_provider::RawMemory;
pub use mentra_provider::RawMemoryMetadata;
pub use mentra_provider::ReasoningEffort;
pub use mentra_provider::ReasoningFormat;
pub use mentra_provider::ReasoningOptions;
pub use mentra_provider::ReasoningProvenance;
pub use mentra_provider::Request;
pub use mentra_provider::Response;
pub use mentra_provider::ResponsesStateMode;
pub use mentra_provider::ResponsesTransport;
pub use mentra_provider::Role;
pub use mentra_provider::TokenUsage;
pub use mentra_provider::ToolChoice;
pub use mentra_provider::ToolSearchMode;
pub use mentra_provider::collect_response_from_stream;
pub use mentra_provider::provider_event_stream_from_response;
}
/// Transport-neutral interface implemented by model providers.
#[async_trait]
pub trait Provider: Send + Sync {
/// Returns identifying metadata for the provider instance.
fn descriptor(&self) -> ProviderDescriptor;
/// Returns feature flags supported by this provider instance.
fn capabilities(&self) -> ProviderCapabilities {
ProviderCapabilities::default()
}
/// Lists models available from the provider.
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError>;
/// Streams a model response for the given request.
async fn stream(&self, request: Request<'_>) -> Result<ProviderEventStream, ProviderError>;
/// Sends a request and collects the full response in memory.
async fn send(&self, request: Request<'_>) -> Result<Response, ProviderError> {
collect_response_from_stream(self.stream(request).await?).await
}
/// Compacts transcript history using a provider-native endpoint when supported.
async fn compact(
&self,
_request: CompactionRequest<'_>,
) -> Result<CompactionResponse, ProviderError> {
Err(ProviderError::UnsupportedCapability(
"history_compaction".to_string(),
))
}
/// Summarizes raw trace memories using a provider-native implementation when supported.
async fn summarize_memories(
&self,
_request: MemorySummarizeRequest<'_>,
) -> Result<MemorySummarizeResponse, ProviderError> {
Err(ProviderError::UnsupportedCapability(
"memory_summarization".to_string(),
))
}
}
#[derive(Default)]
pub struct ProviderRegistry {
default_provider: Option<ProviderId>,
default_embedding_provider: Option<ProviderId>,
providers: HashMap<ProviderId, Arc<dyn Provider>>,
embedding_providers: HashMap<ProviderId, Arc<dyn EmbeddingProvider>>,
/// The Responses transport this runtime's requests go out on, or `None`
/// when the runtime does not choose and each request's own options stand.
///
/// It lives here rather than on the handle because a transport is a
/// property of the connection to a provider, and this is where a runtime
/// keeps those. It also means the choice travels the one path the builder
/// already hands to the handle at build time, instead of a field every
/// `with_*` reconstructor would have to remember to carry.
responses_transport: Option<ResponsesTransport>,
}
impl ProviderRegistry {
pub(crate) fn register_builtin_provider(
&mut self,
id: BuiltinProvider,
api_key: impl Into<String>,
) -> Result<(), String> {
let api_key = api_key.into();
let provider: Arc<dyn Provider> = match id {
BuiltinProvider::Anthropic => {
Arc::new(anthropic::AnthropicProvider::new(api_key.clone()))
}
BuiltinProvider::Gemini => Arc::new(gemini::GeminiProvider::new(api_key.clone())),
BuiltinProvider::OpenAI => Arc::new(openai::OpenAIProvider::new(api_key.clone())),
BuiltinProvider::OpenRouter => {
Arc::new(openrouter::OpenRouterProvider::new(api_key.clone()))
}
BuiltinProvider::Ollama => Arc::new(ollama::OllamaProvider::new()),
BuiltinProvider::LmStudio => Arc::new(lmstudio::LmStudioProvider::new()),
};
let provider_id: ProviderId = id.into();
if self.default_provider.is_none() {
self.default_provider = Some(provider_id.clone());
}
// Register embedding provider for providers that support it.
let ep: Option<Arc<dyn EmbeddingProvider>> = match id {
BuiltinProvider::OpenAI => Some(Arc::new(mentra_provider::responses::openai(api_key))),
BuiltinProvider::OpenRouter => {
Some(Arc::new(mentra_provider::responses::openrouter(api_key)))
}
BuiltinProvider::Ollama => Some(Arc::new(openai_compatible_embedding_provider(
id,
"http://127.0.0.1:11434/",
))),
BuiltinProvider::LmStudio => Some(Arc::new(openai_compatible_embedding_provider(
id,
"http://127.0.0.1:1234/",
))),
_ => None,
};
if let Some(ep) = ep {
if self.default_embedding_provider.is_none() {
self.default_embedding_provider = Some(provider_id.clone());
}
self.embedding_providers.insert(provider_id.clone(), ep);
}
self.providers.insert(provider_id, provider);
Ok(())
}
pub(crate) fn register_provider_instance<P>(&mut self, provider: P)
where
P: Provider + 'static,
{
let descriptor = provider.descriptor();
let id = descriptor.id;
if self.default_provider.is_none() {
self.default_provider = Some(id.clone());
}
self.providers.insert(id, Arc::new(provider));
}
pub(crate) fn register_registered_provider<P>(&mut self, provider: P)
where
P: mentra_provider::Provider + 'static,
{
let descriptor = provider.descriptor();
let id = descriptor.id;
if self.default_provider.is_none() {
self.default_provider = Some(id.clone());
}
self.providers.insert(id, shared_provider(provider));
}
pub(crate) fn register_ollama(&mut self) {
self.register_provider_instance(ollama::OllamaProvider::new());
}
pub(crate) fn register_lmstudio(&mut self) {
self.register_provider_instance(lmstudio::LmStudioProvider::new());
}
pub(crate) fn get_provider(&self, id: Option<&ProviderId>) -> Option<Arc<dyn Provider>> {
match id {
Some(id) => self.providers.get(id).cloned(),
None => self
.default_provider
.as_ref()
.and_then(|id| self.providers.get(id).cloned()),
}
}
/// Returns the default embedding provider, or `None` if no embedding-capable provider
/// has been registered.
///
/// The default is the first embedding-capable provider registered. To look up a
/// specific provider use [`embedding_provider_for`].
pub fn embedding_provider(&self) -> Option<Arc<dyn EmbeddingProvider>> {
self.default_embedding_provider
.as_ref()
.and_then(|id| self.embedding_providers.get(id))
.map(Arc::clone)
.or_else(|| self.embedding_providers.values().next().map(Arc::clone))
}
/// Returns the embedding provider for a specific provider ID, or `None`.
pub fn embedding_provider_for(&self, id: &ProviderId) -> Option<Arc<dyn EmbeddingProvider>> {
self.embedding_providers.get(id).map(Arc::clone)
}
pub(crate) fn descriptors(&self) -> Vec<ProviderDescriptor> {
self.providers
.values()
.map(|provider| provider.descriptor())
.collect()
}
pub(crate) fn is_empty(&self) -> bool {
self.providers.is_empty()
}
pub(crate) fn set_responses_transport(&mut self, transport: ResponsesTransport) {
self.responses_transport = Some(transport);
}
pub(crate) fn responses_transport(&self) -> Option<ResponsesTransport> {
self.responses_transport
}
}
/// Settles which Responses transport a request goes out on, and refuses one the
/// provider cannot serve.
///
/// The runtime's choice, when it made one, replaces whatever the request's own
/// options carried: it is the connection-level answer, and a per-request one
/// that disagreed would mean two live opinions about a single socket. With no
/// runtime choice the request's own value stands, which is what every caller
/// had before a runtime could choose at all.
///
/// A provider whose capabilities report no websocket support is refused rather
/// than quietly served over HTTP+SSE. The fallback is the tempting behavior and
/// the wrong one: asking for a transport is explicit, so answering on a
/// different one returns a stream nobody asked for and hides a misconfigured
/// runtime behind a working one — the same stance `stream_response` already
/// takes when the transport is not compiled in.
pub(crate) fn select_responses_transport(
provider: &dyn Provider,
chosen: Option<ResponsesTransport>,
options: &mut ProviderRequestOptions,
) -> Result<(), crate::error::RuntimeError> {
if let Some(transport) = chosen {
options.responses.transport = transport;
}
if options.responses.transport != ResponsesTransport::WebSocket
|| provider.capabilities().supports_websockets
{
return Ok(());
}
let descriptor = provider.descriptor();
let name = descriptor
.display_name
.unwrap_or_else(|| descriptor.id.as_str().to_string());
Err(crate::error::RuntimeError::OperationDenied(format!(
"provider '{name}' does not serve the Responses websocket transport; \
select ResponsesTransport::HttpSse or register a provider that does \
— answering over HTTP+SSE would return a transport nobody asked for"
)))
}
fn shared_provider<P>(provider: P) -> Arc<dyn Provider>
where
P: mentra_provider::Provider + 'static,
{
Arc::new(SharedProviderProxy { inner: provider })
}
/// Builds a `ResponsesProvider` (with no credentials) for OpenAI-compatible
/// local providers (Ollama, LmStudio) so they can be used as embedding providers.
fn openai_compatible_embedding_provider(
builtin: BuiltinProvider,
base_url: &str,
) -> mentra_provider::responses::ResponsesProvider<NoCredentialsSource> {
use mentra_provider::AuthScheme;
use mentra_provider::ProviderCapabilities;
use mentra_provider::RetryPolicy;
use mentra_provider::WireApi;
use std::collections::HashMap;
let mut definition = ProviderDefinition::new(builtin);
definition.wire_api = WireApi::Responses;
definition.auth_scheme = AuthScheme::None;
definition.capabilities = ProviderCapabilities {
supports_model_listing: true,
supports_streaming: true,
supports_websockets: false,
supports_tool_calls: true,
supports_images: true,
supports_history_compaction: false,
supports_memory_summarization: false,
supports_deferred_tools: false,
supports_hosted_tool_search: false,
supports_hosted_web_search: false,
supports_image_generation: false,
supports_reasoning_effort: false,
reports_reasoning_tokens: false,
reports_thoughts_tokens: false,
supports_structured_tool_results: false,
supports_embeddings: true,
};
definition.base_url = Some(base_url.to_string());
definition.headers = Some(HashMap::new());
definition.retry = RetryPolicy::default();
mentra_provider::responses::ResponsesProvider::new(definition, NoCredentialsSource)
}
#[derive(Clone)]
struct NoCredentialsSource;
#[async_trait]
impl mentra_provider::CredentialSource for NoCredentialsSource {
async fn credentials(
&self,
) -> Result<mentra_provider::ProviderCredentials, mentra_provider::ProviderError> {
Ok(mentra_provider::ProviderCredentials::default())
}
}
struct SharedProviderProxy<P> {
inner: P,
}
#[async_trait]
impl<P> Provider for SharedProviderProxy<P>
where
P: mentra_provider::Provider + 'static,
{
fn descriptor(&self) -> ProviderDescriptor {
self.inner.descriptor()
}
fn capabilities(&self) -> ProviderCapabilities {
self.inner.definition().capabilities
}
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
self.inner.list_models().await
}
async fn stream(&self, request: Request<'_>) -> Result<ProviderEventStream, ProviderError> {
self.inner.stream(request).await
}
async fn compact(
&self,
request: CompactionRequest<'_>,
) -> Result<CompactionResponse, ProviderError> {
self.inner.compact(request).await
}
async fn summarize_memories(
&self,
request: MemorySummarizeRequest<'_>,
) -> Result<MemorySummarizeResponse, ProviderError> {
self.inner.summarize_memories(request).await
}
}
pub mod openai {
use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use super::AuthScheme;
use super::BuiltinProvider;
use super::CompactionRequest;
use super::CompactionResponse;
use super::Provider;
use super::ProviderCapabilities;
use super::ProviderDefinition;
use super::ProviderDescriptor;
use super::ProviderError;
use super::ProviderEventStream;
use super::Request;
use super::RetryPolicy;
use super::WireApi;
use super::shared_provider;
use crate::provider::model::ModelInfo;
/// Supplies OpenAI API credentials on demand.
#[async_trait]
pub trait OpenAICredentialSource: Send + Sync {
async fn api_key(&self) -> Result<String, String>;
}
#[derive(Clone)]
pub struct OpenAIProvider {
inner: Arc<dyn Provider>,
}
impl OpenAIProvider {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
inner: shared_provider(mentra_provider::responses::openai(api_key)),
}
}
pub(crate) fn openai_compatible(
provider: BuiltinProvider,
display_name: &'static str,
description: &'static str,
base_url: &str,
) -> Self {
let mut definition = ProviderDefinition::new(provider);
definition.descriptor.display_name = Some(display_name.to_string());
definition.descriptor.description = Some(description.to_string());
definition.wire_api = WireApi::Responses;
definition.auth_scheme = AuthScheme::None;
definition.capabilities = ProviderCapabilities {
supports_model_listing: true,
supports_streaming: true,
supports_websockets: false,
supports_tool_calls: true,
supports_images: true,
supports_history_compaction: false,
supports_memory_summarization: false,
supports_deferred_tools: false,
supports_hosted_tool_search: false,
supports_hosted_web_search: false,
supports_image_generation: false,
supports_reasoning_effort: false,
reports_reasoning_tokens: false,
reports_thoughts_tokens: false,
supports_structured_tool_results: false,
supports_embeddings: true,
};
definition.base_url = Some(base_url.to_string());
definition.headers = Some(HashMap::new());
definition.retry = RetryPolicy::default();
let provider = mentra_provider::responses::ResponsesProvider::new(
definition,
super::NoCredentialsSource,
);
Self {
inner: shared_provider(provider),
}
}
pub fn with_credential_source(source: impl OpenAICredentialSource + 'static) -> Self {
Self::with_shared_credential_source(Arc::new(source))
}
pub fn with_shared_credential_source(source: Arc<dyn OpenAICredentialSource>) -> Self {
let provider = mentra_provider::responses::openai_with_credential_source(
OpenAICredentialAdapter { source },
);
Self {
inner: shared_provider(provider),
}
}
}
#[async_trait]
impl Provider for OpenAIProvider {
fn descriptor(&self) -> ProviderDescriptor {
self.inner.descriptor()
}
fn capabilities(&self) -> ProviderCapabilities {
self.inner.capabilities()
}
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
self.inner.list_models().await
}
async fn stream(&self, request: Request<'_>) -> Result<ProviderEventStream, ProviderError> {
self.inner.stream(request).await
}
async fn compact(
&self,
request: CompactionRequest<'_>,
) -> Result<CompactionResponse, ProviderError> {
self.inner.compact(request).await
}
async fn summarize_memories(
&self,
request: super::MemorySummarizeRequest<'_>,
) -> Result<super::MemorySummarizeResponse, ProviderError> {
self.inner.summarize_memories(request).await
}
}
#[derive(Clone)]
struct OpenAICredentialAdapter {
source: Arc<dyn OpenAICredentialSource>,
}
#[async_trait]
impl mentra_provider::CredentialSource for OpenAICredentialAdapter {
async fn credentials(
&self,
) -> Result<mentra_provider::ProviderCredentials, mentra_provider::ProviderError> {
let api_key = self
.source
.api_key()
.await
.map_err(mentra_provider::ProviderError::InvalidRequest)?;
Ok(mentra_provider::ProviderCredentials {
bearer_token: Some(api_key),
account_id: None,
headers: Default::default(),
})
}
}
}
pub mod openrouter {
use std::sync::Arc;
use async_trait::async_trait;
use super::CompactionRequest;
use super::CompactionResponse;
use super::Provider;
use super::ProviderCapabilities;
use super::ProviderDescriptor;
use super::ProviderError;
use super::ProviderEventStream;
use super::Request;
use super::shared_provider;
use crate::provider::model::ModelInfo;
#[derive(Clone)]
pub struct OpenRouterProvider {
inner: Arc<dyn Provider>,
}
impl OpenRouterProvider {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
inner: shared_provider(mentra_provider::responses::openrouter(api_key)),
}
}
}
#[async_trait]
impl Provider for OpenRouterProvider {
fn descriptor(&self) -> ProviderDescriptor {
self.inner.descriptor()
}
fn capabilities(&self) -> ProviderCapabilities {
self.inner.capabilities()
}
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
self.inner.list_models().await
}
async fn stream(&self, request: Request<'_>) -> Result<ProviderEventStream, ProviderError> {
self.inner.stream(request).await
}
async fn compact(
&self,
request: CompactionRequest<'_>,
) -> Result<CompactionResponse, ProviderError> {
self.inner.compact(request).await
}
async fn summarize_memories(
&self,
request: super::MemorySummarizeRequest<'_>,
) -> Result<super::MemorySummarizeResponse, ProviderError> {
self.inner.summarize_memories(request).await
}
}
}
pub mod anthropic {
use std::sync::Arc;
use async_trait::async_trait;
use super::Provider;
use super::ProviderDescriptor;
use super::ProviderError;
use super::ProviderEventStream;
use super::Request;
use super::shared_provider;
use crate::provider::model::ModelInfo;
#[derive(Clone)]
pub struct AnthropicProvider {
inner: Arc<dyn Provider>,
}
impl AnthropicProvider {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
inner: shared_provider(mentra_provider::anthropic::AnthropicProvider::new(api_key)),
}
}
}
#[async_trait]
impl Provider for AnthropicProvider {
fn descriptor(&self) -> ProviderDescriptor {
self.inner.descriptor()
}
fn capabilities(&self) -> super::ProviderCapabilities {
self.inner.capabilities()
}
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
self.inner.list_models().await
}
async fn stream(&self, request: Request<'_>) -> Result<ProviderEventStream, ProviderError> {
self.inner.stream(request).await
}
async fn summarize_memories(
&self,
request: super::MemorySummarizeRequest<'_>,
) -> Result<super::MemorySummarizeResponse, ProviderError> {
self.inner.summarize_memories(request).await
}
}
}
pub mod gemini {
use std::sync::Arc;
use async_trait::async_trait;
use super::Provider;
use super::ProviderDescriptor;
use super::ProviderError;
use super::ProviderEventStream;
use super::Request;
use super::shared_provider;
use crate::provider::model::ModelInfo;
#[derive(Clone)]
pub struct GeminiProvider {
inner: Arc<dyn Provider>,
}
impl GeminiProvider {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
inner: shared_provider(mentra_provider::gemini::GeminiProvider::new(api_key)),
}
}
}
#[async_trait]
impl Provider for GeminiProvider {
fn descriptor(&self) -> ProviderDescriptor {
self.inner.descriptor()
}
fn capabilities(&self) -> super::ProviderCapabilities {
self.inner.capabilities()
}
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
self.inner.list_models().await
}
async fn stream(&self, request: Request<'_>) -> Result<ProviderEventStream, ProviderError> {
self.inner.stream(request).await
}
async fn summarize_memories(
&self,
request: super::MemorySummarizeRequest<'_>,
) -> Result<super::MemorySummarizeResponse, ProviderError> {
self.inner.summarize_memories(request).await
}
}
}
pub mod ollama {
use std::sync::Arc;
use async_trait::async_trait;
use super::BuiltinProvider;
use super::Provider;
use super::ProviderDescriptor;
use super::ProviderError;
use super::ProviderEventStream;
use super::Request;
use crate::provider::model::ModelInfo;
const DEFAULT_BASE_URL: &str = "http://127.0.0.1:11434/";
#[derive(Clone)]
pub struct OllamaProvider {
inner: Arc<dyn Provider>,
}
impl OllamaProvider {
pub fn new() -> Self {
Self::with_base_url(DEFAULT_BASE_URL)
}
pub fn with_base_url(base_url: impl AsRef<str>) -> Self {
Self {
inner: Arc::new(super::openai::OpenAIProvider::openai_compatible(
BuiltinProvider::Ollama,
"Ollama",
"Ollama OpenAI-compatible Responses API provider",
base_url.as_ref(),
)),
}
}
}
impl Default for OllamaProvider {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Provider for OllamaProvider {
fn descriptor(&self) -> ProviderDescriptor {
self.inner.descriptor()
}
fn capabilities(&self) -> super::ProviderCapabilities {
self.inner.capabilities()
}
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
self.inner.list_models().await
}
async fn stream(&self, request: Request<'_>) -> Result<ProviderEventStream, ProviderError> {
self.inner.stream(request).await
}
async fn summarize_memories(
&self,
request: super::MemorySummarizeRequest<'_>,
) -> Result<super::MemorySummarizeResponse, ProviderError> {
self.inner.summarize_memories(request).await
}
}
}
pub mod lmstudio {
use std::sync::Arc;
use async_trait::async_trait;
use super::BuiltinProvider;
use super::Provider;
use super::ProviderDescriptor;
use super::ProviderError;
use super::ProviderEventStream;
use super::Request;
use crate::provider::model::ModelInfo;
const DEFAULT_BASE_URL: &str = "http://127.0.0.1:1234/";
#[derive(Clone)]
pub struct LmStudioProvider {
inner: Arc<dyn Provider>,
}
impl LmStudioProvider {
pub fn new() -> Self {
Self::with_base_url(DEFAULT_BASE_URL)
}
pub fn with_base_url(base_url: impl AsRef<str>) -> Self {
Self {
inner: Arc::new(super::openai::OpenAIProvider::openai_compatible(
BuiltinProvider::LmStudio,
"LM Studio",
"LM Studio OpenAI-compatible Responses API provider",
base_url.as_ref(),
)),
}
}
}
impl Default for LmStudioProvider {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl Provider for LmStudioProvider {
fn descriptor(&self) -> ProviderDescriptor {
self.inner.descriptor()
}
fn capabilities(&self) -> super::ProviderCapabilities {
self.inner.capabilities()
}
async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
self.inner.list_models().await
}
async fn stream(&self, request: Request<'_>) -> Result<ProviderEventStream, ProviderError> {
self.inner.stream(request).await
}
async fn summarize_memories(
&self,
request: super::MemorySummarizeRequest<'_>,
) -> Result<super::MemorySummarizeResponse, ProviderError> {
self.inner.summarize_memories(request).await
}
}
}

638
vendor/mentra/src/runtime.rs vendored Normal file
View File

@@ -0,0 +1,638 @@
mod builder;
pub(crate) mod control;
mod error;
pub(crate) mod handle;
mod hybrid_store;
mod intrinsic;
mod skill;
mod store;
pub(crate) mod task;
mod task_board;
mod volatile_store;
use std::{any::Any, path::Path, sync::Arc};
use tokio::sync::broadcast;
use crate::{
agent::{Agent, AgentConfig, AgentSpawnOptions, AgentStatus},
provider::{Provider, ProviderRegistry},
session::{
Session, SessionEvent, SessionId, SessionMetadata,
permission::{PendingPermissionStore, SessionToolAuthorizer},
},
tool::ExecutableTool,
};
use mentra_provider::{BuiltinProvider, ModelInfo, ModelSelector, ProviderDescriptor, ProviderId};
pub use builder::RuntimeBuilder;
pub use control::sandbox::{ExecutionEnvironment, detect_environment};
pub use control::{
AuditHook, AuditLogHook, CancellationFlag, CancellationToken, CommandOutput, CommandRequest,
CommandSpec, EarlyEnd, ExecOutput, HookDecision, LocalRuntimeExecutor, PreExecutionContext,
PreExecutionHook, PreExecutionHooks, ProviderRetry, RunOptions, RuntimeExecutor, RuntimeHook,
RuntimeHookEvent, RuntimeHooks, RuntimePolicy, ShellValidationMode,
is_transient_provider_error, is_transient_runtime_error,
};
pub use error::{ErrorCategory, RuntimeError};
pub(crate) use handle::RuntimeHandle;
pub use hybrid_store::HybridRuntimeStore;
pub(crate) use intrinsic::RuntimeIntrinsicTool;
pub use skill::{SkillInfo, SkillLoadError};
pub use store::{
AgentStore, AuditStore, LeaseStore, PermissionRuleStore, RunStore, RuntimeStore,
SqliteRuntimeStore, TaskStore,
};
pub(crate) use store::{LoadedAgentState, PersistedAgentRecord, TaskStateSnapshot};
pub(crate) use task::TaskIntrinsicTool;
pub use task::{TaskItem, TaskStatus};
pub use task_board::{NewTask, TaskBoard, TaskBoardError, TaskPatch};
pub use volatile_store::VolatileRuntimeStore;
/// Entry point for configuring providers, tools, and agent lifecycles.
///
/// A runtime composes four main subsystems:
/// - execution: providers, policies, hooks, and command execution
/// - persistence: agent state, runs, tasks, leases, and memory
/// - tooling: registered tools, skills, and app context
/// - collaboration: persistent teams and background task coordination
pub struct Runtime {
handle: RuntimeHandle,
provider_registry: Arc<std::sync::RwLock<ProviderRegistry>>,
pub(crate) mcp_servers: Vec<McpServerSummary>,
}
/// How one configured MCP server fared during
/// [`build_async`](RuntimeBuilder::build_async).
///
/// A server that fails to connect leaves the runtime in degraded mode rather
/// than failing the build — one unreachable server should not sink a session.
/// This is how a host finds out which ones are actually live, so it can say so
/// instead of leaving a user to wonder why a tool is missing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct McpServerSummary {
pub name: String,
/// Tools this server contributed. Zero when it failed.
pub tools: usize,
/// Why it did not connect, when it did not.
pub error: Option<String>,
}
impl McpServerSummary {
pub fn connected(&self) -> bool {
self.error.is_none()
}
}
/// Read-only summary of a persisted agent record for a runtime identifier.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PersistedAgentSummary {
pub id: String,
pub runtime_identifier: String,
pub name: String,
pub is_teammate: bool,
pub status: AgentStatus,
pub history_len: usize,
}
impl Runtime {
/// Returns a builder with Mentra's builtin tools enabled.
pub fn builder() -> RuntimeBuilder {
RuntimeBuilder::new(true)
}
/// Returns a builder with no builtin tools registered.
pub fn empty_builder() -> RuntimeBuilder {
RuntimeBuilder::new(false)
}
/// Registers a custom tool on the runtime after construction.
pub fn register_tool<T>(&self, tool: T)
where
T: ExecutableTool + 'static,
{
self.handle.register_tool(tool);
}
/// Returns descriptors for registered tools in a deterministic order.
pub fn tools(&self) -> Vec<crate::tool::RuntimeToolDescriptor> {
let tool_names = self
.handle
.tools()
.iter()
.map(|tool| tool.name.clone())
.collect::<Vec<_>>();
let mut tools = tool_names
.into_iter()
.filter_map(|name| self.handle.get_tool_descriptor(&name))
.collect::<Vec<_>>();
tools.sort_by(|left, right| left.provider.name.cmp(&right.provider.name));
tools
}
/// Returns the descriptor for a registered tool by name.
pub fn tool_descriptor(&self, name: &str) -> Option<crate::tool::RuntimeToolDescriptor> {
self.handle.get_tool_descriptor(name)
}
/// Registers typed application state that tools can retrieve from their context.
pub fn register_context(&self, context: Arc<dyn Any + Send + Sync>) {
self.handle.register_app_context(context);
}
/// Returns typed application state previously registered on this runtime.
pub fn app_context<T>(&self) -> Result<Arc<T>, String>
where
T: Any + Send + Sync + 'static,
{
self.handle.app_context::<T>()
}
/// Registers a skills directory and enables the builtin `load_skill` tool.
///
/// Additive: calling this again adds a second root rather than replacing
/// the first, and a name already registered wins. Register the most
/// specific root first.
pub fn register_skills_dir(&self, path: impl AsRef<Path>) -> Result<(), SkillLoadError> {
self.handle
.register_skill_loader(skill::SkillLoader::from_dir(path)?);
Ok(())
}
/// Registers several skills directories at once, strongest first.
///
/// Equivalent to calling [`register_skills_dir`](Self::register_skills_dir)
/// for each in order: a skill defined in an earlier root shadows the same
/// name in a later one, so a project root can override a personal one.
/// Within a single root a repeated name is still an error.
///
/// Registration stops at the first unreadable root, leaving the roots
/// before it registered.
pub fn register_skills_dirs<I, P>(&self, paths: I) -> Result<(), SkillLoadError>
where
I: IntoIterator<Item = P>,
P: AsRef<Path>,
{
for path in paths {
self.register_skills_dir(path)?;
}
Ok(())
}
/// Every loaded skill, name-ordered, with its description and source path
/// but not its body.
pub fn skills(&self) -> Vec<SkillInfo> {
self.handle.skills()
}
/// How each configured MCP server fared while the runtime was built.
///
/// Empty when none were configured, or when the runtime came from
/// [`build`](RuntimeBuilder::build), which refuses to be given any.
/// A failed server is present with its error rather than absent: a host
/// telling a user which tools they have needs to name what is missing.
pub fn mcp_servers(&self) -> &[McpServerSummary] {
&self.mcp_servers
}
/// Returns a lead-privileged task-board view for `namespace`.
///
/// The namespace is an opaque store key; no directory is created. Reads are
/// live and every mutation passes through the same validation and
/// transactional store path as the builtin task tools.
pub fn task_board(&self, namespace: impl AsRef<Path>) -> TaskBoard {
TaskBoard::lead(self.handle.clone(), namespace.as_ref().to_path_buf())
}
/// Spawns a new agent with the default [`AgentConfig`].
pub fn spawn(&self, name: impl Into<String>, model: ModelInfo) -> Result<Agent, RuntimeError> {
self.spawn_with_config(name, model, AgentConfig::default())
}
/// Spawns a new agent with an explicit configuration.
pub fn spawn_with_config(
&self,
name: impl Into<String>,
model: ModelInfo,
config: AgentConfig,
) -> Result<Agent, RuntimeError> {
Agent::new(
self.handle.clone(),
model.id,
name.into(),
config,
self.provider_registry
.read()
.expect("provider registry poisoned")
.get_provider(Some(&model.provider))
.ok_or_else(|| RuntimeError::ProviderNotFound(Some(model.provider.clone())))?,
AgentSpawnOptions::default(),
)
}
/// Restores a previously persisted agent by identifier.
pub fn resume_agent(&self, agent_id: &str) -> Result<Agent, RuntimeError> {
let Some(state) = self.handle.store().load_agent(agent_id)? else {
return Err(RuntimeError::Store(format!(
"No persisted agent with id '{agent_id}'"
)));
};
let provider = self
.provider_registry
.read()
.expect("provider registry poisoned")
.get_provider(Some(&state.record.provider_id))
.ok_or_else(|| {
RuntimeError::ProviderNotFound(Some(state.record.provider_id.clone()))
})?;
Agent::from_loaded(self.handle.clone(), state, provider)
}
/// Restores every persisted agent that belongs to the provided runtime identifier.
pub fn resume(&self, runtime_identifier: &str) -> Result<Vec<Agent>, RuntimeError> {
let states = self
.handle
.store()
.list_agents_by_runtime(runtime_identifier)?;
let mut agents = Vec::new();
for state in states {
let provider = self
.provider_registry
.read()
.expect("provider registry poisoned")
.get_provider(Some(&state.record.provider_id))
.ok_or_else(|| {
RuntimeError::ProviderNotFound(Some(state.record.provider_id.clone()))
})?;
let agent = Agent::from_loaded(self.handle.clone(), state, provider)?;
if agent.is_teammate() {
agent.revive_teammate_actor()?;
} else {
agents.push(agent);
}
}
Ok(agents)
}
/// Lists persisted agents for a runtime identifier without reviving them.
pub fn list_persisted_agents(
&self,
runtime_identifier: &str,
) -> Result<Vec<PersistedAgentSummary>, RuntimeError> {
self.handle
.store()
.list_agents_by_runtime(runtime_identifier)
.map(|states| {
states
.into_iter()
.map(|state| PersistedAgentSummary {
id: state.record.id,
runtime_identifier: state.record.runtime_identifier,
name: state.record.name,
is_teammate: state.record.teammate_identity.is_some(),
status: state.record.status,
history_len: state.memory.transcript.len(),
})
.collect()
})
}
/// Restores every persisted agent known to the runtime store.
pub fn resume_all(&self) -> Result<Vec<Agent>, RuntimeError> {
let states = self.handle.store().list_agents()?;
let mut agents = Vec::new();
for state in states {
let provider = self
.provider_registry
.read()
.expect("provider registry poisoned")
.get_provider(Some(&state.record.provider_id))
.ok_or_else(|| {
RuntimeError::ProviderNotFound(Some(state.record.provider_id.clone()))
})?;
agents.push(Agent::from_loaded(self.handle.clone(), state, provider)?);
}
Ok(agents)
}
}
impl Runtime {
/// Returns descriptors for registered providers.
pub fn providers(&self) -> Vec<ProviderDescriptor> {
self.provider_registry
.read()
.expect("provider registry poisoned")
.descriptors()
}
/// The Responses transport this runtime chose for every request it makes,
/// or `None` when it left the choice to each request's own options — which
/// is HTTP+SSE unless a host set otherwise.
///
/// The reader for
/// [`RuntimeBuilder::with_responses_transport`](crate::runtime::RuntimeBuilder::with_responses_transport).
/// A transport is otherwise the one piece of a runtime's configuration
/// nothing can observe: a registered tool shows up in
/// [`tools`](Self::tools), a provider in [`providers`](Self::providers),
/// but a transport reaches only the requests the runtime sends. That makes
/// the wiring between a host's choice and this runtime untestable except by
/// running a turn against a provider that records what it was handed — and
/// leaves a host that wants to report its own configuration with no way to
/// ask.
pub fn responses_transport(&self) -> Option<crate::provider::ResponsesTransport> {
self.provider_registry
.read()
.expect("provider registry poisoned")
.responses_transport()
}
/// Registers a builtin provider from an API key.
pub fn register_provider(
&mut self,
id: BuiltinProvider,
api_key: impl Into<String>,
) -> Result<(), String> {
self.provider_registry
.write()
.expect("provider registry poisoned")
.register_builtin_provider(id, api_key)
}
/// Registers the local Ollama provider using its default OpenAI-compatible endpoint.
pub fn register_ollama(&mut self) {
self.provider_registry
.write()
.expect("provider registry poisoned")
.register_ollama();
}
/// Registers the local LM Studio provider using its default OpenAI-compatible endpoint.
pub fn register_lmstudio(&mut self) {
self.provider_registry
.write()
.expect("provider registry poisoned")
.register_lmstudio();
}
/// Registers a custom runtime provider implementation.
///
/// This is the supported seam for injecting a scripted provider in tests or
/// embedding Mentra on top of a custom transport.
///
/// ```rust,no_run
/// use async_trait::async_trait;
/// use mentra::{BuiltinProvider, ModelInfo, ProviderDescriptor, Runtime};
/// use mentra::error::{ProviderError, RuntimeError};
/// use mentra::provider::{Provider, ProviderEventStream, Request};
/// use tokio::sync::mpsc;
///
/// struct TestProvider;
///
/// #[async_trait]
/// impl Provider for TestProvider {
/// fn descriptor(&self) -> ProviderDescriptor {
/// ProviderDescriptor::new(BuiltinProvider::Anthropic)
/// }
///
/// async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
/// Ok(vec![ModelInfo::new("test-model", BuiltinProvider::Anthropic)])
/// }
///
/// async fn stream(
/// &self,
/// _request: Request<'_>,
/// ) -> Result<ProviderEventStream, ProviderError> {
/// let (_tx, rx) = mpsc::unbounded_channel();
/// Ok(rx)
/// }
/// }
///
/// let mut runtime = Runtime::empty_builder()
/// .with_provider(BuiltinProvider::Anthropic, "placeholder")
/// .build()?;
/// runtime.register_provider_instance(TestProvider);
/// # Ok::<(), RuntimeError>(())
/// ```
pub fn register_provider_instance<P>(&mut self, provider: P)
where
P: Provider + 'static,
{
self.provider_registry
.write()
.expect("provider registry poisoned")
.register_provider_instance(provider);
}
/// Registers a provider-core instance built from `mentra::provider_core`.
///
/// Use this when you want Mentra's runtime with a customized provider
/// definition, such as a custom OpenAI-compatible or Anthropic-compatible
/// base URL.
pub fn register_registered_provider<P>(&mut self, provider: P)
where
P: mentra_provider::Provider + 'static,
{
self.provider_registry
.write()
.expect("provider registry poisoned")
.register_registered_provider(provider);
}
/// Lists models for a specific provider, or the default provider when omitted.
pub async fn list_models(
&self,
provider: Option<&ProviderId>,
) -> Result<Vec<ModelInfo>, RuntimeError> {
let provider = self
.provider_registry
.read()
.expect("provider registry poisoned")
.get_provider(provider)
.ok_or_else(|| RuntimeError::ProviderNotFound(provider.cloned()))?;
provider
.list_models()
.await
.map_err(RuntimeError::FailedToListModels)
}
/// Resolves a model for a registered provider using a deterministic selection strategy.
pub async fn resolve_model(
&self,
provider: impl Into<ProviderId>,
selector: ModelSelector,
) -> Result<ModelInfo, RuntimeError> {
let provider = provider.into();
if self
.provider_registry
.read()
.expect("provider registry poisoned")
.get_provider(Some(&provider))
.is_none()
{
return Err(RuntimeError::ProviderNotFound(Some(provider)));
}
match selector {
ModelSelector::Id(id) => Ok(ModelInfo::new(id, provider)),
ModelSelector::NewestAvailable => {
let mut models = self.list_models(Some(&provider)).await?;
models.sort_by(|left, right| {
right
.created_at
.cmp(&left.created_at)
.then_with(|| left.id.cmp(&right.id))
});
models
.into_iter()
.next()
.ok_or(RuntimeError::NoModelsAvailable(provider))
}
}
}
}
// -- Session lifecycle methods --
impl Runtime {
/// Creates a new session wrapping a freshly spawned agent with default config.
pub fn create_session(
&self,
name: impl Into<String>,
model: ModelInfo,
) -> Result<Session, RuntimeError> {
self.create_session_with_config(name, model, AgentConfig::default())
}
/// Creates a new session wrapping a freshly spawned agent with explicit config.
///
/// Convenience wrapper around [`create_session_full`](Self::create_session_full) that
/// passes `None` for `project_id`.
pub fn create_session_with_config(
&self,
name: impl Into<String>,
model: ModelInfo,
config: AgentConfig,
) -> Result<Session, RuntimeError> {
self.create_session_full(name, model, config, None)
}
/// Creates a new session wrapping a freshly spawned agent with explicit config and
/// an optional project identifier.
///
/// The `project_id` is threaded into the [`SessionPermissionHandle`] so that
/// permission rules are scoped to the project when a [`PermissionRuleStore`] is
/// attached.
pub fn create_session_full(
&self,
name: impl Into<String>,
model: ModelInfo,
config: AgentConfig,
project_id: Option<String>,
) -> Result<Session, RuntimeError> {
let name = name.into();
let session_id = SessionId::new();
let metadata = SessionMetadata::new(session_id.clone(), &name, &model.id);
let (event_tx, _) = broadcast::channel(512);
let rule_store = crate::session::RuleStore::new();
let pending_permissions = PendingPermissionStore::new();
let session_handle =
self.handle
.with_tool_authorizer(Arc::new(SessionToolAuthorizer::new(
self.handle.execution.tool_authorizer.clone(),
event_tx.clone(),
pending_permissions.clone(),
rule_store.clone(),
)));
let provider = self
.provider_registry
.read()
.expect("provider registry poisoned")
.get_provider(Some(&model.provider))
.ok_or_else(|| RuntimeError::ProviderNotFound(Some(model.provider.clone())))?;
let agent = Agent::new(
session_handle,
model.id.clone(),
name.clone(),
config,
provider,
AgentSpawnOptions::default(),
)?;
let mut session = Session::new_with_parts(
session_id.clone(),
metadata,
agent,
event_tx,
rule_store,
pending_permissions,
project_id,
);
// Emit the initial SessionStarted event.
let started = SessionEvent::SessionStarted { session_id };
// Subscribe briefly just to ensure the event is broadcast.
let _rx = session.subscribe();
// Use the internal emit path via a helper on Session.
session.emit_started(started);
Ok(session)
}
/// Resumes a previously persisted agent and wraps it in a session.
///
/// Convenience wrapper around [`resume_session_with_project`](Self::resume_session_with_project)
/// that passes `None` for `project_id`.
pub fn resume_session(&self, agent_id: &str) -> Result<Session, RuntimeError> {
self.resume_session_with_project(agent_id, None)
}
/// Resumes a previously persisted agent, wraps it in a session, and associates
/// the session with an optional project identifier.
///
/// The `project_id` is threaded into the [`SessionPermissionHandle`] so that
/// permission rules are scoped to the project when a [`PermissionRuleStore`] is
/// attached.
pub fn resume_session_with_project(
&self,
agent_id: &str,
project_id: Option<String>,
) -> Result<Session, RuntimeError> {
let session_id = SessionId::new();
let (event_tx, _) = broadcast::channel(512);
let rule_store = crate::session::RuleStore::new();
let pending_permissions = PendingPermissionStore::new();
let session_handle =
self.handle
.with_tool_authorizer(Arc::new(SessionToolAuthorizer::new(
self.handle.execution.tool_authorizer.clone(),
event_tx.clone(),
pending_permissions.clone(),
rule_store.clone(),
)));
let Some(state) = self.handle.store().load_agent(agent_id)? else {
return Err(RuntimeError::Store(format!(
"No persisted agent with id '{agent_id}'"
)));
};
let provider = self
.provider_registry
.read()
.expect("provider registry poisoned")
.get_provider(Some(&state.record.provider_id))
.ok_or_else(|| {
RuntimeError::ProviderNotFound(Some(state.record.provider_id.clone()))
})?;
let agent = Agent::from_loaded(session_handle, state, provider)?;
let metadata = SessionMetadata::new(session_id.clone(), agent.name(), agent.model());
let session = Session::new_with_parts(
session_id,
metadata,
agent,
event_tx,
rule_store,
pending_permissions,
project_id,
);
Ok(session)
}
}

727
vendor/mentra/src/runtime/builder.rs vendored Normal file
View File

@@ -0,0 +1,727 @@
use std::{any::Any, path::Path, sync::Arc};
use crate::{
compaction::CompactionEngine,
mcp::{McpManager, McpServerConfig, McpSseServerConfig},
provider::{Provider, ProviderRegistry, ResponsesTransport},
runtime::{
RuntimeExecutor, RuntimeHandle, RuntimeHook, RuntimeHooks, RuntimePolicy, RuntimeStore,
control::PreExecutionHook, error::RuntimeError, skill::SkillLoadError,
},
tool::{ExecutableTool, FileToolProfile, ToolAuthorizer},
};
use mentra_provider::BuiltinProvider;
use super::skill::SkillLoader;
use super::{McpServerSummary, Runtime};
/// An MCP server to connect to during build, and how to reach it.
///
/// This is internal so that the two public registration methods keep taking
/// their own configuration types: [`McpServerConfig`] stays the stdio
/// configuration and callers never gain a transport field to fill in.
enum McpRegistration {
Stdio(Box<McpServerConfig>),
Sse(Box<McpSseServerConfig>),
}
impl McpRegistration {
/// The configured server name, used for diagnostics.
fn name(&self) -> &str {
match self {
Self::Stdio(config) => &config.name,
Self::Sse(config) => &config.name,
}
}
}
/// Builder for constructing a [`Runtime`] with providers, tools, and policies.
pub struct RuntimeBuilder {
handle: RuntimeHandle,
provider_registry: ProviderRegistry,
mcp_configs: Vec<McpRegistration>,
}
impl RuntimeBuilder {
/// Creates a builder with Mentra's builtin tools enabled.
pub fn new(runtime_intrinsics_enabled: bool) -> Self {
Self {
handle: RuntimeHandle::new(runtime_intrinsics_enabled),
provider_registry: ProviderRegistry::default(),
mcp_configs: Vec::new(),
}
}
/// Registers a custom tool.
pub fn with_tool<T>(self, tool: T) -> Self
where
T: ExecutableTool + 'static,
{
self.handle.register_tool(tool);
self
}
/// Reconfigures the eagerly registered builtin file-tool surface.
///
/// The default is [`FileToolProfile::Batched`], preserving the historical
/// `files` tool. This method also works with [`Runtime::empty_builder`] to
/// opt into only the selected file tools.
pub fn with_file_tools(self, profile: FileToolProfile) -> Self {
self.handle.configure_file_tools(profile);
self
}
/// Registers typed application state that tools can retrieve from their context.
pub fn with_context(self, context: Arc<dyn Any + Send + Sync>) -> Self {
self.handle.register_app_context(context);
self
}
/// Registers a runtime intrinsic tool.
pub fn with_intrinsic<T>(self, tool: T) -> Self
where
T: ExecutableTool + 'static,
{
self.with_tool(tool)
}
/// Replaces the runtime store implementation.
///
/// The default store is not opened on the way here. Recovery runs at build
/// time against whichever store the builder ends with, so a caller that
/// supplies its own never has the machine-wide default database created
/// underneath it.
pub fn with_store(self, store: impl RuntimeStore + 'static) -> Self {
Self {
handle: self.handle.rebind_store(std::sync::Arc::new(store)),
provider_registry: self.provider_registry,
mcp_configs: self.mcp_configs,
}
}
/// Replaces the command executor used by builtin tools.
pub fn with_executor<E>(self, executor: E) -> Self
where
E: RuntimeExecutor + 'static,
{
Self {
handle: self.handle.with_executor(Arc::new(executor)),
provider_registry: self.provider_registry,
mcp_configs: self.mcp_configs,
}
}
/// Replaces the compaction engine used for transcript summarization.
pub fn with_compaction_engine<C>(self, engine: C) -> Self
where
C: CompactionEngine + 'static,
{
Self {
handle: self.handle.with_compaction_engine(Arc::new(engine)),
provider_registry: self.provider_registry,
mcp_configs: self.mcp_configs,
}
}
/// Sets the runtime policy used to authorize file and process access.
pub fn with_policy(self, policy: RuntimePolicy) -> Self {
Self {
handle: self.handle.with_policy(policy),
provider_registry: self.provider_registry,
mcp_configs: self.mcp_configs,
}
}
/// Installs a pre-tool authorization service for runtime tool calls.
pub fn with_tool_authorizer<A>(self, tool_authorizer: A) -> Self
where
A: ToolAuthorizer + 'static,
{
Self {
handle: self.handle.with_tool_authorizer(Arc::new(tool_authorizer)),
provider_registry: self.provider_registry,
mcp_configs: self.mcp_configs,
}
}
/// Sets the persisted runtime identifier used to group resumable agents.
pub fn with_runtime_identifier(self, runtime_identifier: impl Into<Arc<str>>) -> Self {
Self {
handle: self.handle.with_runtime_identifier(runtime_identifier),
provider_registry: self.provider_registry,
mcp_configs: self.mcp_configs,
}
}
/// Appends a single runtime hook, keeping any already registered.
pub fn with_hook<H>(self, hook: H) -> Self
where
H: RuntimeHook + 'static,
{
let hooks = self.handle.hooks().clone().with_hook(hook);
Self {
handle: self.handle.with_hooks(hooks),
provider_registry: self.provider_registry,
mcp_configs: self.mcp_configs,
}
}
/// Appends a single pre-execution hook, keeping any already registered.
pub fn with_pre_hook<H>(self, hook: H) -> Self
where
H: PreExecutionHook + 'static,
{
let pre_hooks = self.handle.pre_hooks().clone().with_hook(hook);
Self {
handle: self.handle.with_pre_hooks(pre_hooks),
provider_registry: self.provider_registry,
mcp_configs: self.mcp_configs,
}
}
/// Replaces hooks with the provided collection.
pub fn with_hooks<I>(self, hooks: I) -> Self
where
I: IntoIterator<Item = Arc<dyn RuntimeHook>>,
{
Self {
handle: self.handle.with_hooks(RuntimeHooks::new().extend(hooks)),
provider_registry: self.provider_registry,
mcp_configs: self.mcp_configs,
}
}
/// Registers a skills directory and enables the builtin `load_skill` tool.
pub fn with_skills_dir(self, path: impl AsRef<Path>) -> Result<Self, SkillLoadError> {
self.handle
.register_skill_loader(SkillLoader::from_dir(path)?);
Ok(self)
}
/// Registers an MCP server, reached over stdio, to connect to during build.
pub fn with_mcp_server(mut self, config: McpServerConfig) -> Self {
self.mcp_configs
.push(McpRegistration::Stdio(Box::new(config)));
self
}
/// Registers multiple stdio MCP servers to connect to during build.
pub fn with_mcp_servers(mut self, configs: impl IntoIterator<Item = McpServerConfig>) -> Self {
self.mcp_configs.extend(
configs
.into_iter()
.map(|config| McpRegistration::Stdio(Box::new(config))),
);
self
}
/// Registers an MCP server reached over the legacy HTTP+SSE transport.
///
/// Every tool the server advertises is bridged into the runtime under a
/// namespaced name. Use [`McpSseClient`](crate::mcp::McpSseClient) directly
/// when a host needs to apply its own allowlist before anything is
/// registered.
///
/// ```rust,no_run
/// use mentra::{BuiltinProvider, McpSseServerConfig, Runtime};
/// # async fn demo() -> Result<(), Box<dyn std::error::Error>> {
/// let runtime = Runtime::builder()
/// .with_provider(BuiltinProvider::Anthropic, "sk-...")
/// .with_mcp_sse_server(
/// McpSseServerConfig::new("observability", "https://mcp.example.com/sse")
/// .with_bearer_token("<token>"),
/// )
/// .build_async()
/// .await?;
/// # let _ = runtime;
/// # Ok(())
/// # }
/// ```
pub fn with_mcp_sse_server(mut self, config: McpSseServerConfig) -> Self {
self.mcp_configs
.push(McpRegistration::Sse(Box::new(config)));
self
}
/// Registers multiple HTTP+SSE MCP servers to connect to during build.
pub fn with_mcp_sse_servers(
mut self,
configs: impl IntoIterator<Item = McpSseServerConfig>,
) -> Self {
self.mcp_configs.extend(
configs
.into_iter()
.map(|config| McpRegistration::Sse(Box::new(config))),
);
self
}
/// Registers a builtin provider when an API key is present.
pub fn with_optional_provider(
mut self,
id: BuiltinProvider,
api_key: Option<impl Into<String>>,
) -> Self {
if let Some(api_key) = api_key {
let _ = self
.provider_registry
.register_builtin_provider(id, api_key.into());
}
self
}
/// Registers a builtin provider from an API key.
pub fn with_provider(mut self, id: BuiltinProvider, api_key: impl Into<String>) -> Self {
let _ = self
.provider_registry
.register_builtin_provider(id, api_key);
self
}
/// Chooses the transport this runtime's Responses-family requests stream
/// over.
///
/// Runtime scope, because a transport is a property of the connection to a
/// provider rather than of one run: an HTTP+SSE turn and a websocket turn
/// against the same endpoint are two different conversations with it, and a
/// per-run switch would mean the runtime holding two live opinions about
/// one socket. Left unset, each request's own
/// [`ResponsesRequestOptions::transport`](crate::provider::ResponsesRequestOptions)
/// stands — which is HTTP+SSE unless a host set otherwise, exactly as
/// before this method existed.
///
/// A provider that does not serve websockets — anthropic and gemini, whose
/// definitions report `supports_websockets: false` — refuses an explicit
/// [`ResponsesTransport::WebSocket`](crate::provider::ResponsesTransport)
/// at its first request, naming itself, rather than answering over
/// HTTP+SSE. Selecting a transport is an explicit act, and a silent
/// fallback would hand back a stream nobody asked for.
///
/// ```rust,no_run
/// use mentra::{BuiltinProvider, Runtime};
/// use mentra::provider::ResponsesTransport;
/// # fn demo() -> Result<(), Box<dyn std::error::Error>> {
/// let runtime = Runtime::builder()
/// .with_provider(BuiltinProvider::OpenAI, "sk-...")
/// .with_responses_transport(ResponsesTransport::WebSocket)
/// .build()?;
/// # let _ = runtime;
/// # Ok(())
/// # }
/// ```
pub fn with_responses_transport(mut self, transport: ResponsesTransport) -> Self {
self.provider_registry.set_responses_transport(transport);
self
}
/// Registers the local Ollama provider using its default OpenAI-compatible endpoint.
pub fn with_ollama(mut self) -> Self {
self.provider_registry.register_ollama();
self
}
/// Registers the local LM Studio provider using its default OpenAI-compatible endpoint.
pub fn with_lmstudio(mut self) -> Self {
self.provider_registry.register_lmstudio();
self
}
/// Registers a custom runtime provider implementation.
///
/// This is the supported seam for test-time provider injection when you
/// want to script model responses without live API calls.
///
/// ```rust,no_run
/// use async_trait::async_trait;
/// use mentra::{BuiltinProvider, ModelInfo, ProviderDescriptor, Runtime};
/// use mentra::error::{ProviderError, RuntimeError};
/// use mentra::provider::{Provider, ProviderEventStream, Request};
/// use tokio::sync::mpsc;
///
/// struct TestProvider;
///
/// #[async_trait]
/// impl Provider for TestProvider {
/// fn descriptor(&self) -> ProviderDescriptor {
/// ProviderDescriptor::new(BuiltinProvider::Anthropic)
/// }
///
/// async fn list_models(&self) -> Result<Vec<ModelInfo>, ProviderError> {
/// Ok(vec![ModelInfo::new("test-model", BuiltinProvider::Anthropic)])
/// }
///
/// async fn stream(
/// &self,
/// _request: Request<'_>,
/// ) -> Result<ProviderEventStream, ProviderError> {
/// let (_tx, rx) = mpsc::unbounded_channel();
/// Ok(rx)
/// }
/// }
///
/// let runtime = Runtime::empty_builder()
/// .with_provider_instance(TestProvider)
/// .build()?;
/// # Ok::<(), RuntimeError>(())
/// ```
pub fn with_provider_instance<P>(mut self, provider: P) -> Self
where
P: Provider + 'static,
{
self.provider_registry.register_provider_instance(provider);
self
}
/// Registers a provider-core instance built from `mentra::provider_core`.
///
/// Use this when you want Mentra's runtime with a customized provider
/// definition, such as a custom OpenAI-compatible or Anthropic-compatible
/// base URL.
pub fn with_registered_provider<P>(mut self, provider: P) -> Self
where
P: mentra_provider::Provider + 'static,
{
self.provider_registry
.register_registered_provider(provider);
self
}
/// Builds the runtime, connects to MCP servers, and validates providers.
///
/// This is an async method because MCP server connections require spawning
/// processes and performing the initialize handshake.
pub async fn build_async(self) -> Result<Runtime, RuntimeError> {
if self.provider_registry.is_empty() {
return Err(RuntimeError::ProviderNotFound(None));
}
// Connect to MCP servers and register their tools.
let mut outcomes = Vec::new();
if !self.mcp_configs.is_empty() {
let mut manager = McpManager::new();
for config in &self.mcp_configs {
let connected = match config {
McpRegistration::Stdio(config) => manager
.connect(config)
.await
.map_err(|error| error.to_string()),
McpRegistration::Sse(config) => manager
.connect_sse(config)
.await
.map_err(|error| error.to_string()),
};
match connected {
Ok(bridged_tools) => {
let tools = bridged_tools.len();
for tool in bridged_tools {
self.handle.register_tool(tool);
}
outcomes.push(McpServerSummary {
name: config.name().to_string(),
tools,
error: None,
});
}
Err(error) => {
// Degraded mode: one unreachable server must not sink a
// session. Recorded rather than only printed, so a host
// can say which servers are live instead of a user
// wondering why a tool is missing.
eprintln!(
"Warning: MCP server '{}' failed to connect: {error}",
config.name()
);
outcomes.push(McpServerSummary {
name: config.name().to_string(),
tools: 0,
error: Some(error),
});
}
}
}
// Store the manager in the app context for later use.
self.handle
.register_app_context(Arc::new(tokio::sync::Mutex::new(manager)));
}
let provider_registry = Arc::new(std::sync::RwLock::new(self.provider_registry));
let handle = self
.handle
.with_provider_registry(provider_registry.clone());
handle.prepare_recovery();
Ok(Runtime {
handle,
provider_registry,
mcp_servers: outcomes,
})
}
/// Builds the runtime synchronously.
///
/// Connecting to an MCP server means spawning a process and completing a
/// handshake, which cannot happen here — so registering one and then
/// calling this is refused rather than silently honored halfway. Use
/// [`build_async`](Self::build_async) when MCP servers are configured.
pub fn build(self) -> Result<Runtime, RuntimeError> {
if self.provider_registry.is_empty() {
return Err(RuntimeError::ProviderNotFound(None));
}
if !self.mcp_configs.is_empty() {
let names: Vec<&str> = self.mcp_configs.iter().map(McpRegistration::name).collect();
return Err(RuntimeError::OperationDenied(format!(
"MCP servers are registered ({}) but `build` cannot connect them; \
use `build_async`",
names.join(", ")
)));
}
let provider_registry = Arc::new(std::sync::RwLock::new(self.provider_registry));
let handle = self
.handle
.with_provider_registry(provider_registry.clone());
handle.prepare_recovery();
Ok(Runtime {
handle,
provider_registry,
mcp_servers: Vec::new(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::VolatileRuntimeStore;
use crate::runtime::control::{HookDecision, PreExecutionContext};
use crate::runtime::store::default_store_paths_on_this_thread;
use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering};
/// The least a builder will accept: a provider must exist before any
/// other check runs.
struct StubProvider;
#[async_trait]
impl crate::provider::Provider for StubProvider {
fn descriptor(&self) -> crate::provider::ProviderDescriptor {
crate::provider::ProviderDescriptor::new(BuiltinProvider::OpenAI)
}
async fn list_models(
&self,
) -> Result<Vec<crate::ModelInfo>, crate::provider::ProviderError> {
Ok(Vec::new())
}
async fn stream(
&self,
_request: crate::provider::Request<'_>,
) -> Result<crate::provider::ProviderEventStream, crate::provider::ProviderError> {
unreachable!("no turn is run in these tests")
}
}
/// Counts how many times it was consulted, so a hook that was silently
/// dropped during registration shows up as a count that never moves.
struct Counting(Arc<AtomicUsize>);
#[async_trait]
impl PreExecutionHook for Counting {
async fn pre_tool_execution(
&self,
_context: &PreExecutionContext,
) -> Result<HookDecision, RuntimeError> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(HookDecision::Allow)
}
}
#[tokio::test]
async fn registering_a_second_pre_hook_keeps_the_first() {
let first = Arc::new(AtomicUsize::new(0));
let second = Arc::new(AtomicUsize::new(0));
let builder = RuntimeBuilder::new(false)
.with_pre_hook(Counting(Arc::clone(&first)))
.with_pre_hook(Counting(Arc::clone(&second)));
let context = PreExecutionContext {
agent_id: "a1".to_string(),
tool_name: "shell".to_string(),
tool_call_id: "tc-1".to_string(),
input_json: "{}".to_string(),
working_directory: std::path::PathBuf::from("/repo"),
};
builder
.handle
.pre_hooks()
.run(&context)
.await
.expect("hooks run");
// The first registration used to be discarded by the second, which is
// a security-relevant silent failure for a veto seam.
assert_eq!(
first.load(Ordering::SeqCst),
1,
"the first hook must still run"
);
assert_eq!(second.load(Ordering::SeqCst), 1);
}
#[test]
fn build_refuses_to_discard_registered_mcp_servers() {
let error = RuntimeBuilder::new(false)
.with_provider_instance(StubProvider)
.with_mcp_server(McpServerConfig {
name: "github".to_string(),
command: "npx".to_string(),
args: Vec::new(),
env: Default::default(),
cwd: None,
})
.build()
.err()
.expect("a sync build cannot connect a server, so it must say so");
// The old behavior was to build cleanly and drop the server, which a
// caller only discovered when a tool it had configured was missing.
let message = error.to_string();
assert!(
message.contains("github") && message.contains("build_async"),
"the refusal must name the server and the way forward: {message}"
);
}
#[tokio::test]
async fn a_runtime_with_no_mcp_servers_reports_none() {
let runtime = RuntimeBuilder::new(false)
.with_provider_instance(StubProvider)
.build_async()
.await
.expect("builds");
assert!(runtime.mcp_servers().is_empty());
}
/// A caller that supplies its own store has opted out of the machine-wide
/// default. Constructing the handle used to open it anyway — creating
/// `runtime.sqlite` on a pristine machine — before `with_store` replaced
/// the store it had just prepared.
#[test]
fn a_build_with_a_caller_store_leaves_the_default_database_alone() {
let store = VolatileRuntimeStore::new();
let probe = store.clone();
let runtime = RuntimeBuilder::new(false)
.with_store(store)
.with_provider_instance(StubProvider)
.build()
.expect("builds");
let default_paths = default_store_paths_on_this_thread();
assert!(
!default_paths.is_empty(),
"the handle still constructs a default store, so this test has something to check"
);
for path in default_paths {
assert!(
!path.exists(),
"a discarded default store must never be opened: {}",
path.display()
);
}
assert_eq!(
probe.recovery_preparations(),
1,
"recovery must run once, on the store the caller kept"
);
drop(runtime);
}
/// The async build boundary carries the same guarantee as the sync one.
#[tokio::test]
async fn an_async_build_prepares_recovery_once_on_the_caller_store() {
let store = VolatileRuntimeStore::new();
let probe = store.clone();
let runtime = RuntimeBuilder::new(false)
.with_store(store)
.with_provider_instance(StubProvider)
.build_async()
.await
.expect("builds");
assert_eq!(probe.recovery_preparations(), 1);
drop(runtime);
}
/// Deferring recovery must not skip it: a build that keeps the default
/// store still reconciles interrupted state, which for SQLite means the
/// database is opened and its schema created.
#[test]
fn a_default_build_still_prepares_recovery() {
let runtime = RuntimeBuilder::new(false)
.with_provider_instance(StubProvider)
.build()
.expect("builds");
let default_paths = default_store_paths_on_this_thread();
assert!(!default_paths.is_empty());
for path in default_paths {
assert!(
path.exists(),
"the store a runtime actually kept must be prepared: {}",
path.display()
);
}
drop(runtime);
}
/// Recovery belongs to the build boundary, not to assembly: until `build`
/// settles which store survives, nothing may be prepared.
#[test]
fn assembling_a_builder_prepares_nothing() {
let store = VolatileRuntimeStore::new();
let probe = store.clone();
let builder = RuntimeBuilder::new(false)
.with_store(store)
.with_provider_instance(StubProvider);
assert_eq!(
probe.recovery_preparations(),
0,
"a store is only prepared once the builder is done being reconfigured"
);
drop(builder);
}
/// A store that is swapped out again must never be prepared: `with_store`
/// used to prepare eagerly, which made every intermediate store pay for a
/// choice the caller went on to revise.
#[test]
fn a_replaced_store_is_never_prepared() {
let discarded = VolatileRuntimeStore::new();
let discarded_probe = discarded.clone();
let kept = VolatileRuntimeStore::new();
let kept_probe = kept.clone();
let runtime = RuntimeBuilder::new(false)
.with_store(discarded)
.with_store(kept)
.with_provider_instance(StubProvider)
.build()
.expect("builds");
assert_eq!(
discarded_probe.recovery_preparations(),
0,
"a store the builder threw away must not have been opened"
);
assert_eq!(kept_probe.recovery_preparations(), 1);
drop(runtime);
}
}

Some files were not shown because too many files have changed in this diff Show More