Add operator Mentra memory browser
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
288
crates/metacrate-grid-agent/src/memory_admin.rs
Normal file
288
crates/metacrate-grid-agent/src/memory_admin.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
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
177
vendor/mentra/Cargo.toml
vendored
Normal 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
68
vendor/mentra/Cargo.toml.orig
generated
vendored
Normal 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
21
vendor/mentra/LICENSE
vendored
Normal 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
6
vendor/mentra/METACRATE.md
vendored
Normal 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
811
vendor/mentra/README.md
vendored
Normal 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
186
vendor/mentra/benches/long_session.rs
vendored
Normal 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
648
vendor/mentra/src/agent.rs
vendored
Normal 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
201
vendor/mentra/src/agent/compact.rs
vendored
Normal 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
516
vendor/mentra/src/agent/config.rs
vendored
Normal 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
203
vendor/mentra/src/agent/events.rs
vendored
Normal 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
136
vendor/mentra/src/agent/lifecycle.rs
vendored
Normal 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
269
vendor/mentra/src/agent/pending.rs
vendored
Normal 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
309
vendor/mentra/src/agent/pending_block.rs
vendored
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
229
vendor/mentra/src/agent/round_strategy.rs
vendored
Normal file
229
vendor/mentra/src/agent/round_strategy.rs
vendored
Normal 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
782
vendor/mentra/src/agent/runner.rs
vendored
Normal 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(¬ifications))?;
|
||||
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(¬ification.command),
|
||||
escape_background_field(¬ification.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
114
vendor/mentra/src/agent/snapshot.rs
vendored
Normal 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
217
vendor/mentra/src/agent/steering.rs
vendored
Normal 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
128
vendor/mentra/src/agent/subagent.rs
vendored
Normal 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
143
vendor/mentra/src/agent/task_state.rs
vendored
Normal 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
197
vendor/mentra/src/agent/team.rs
vendored
Normal 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)
|
||||
}
|
||||
319
vendor/mentra/src/agent/terminal_output.rs
vendored
Normal file
319
vendor/mentra/src/agent/terminal_output.rs
vendored
Normal 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
16
vendor/mentra/src/agent/tests.rs
vendored
Normal 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
776
vendor/mentra/src/agent/tests/budgets.rs
vendored
Normal 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
304
vendor/mentra/src/agent/tests/pending.rs
vendored
Normal 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"))
|
||||
);
|
||||
}
|
||||
500
vendor/mentra/src/agent/tests/round_strategy.rs
vendored
Normal file
500
vendor/mentra/src/agent/tests/round_strategy.rs
vendored
Normal 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
999
vendor/mentra/src/agent/tests/runtime.rs
vendored
Normal 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,
|
||||
])
|
||||
}
|
||||
1127
vendor/mentra/src/agent/tests/runtime_compact.rs
vendored
Normal file
1127
vendor/mentra/src/agent/tests/runtime_compact.rs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
483
vendor/mentra/src/agent/tests/runtime_memory.rs
vendored
Normal file
483
vendor/mentra/src/agent/tests/runtime_memory.rs
vendored
Normal 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("")
|
||||
}
|
||||
1148
vendor/mentra/src/agent/tests/runtime_resume.rs
vendored
Normal file
1148
vendor/mentra/src/agent/tests/runtime_resume.rs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
385
vendor/mentra/src/agent/tests/runtime_snapshot.rs
vendored
Normal file
385
vendor/mentra/src/agent/tests/runtime_snapshot.rs
vendored
Normal 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:?}"));
|
||||
}
|
||||
502
vendor/mentra/src/agent/tests/runtime_tasks.rs
vendored
Normal file
502
vendor/mentra/src/agent/tests/runtime_tasks.rs
vendored
Normal 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"
|
||||
)))
|
||||
}
|
||||
5441
vendor/mentra/src/agent/tests/runtime_tools.rs
vendored
Normal file
5441
vendor/mentra/src/agent/tests/runtime_tools.rs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
427
vendor/mentra/src/agent/tests/runtime_volatile_store.rs
vendored
Normal file
427
vendor/mentra/src/agent/tests/runtime_volatile_store.rs
vendored
Normal 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}"))
|
||||
}
|
||||
672
vendor/mentra/src/agent/tests/steering.rs
vendored
Normal file
672
vendor/mentra/src/agent/tests/steering.rs
vendored
Normal 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
494
vendor/mentra/src/agent/tests/support.rs
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
684
vendor/mentra/src/agent/tests/terminal_output.rs
vendored
Normal file
684
vendor/mentra/src/agent/tests/terminal_output.rs
vendored
Normal 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}"
|
||||
);
|
||||
}
|
||||
1487
vendor/mentra/src/agent/tests/tool_output.rs
vendored
Normal file
1487
vendor/mentra/src/agent/tests/tool_output.rs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
566
vendor/mentra/src/agent/tests/tool_paging.rs
vendored
Normal file
566
vendor/mentra/src/agent/tests/tool_paging.rs
vendored
Normal 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 1–5 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 6–10 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 16–30 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 6–10 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
136
vendor/mentra/src/agent/wait.rs
vendored
Normal 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
1
vendor/mentra/src/auth.rs
vendored
Normal file
@@ -0,0 +1 @@
|
||||
pub mod openai;
|
||||
13
vendor/mentra/src/auth/openai.rs
vendored
Normal file
13
vendor/mentra/src/auth/openai.rs
vendored
Normal 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
334
vendor/mentra/src/auth/openai/client.rs
vendored
Normal 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('/'));
|
||||
}
|
||||
}
|
||||
136
vendor/mentra/src/auth/openai/credential.rs
vendored
Normal file
136
vendor/mentra/src/auth/openai/credential.rs
vendored
Normal 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
348
vendor/mentra/src/auth/openai/store.rs
vendored
Normal 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
417
vendor/mentra/src/background.rs
vendored
Normal 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
20
vendor/mentra/src/background/hook.rs
vendored
Normal 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>;
|
||||
}
|
||||
16
vendor/mentra/src/background/observer.rs
vendored
Normal file
16
vendor/mentra/src/background/observer.rs
vendored
Normal 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
27
vendor/mentra/src/background/store.rs
vendored
Normal 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
775
vendor/mentra/src/compaction.rs
vendored
Normal 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
678
vendor/mentra/src/compaction/tests.rs
vendored
Normal 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
143
vendor/mentra/src/default_paths.rs
vendored
Normal 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
84
vendor/mentra/src/lib.rs
vendored
Normal 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
53
vendor/mentra/src/mcp.rs
vendored
Normal 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
200
vendor/mentra/src/mcp/bridge.rs
vendored
Normal 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
354
vendor/mentra/src/mcp/client.rs
vendored
Normal 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
197
vendor/mentra/src/mcp/client/tests.rs
vendored
Normal 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
262
vendor/mentra/src/mcp/manager.rs
vendored
Normal 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
181
vendor/mentra/src/mcp/protocol.rs
vendored
Normal 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>,
|
||||
}
|
||||
572
vendor/mentra/src/mcp/registration_tests.rs
vendored
Normal file
572
vendor/mentra/src/mcp/registration_tests.rs
vendored
Normal 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
14
vendor/mentra/src/mcp/sse.rs
vendored
Normal 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
790
vendor/mentra/src/mcp/sse/client.rs
vendored
Normal 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(¬ification))
|
||||
.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
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
280
vendor/mentra/src/mcp/sse/config.rs
vendored
Normal 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,
|
||||
}
|
||||
}
|
||||
243
vendor/mentra/src/mcp/sse/config/tests.rs
vendored
Normal file
243
vendor/mentra/src/mcp/sse/config/tests.rs
vendored
Normal 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
134
vendor/mentra/src/mcp/sse/endpoint.rs
vendored
Normal 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(),
|
||||
})
|
||||
}
|
||||
337
vendor/mentra/src/mcp/sse/endpoint/tests.rs
vendored
Normal file
337
vendor/mentra/src/mcp/sse/endpoint/tests.rs
vendored
Normal 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
497
vendor/mentra/src/mcp/sse/testing.rs
vendored
Normal 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
207
vendor/mentra/src/mcp/sse/wire.rs
vendored
Normal 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
334
vendor/mentra/src/mcp/sse/wire/tests.rs
vendored
Normal 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
298
vendor/mentra/src/mcp/tests.rs
vendored
Normal 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(¶ms).expect("serialize");
|
||||
assert_eq!(json["protocolVersion"], "2024-11-05");
|
||||
assert_eq!(json["clientInfo"]["name"], "mentra");
|
||||
}
|
||||
16
vendor/mentra/src/memory.rs
vendored
Normal file
16
vendor/mentra/src/memory.rs
vendored
Normal 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
107
vendor/mentra/src/memory/compaction.rs
vendored
Normal 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
667
vendor/mentra/src/memory/engine.rs
vendored
Normal 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
861
vendor/mentra/src/memory/hybrid_store.rs
vendored
Normal 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
10
vendor/mentra/src/memory/journal.rs
vendored
Normal 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
232
vendor/mentra/src/memory/journal/ops.rs
vendored
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
5
vendor/mentra/src/memory/journal/recovery.rs
vendored
Normal file
5
vendor/mentra/src/memory/journal/recovery.rs
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(crate) struct RecoveryOutcome {
|
||||
pub interrupted: bool,
|
||||
pub interrupted_run_id: Option<String>,
|
||||
}
|
||||
28
vendor/mentra/src/memory/journal/snapshot.rs
vendored
Normal file
28
vendor/mentra/src/memory/journal/snapshot.rs
vendored
Normal 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
48
vendor/mentra/src/memory/journal/state.rs
vendored
Normal file
48
vendor/mentra/src/memory/journal/state.rs
vendored
Normal 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),
|
||||
})
|
||||
}
|
||||
16
vendor/mentra/src/memory/journal/store.rs
vendored
Normal file
16
vendor/mentra/src/memory/journal/store.rs
vendored
Normal 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)
|
||||
}
|
||||
}
|
||||
132
vendor/mentra/src/memory/journal/tests.rs
vendored
Normal file
132
vendor/mentra/src/memory/journal/tests.rs
vendored
Normal 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
890
vendor/mentra/src/provider.rs
vendored
Normal 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
638
vendor/mentra/src/runtime.rs
vendored
Normal 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
727
vendor/mentra/src/runtime/builder.rs
vendored
Normal 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
Reference in New Issue
Block a user