car-server-core 0.34.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! CAR Assistant — the flagship, general-purpose agent that ships in the `car`
//! binary and works out of the box (`car do`).
//!
//! Unlike the coder (coding-specific) or the create-car-agent skill (build your
//! own), this is a batteries-included assistant: files + a real shell + web +
//! durable memory, sandbox-first for safety, driven by CAR inference through a
//! full [`Runtime`] (validator, policy, permission tiers, event log). One core
//! backs three entry modes — one-shot, REPL, and the conversational
//! `agent.chat` surface.
//!
//! ## Module map
//! - [`executor`] — [`GeneralExecutor`], the substrate-bound tool executor
//!   (agent_basics + `calculate` + `shell` + network delegate).
//! - [`net_tools`] — host-side `http_request` / `web_search` (bypass the
//!   sandbox's `--network none`).
//! - [`substrate`] — sandbox-first environment selection ([`bind_default_substrate`]).
//! - [`policy`] — the assistant inspector chain (reuses the coder's footgun set).
//! - [`prompt`] — batch vs. conversational system prompts.
//! - [`agent_loop`] — the propose→validate→execute→observe loop.
//!
//! [`Runtime`]: car_engine::Runtime
//! [`GeneralExecutor`]: executor::GeneralExecutor
//! [`bind_default_substrate`]: substrate::bind_default_substrate

pub mod agent_loop;
pub mod automation_tools;
pub mod chat;
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;
pub use executor::GeneralExecutor;
pub use net_tools::NetTools;
pub use substrate::{bind_default_substrate, BoundEnvironment};

/// An assembled assistant runtime: the [`Runtime`] to drive, the model-visible
/// tool list, and the environment metadata for the system prompt.
pub struct AssistantRuntime {
    /// The configured runtime (validator + policy + tiers + event log), whose
    /// tool executor is the [`GeneralExecutor`].
    pub runtime: Runtime,
    /// The model-visible tools (from `GeneralExecutor::all_tool_defs()`).
    pub tools: Vec<Value>,
    /// One-line environment description for the system prompt.
    pub description: String,
    /// Whether execution is isolated in a container.
    pub sandboxed: bool,
    /// Tools that require human approval before running under the standing tier
    /// (writes/shell on the local host without `--full-access`); empty when the
    /// tier auto-allows everything. Feed into `AssistantConfig::gated_tools`.
    pub gated_tools: Vec<String>,
    /// If the sandbox was requested but unavailable, why we fell back to local.
    pub fallback_notice: Option<String>,
}

/// A delegate that tries each inner executor in turn, using the `unknown tool`
/// convention to fall through — so several tool families (network, memory) share
/// one `GeneralExecutor` delegate slot.
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}'"))
    }
}

/// Build a registry [`ToolSchema`] from a model-facing `{name, description,
/// parameters}` def, so any advertised tool can be registered for validation.
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,
    }
}

/// The names of advertised tools whose self-declared `"tier"` exceeds the
/// standing `tier` — these must be approval-gated (neo leak #3). A tool without
/// a `"tier"` field, or one at/below the standing tier, is not gated here.
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()
}

/// Default durable-memory path: `~/.car/memory/assistant.json`
/// (HOME, or USERPROFILE on Windows).
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")
}

/// Assemble an [`AssistantRuntime`] from an engine and a bound environment.
///
/// Registers the model-visible tools so the validator allows them (agent_basics
/// builtins + `shell` + `http_request` + `web_search`), binds the
/// [`GeneralExecutor`] as the executor and the environment's substrate, and
/// attaches an optional event-log journal.
pub async fn build_assistant_runtime(
    engine: Arc<InferenceEngine>,
    env: BoundEnvironment,
    eventlog: Option<PathBuf>,
) -> AssistantRuntime {
    // Mutations auto-allow unless the standing tier is ReadOnly (local host
    // without --full-access), in which case writes/shell need approval; clamp
    // file paths to root only off-sandbox.
    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();

    // Delegate tools (host-side): network + durable memory. Both bypass the
    // substrate/path-clamp — network needs host egress, memory is CAR's graph.
    let net: Arc<dyn ToolExecutor> = Arc::new(NetTools::new());
    let mem: Arc<dyn ToolExecutor> = Arc::new(MemoryTools::open(default_memory_path()));
    // Media generation (image today) — host-side, backed by the inference
    // engine's local models. Advertises nothing when no image model is
    // available, so it never offers a tool it can't run. The capability a
    // text-only agent structurally cannot have.
    let media = Arc::new(media_tools::MediaTools::new(
        engine.clone(),
        env.root.clone(),
    ));
    // Parslee Studio media (music today) — host-side, via the Studio service on
    // CAR's existing Parslee bearer. Advertises nothing without a Parslee
    // session. Another capability a text-only agent structurally lacks.
    let studio = Arc::new(studio_tools::StudioMediaTools::new(env.root.clone()));
    // Vision (image understanding) — host-side, via Apple Vision / Tesseract.
    // The CONSUMER counterpart to the generators: read text from an image (OCR)
    // and classify what it depicts. Advertises nothing when no vision backend is
    // present. A text-only agent can neither make nor read an image.
    let vision = Arc::new(vision_tools::VisionTools::new(env.root.clone()));
    // macOS automation ("control the Mac", AppleScript/JXA) — host-side, CANNOT
    // be sandboxed. Self-declares tier:full_access, so the tier-based gating
    // below routes it through approval unless the session is --full-access. A
    // capability no sandboxed or text-only agent has.
    let automation = Arc::new(automation_tools::AutomationTools::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());
    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 delegate: Arc<dyn ToolExecutor> = Arc::new(ChainedDelegate(vec![
        net, mem, media, studio, vision, automation,
    ]));

    let executor = GeneralExecutor::new(env.substrate.clone(), env.root.clone(), clamp)
        .with_delegate(delegate, delegate_defs);
    let tools = executor.all_tool_defs();
    // Tier-based approval gating (neo leak #3): any advertised tool that
    // self-declares a `"tier"` ABOVE the standing tier (e.g. a full_access
    // automation tool in a non-full-access session) must route through the
    // approval gate. Derived from the defs, so a new capability gates itself
    // without editing this function.
    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));
    }

    // Register the model-visible tools so the validator admits them. Execution
    // is owned by the GeneralExecutor above; these registrations are for
    // validation + schema listing. agent_basics covers the file/calculate
    // builtins; everything else advertised (shell, http_request, web_search,
    // remember, recall) is registered from its advertised def.
    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 serde_json::json;

    #[test]
    fn tier_gating_derives_from_self_declared_tier() {
        let tools = vec![
            json!({"name": "read_file"}), // no tier → never gated
            json!({"name": "generate_image", "tier": "sandbox_edit"}),
            json!({"name": "run_applescript", "tier": "full_access"}),
        ];

        // A ReadOnly session gates BOTH the sandbox_edit and full_access tools.
        let g = tier_gated_tool_names(&tools, PermissionTier::ReadOnly);
        assert!(g.contains(&"generate_image".to_string()));
        assert!(g.contains(&"run_applescript".to_string()));
        assert!(!g.contains(&"read_file".to_string()));

        // A SandboxEdit session gates only the full_access tool.
        let g = tier_gated_tool_names(&tools, PermissionTier::SandboxEdit);
        assert_eq!(g, vec!["run_applescript".to_string()]);

        // A FullAccess session gates nothing by tier.
        assert!(tier_gated_tool_names(&tools, PermissionTier::FullAccess).is_empty());
    }
}