pub mod agent_loop;
pub mod automation_tools;
pub mod chat;
pub mod device_tools;
pub mod executor;
pub mod media_tools;
pub mod memory;
pub mod net_tools;
pub mod policy;
pub mod prompt;
pub mod register;
pub mod studio_tools;
pub mod substrate;
pub mod vision_tools;
use std::path::PathBuf;
use std::sync::Arc;
use async_trait::async_trait;
use car_engine::{Runtime, ToolEntry, ToolExecutor, ToolSchema};
use car_eventlog::EventLog;
use car_inference::InferenceEngine;
use car_policy::permission::PermissionTier;
use serde_json::Value;
use memory::MemoryTools;
pub use agent_loop::{
run_assistant_goal_loop, run_assistant_loop, run_assistant_loop_cancellable, ApprovalDecision,
ApprovalGate, AssistantConfig, AssistantEvent, AssistantOutcome, GoalLoopResult,
};
pub use chat::{AssistantService, ChatGoal};
pub use device_tools::DeviceProvider;
pub use executor::GeneralExecutor;
pub use net_tools::NetTools;
pub use substrate::{bind_default_substrate, BoundEnvironment};
pub struct AssistantRuntime {
pub runtime: Runtime,
pub tools: Vec<Value>,
pub description: String,
pub sandboxed: bool,
pub gated_tools: Vec<String>,
pub fallback_notice: Option<String>,
}
struct ChainedDelegate(Vec<Arc<dyn ToolExecutor>>);
#[async_trait]
impl ToolExecutor for ChainedDelegate {
async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
for ex in &self.0 {
match ex.execute(tool, params).await {
Err(e) if e.starts_with("unknown tool") => continue,
other => return other,
}
}
Err(format!("unknown tool: '{tool}'"))
}
}
fn schema_from_def(def: &Value) -> ToolSchema {
ToolSchema {
name: def["name"].as_str().unwrap_or_default().to_string(),
description: def["description"].as_str().unwrap_or_default().to_string(),
parameters: def["parameters"].clone(),
returns: None,
idempotent: false,
cache_ttl_secs: None,
rate_limit: None,
}
}
fn tier_gated_tool_names(tools: &[Value], standing: PermissionTier) -> Vec<String> {
tools
.iter()
.filter_map(|def| {
let name = def.get("name").and_then(|v| v.as_str())?;
let tier = PermissionTier::from_str_opt(def.get("tier").and_then(|v| v.as_str())?)?;
(tier > standing).then(|| name.to_string())
})
.collect()
}
fn default_memory_path() -> PathBuf {
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
home.join(".car").join("memory").join("assistant.json")
}
pub async fn build_assistant_runtime(
engine: Arc<InferenceEngine>,
env: BoundEnvironment,
eventlog: Option<PathBuf>,
device_provider: Option<Arc<dyn DeviceProvider>>,
) -> AssistantRuntime {
let mut gated_tools: Vec<String> = if matches!(env.tier, PermissionTier::ReadOnly) {
["write_file", "edit_file", "shell"]
.iter()
.map(|s| s.to_string())
.collect()
} else {
Vec::new()
};
let clamp = !env.sandboxed;
let description = env.description.clone();
let sandboxed = env.sandboxed;
let fallback_notice = env.fallback_notice.clone();
let net: Arc<dyn ToolExecutor> = Arc::new(NetTools::new());
let mem: Arc<dyn ToolExecutor> = Arc::new(MemoryTools::open(default_memory_path()));
let media = Arc::new(media_tools::MediaTools::new(
engine.clone(),
env.root.clone(),
));
let studio = Arc::new(studio_tools::StudioMediaTools::new(env.root.clone()));
let vision = Arc::new(vision_tools::VisionTools::new(env.root.clone()));
let automation = Arc::new(automation_tools::AutomationTools::new());
let device_tools = device_provider
.map(device_tools::DeviceTools::new)
.map(Arc::new);
let mut delegate_defs = net_tools::net_tool_defs();
delegate_defs.extend(MemoryTools::tool_defs());
delegate_defs.extend(media.tool_defs());
delegate_defs.extend(studio.tool_defs());
delegate_defs.extend(vision.tool_defs());
delegate_defs.extend(automation.tool_defs());
if device_tools.is_some() {
delegate_defs.extend(device_tools::DeviceTools::tool_defs());
}
let media: Arc<dyn ToolExecutor> = media;
let studio: Arc<dyn ToolExecutor> = studio;
let vision: Arc<dyn ToolExecutor> = vision;
let automation: Arc<dyn ToolExecutor> = automation;
let mut delegates: Vec<Arc<dyn ToolExecutor>> =
vec![net, mem, media, studio, vision, automation];
if let Some(device_tools) = device_tools {
delegates.push(device_tools);
}
let delegate: Arc<dyn ToolExecutor> = Arc::new(ChainedDelegate(delegates));
let executor = GeneralExecutor::new(env.substrate.clone(), env.root.clone(), clamp)
.with_delegate(delegate, delegate_defs);
let tools = executor.all_tool_defs();
gated_tools.extend(tier_gated_tool_names(&tools, env.tier));
let executor: Arc<dyn ToolExecutor> = Arc::new(executor);
let mut runtime = Runtime::new()
.with_inference(engine)
.with_executor(executor)
.with_substrate(env.substrate.clone());
if let Some(path) = eventlog {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
runtime = runtime.with_event_log(EventLog::with_journal(path));
}
if let Err(e) = runtime
.install_information_flow_gate(env.root.join(".car"))
.await
{
tracing::warn!(
error = %e,
"assistant could not load project tool labels; falling back to built-in information-flow labels"
);
runtime
.register_admission_gate(Arc::new(
car_engine::InformationFlowGate::with_builtin_labels(),
))
.await;
}
runtime.register_agent_basics().await;
let builtin_names: std::collections::HashSet<String> = car_engine::agent_basic_entries()
.into_iter()
.map(|e| e.schema.name)
.collect();
for def in &tools {
let name = def["name"].as_str().unwrap_or_default();
if name.is_empty() || builtin_names.contains(name) {
continue;
}
runtime
.register_tool_entry(ToolEntry::new(schema_from_def(def)).with_side_effects(true))
.await;
}
AssistantRuntime {
runtime,
tools,
description,
sandboxed,
gated_tools,
fallback_notice,
}
}
#[cfg(test)]
mod tests {
use super::*;
use car_eventlog::EventKind;
use car_ir::{Action, ActionProposal, ActionStatus, ActionType, FailureBehavior};
use std::collections::HashMap;
use serde_json::json;
struct StaticDeviceProvider(Value);
#[async_trait]
impl DeviceProvider for StaticDeviceProvider {
async fn devices(&self) -> Result<Value, String> {
Ok(self.0.clone())
}
async fn notify_device(
&self,
device_id: Option<String>,
title: String,
body: String,
) -> Result<Value, String> {
Ok(json!({
"device_id": device_id,
"title": title,
"body": body
}))
}
}
fn test_engine(root: &std::path::Path) -> Arc<InferenceEngine> {
let mut cfg = car_inference::InferenceConfig::default();
cfg.models_dir = root.join("models");
Arc::new(InferenceEngine::new(cfg))
}
fn test_env(root: &std::path::Path) -> BoundEnvironment {
BoundEnvironment {
substrate: Arc::new(car_engine::LocalSubstrate::new()),
root: root.to_path_buf(),
tier: PermissionTier::ReadOnly,
description: "test local host".to_string(),
sandboxed: false,
fallback_notice: None,
}
}
fn test_action(tool: &str) -> Action {
Action {
id: uuid::Uuid::new_v4().simple().to_string()[..12].to_string(),
action_type: ActionType::ToolCall,
tool: Some(tool.to_string()),
parameters: HashMap::new(),
preconditions: vec![],
expected_effects: HashMap::new(),
state_dependencies: vec![],
read_set: vec![],
write_set: vec![],
assumptions: vec![],
invocation_mode: Default::default(),
idempotent: false,
max_retries: 3,
failure_behavior: FailureBehavior::Abort,
timeout_ms: None,
metadata: HashMap::new(),
}
}
fn test_proposal(actions: Vec<Action>) -> ActionProposal {
ActionProposal {
id: "assistant-test-proposal".to_string(),
source: "assistant-test".to_string(),
actions,
timestamp: chrono::Utc::now(),
context: HashMap::new(),
}
}
#[test]
fn tier_gating_derives_from_self_declared_tier() {
let tools = vec![
json!({"name": "read_file"}), json!({"name": "generate_image", "tier": "sandbox_edit"}),
json!({"name": "web_search", "tier": "full_access"}),
json!({"name": "run_applescript", "tier": "full_access"}),
];
let g = tier_gated_tool_names(&tools, PermissionTier::ReadOnly);
assert!(g.contains(&"generate_image".to_string()));
assert!(g.contains(&"web_search".to_string()));
assert!(g.contains(&"run_applescript".to_string()));
assert!(!g.contains(&"read_file".to_string()));
let g = tier_gated_tool_names(&tools, PermissionTier::SandboxEdit);
assert_eq!(
g,
vec!["web_search".to_string(), "run_applescript".to_string()]
);
assert!(tier_gated_tool_names(&tools, PermissionTier::FullAccess).is_empty());
}
#[tokio::test]
async fn assistant_runtime_installs_information_flow_gate_by_default() {
let dir = tempfile::tempdir().unwrap();
let engine = test_engine(dir.path());
let env = test_env(dir.path());
let rt = build_assistant_runtime(engine, env, None, None).await;
assert_eq!(rt.runtime.admission_gate_count().await, 1);
}
#[tokio::test]
async fn assistant_runtime_gates_external_and_persistent_sinks_by_default() {
let dir = tempfile::tempdir().unwrap();
let rt = build_assistant_runtime(test_engine(dir.path()), test_env(dir.path()), None, None)
.await;
assert!(rt.gated_tools.contains(&"http_request".to_string()));
assert!(rt.gated_tools.contains(&"web_search".to_string()));
assert!(rt.gated_tools.contains(&"remember".to_string()));
}
#[tokio::test]
async fn assistant_runtime_can_see_linked_devices_when_provider_supplied() {
let dir = tempfile::tempdir().unwrap();
let provider: Arc<dyn DeviceProvider> = Arc::new(StaticDeviceProvider(json!([
{
"name": "Mia's iPhone",
"platform": "ios",
"status": "online",
"capabilities": ["assistant.chat", "assistant.approvals"]
}
])));
let rt = build_assistant_runtime(
test_engine(dir.path()),
test_env(dir.path()),
None,
Some(provider),
)
.await;
assert!(rt
.tools
.iter()
.any(|def| def["name"].as_str() == Some("linked_devices")));
assert!(rt
.tools
.iter()
.any(|def| def["name"].as_str() == Some("notify_linked_device")));
let result = rt
.runtime
.execute(&test_proposal(vec![test_action("linked_devices")]))
.await;
assert_eq!(result.results[0].status, ActionStatus::Succeeded);
assert_eq!(
result.results[0].output.as_ref().unwrap()[0]["platform"],
"ios"
);
}
#[tokio::test]
async fn malformed_assistant_tool_labels_still_install_builtin_flow_gate() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".car")).unwrap();
std::fs::write(dir.path().join(".car/tool-labels.json"), "{not json").unwrap();
let engine = test_engine(dir.path());
let env = test_env(dir.path());
let rt = build_assistant_runtime(engine, env, None, None).await;
assert_eq!(rt.runtime.admission_gate_count().await, 1);
}
#[tokio::test]
async fn assistant_runtime_rejects_confidential_data_to_web_search() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".car")).unwrap();
std::fs::write(
dir.path().join(".car/tool-labels.json"),
r#"{"labels":{"read_file":{"capability":"fs_read","confidentiality":"secret"}}}"#,
)
.unwrap();
let rt = build_assistant_runtime(test_engine(dir.path()), test_env(dir.path()), None, None)
.await;
let mut read = test_action("read_file");
read.expected_effects = [("file_data".to_string(), json!(true))].into();
let mut search = test_action("web_search");
search.state_dependencies = vec!["file_data".to_string()];
let result = rt.runtime.execute(&test_proposal(vec![read, search])).await;
assert!(result
.results
.iter()
.all(|r| r.status == ActionStatus::Rejected));
let log = rt.runtime.log.lock().await;
assert!(log.events().iter().any(|e| {
e.kind == EventKind::AdmissionGateDecision
&& e.data.get("gate").and_then(|v| v.as_str()) == Some("information_flow")
&& e.data.get("decision").and_then(|v| v.as_str()) == Some("reject")
}));
}
#[tokio::test]
async fn assistant_runtime_rejects_recalled_memory_to_web_search_by_default() {
let dir = tempfile::tempdir().unwrap();
let rt = build_assistant_runtime(test_engine(dir.path()), test_env(dir.path()), None, None)
.await;
let mut recall = test_action("recall");
recall.expected_effects = [("memory_context".to_string(), json!(true))].into();
let mut search = test_action("web_search");
search.state_dependencies = vec!["memory_context".to_string()];
let result = rt
.runtime
.execute(&test_proposal(vec![recall, search]))
.await;
assert!(result
.results
.iter()
.all(|r| r.status == ActionStatus::Rejected));
let log = rt.runtime.log.lock().await;
assert!(log.events().iter().any(|e| {
e.kind == EventKind::AdmissionGateDecision
&& e.data.get("gate").and_then(|v| v.as_str()) == Some("information_flow")
&& e.data.get("decision").and_then(|v| v.as_str()) == Some("reject")
}));
}
}