car-server-core 0.36.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! Parslee M365 tools for the general assistant — delegate real M365 work
//! (email, calendar, contacts, CRM, meetings, research, drafting) to the org's
//! M365 employee via the Parslee platform, on CAR's existing Parslee bearer.
//! Advertised only when a Parslee session exists (the user ran `car auth
//! login`). Another capability a text-only agent structurally lacks.
//!
//! A single delegation tool (`m365_task`) reaches the whole ~40-plugin M365
//! surface through one `POST /api/v1/orgs/{orgId}/chat` call, which runs
//! m365dotnet's already-agentic `EmployeeOrchestrator`. Mutating actions (send
//! mail, create events) are guarded server-side by m365's approval filter and
//! come back as a drafted, approval-required reply — never silently executed
//! (see [`car_parslee::parslee_m365::chat_task`]). The assistant's own
//! `full_access` tier gate is the visible "approve the delegation" step.

use async_trait::async_trait;
use car_engine::ToolExecutor;
use serde_json::{json, Value};

const M365_TOOL_TIER: &str = "full_access";

/// Host-side M365 delegation. Carries no state; availability is a cheap
/// bearer-presence check so it never advertises a tool it can't run.
pub struct M365Tools;

impl M365Tools {
    pub fn new() -> Self {
        Self
    }

    /// Cheap, non-network availability check: is a Parslee bearer present? Never
    /// advertise an M365 tool on a host with no Parslee session.
    fn available(&self) -> bool {
        car_auth::access_token().is_some()
    }

    pub fn tool_defs(&self) -> Vec<Value> {
        if !self.available() {
            return Vec::new();
        }
        m365_tool_defs()
    }
}

impl Default for M365Tools {
    fn default() -> Self {
        Self::new()
    }
}

fn m365_tool_defs() -> Vec<Value> {
    vec![json!({
        "name": "m365_task",
        "description": "Delegate a task to the user's Microsoft 365 assistant (their Parslee AI \
            Employee) — email, calendar, contacts, CRM (HubSpot), meetings, and business research \
            across their connected M365 and tools. Give a plain-language instruction like \
            'summarize my unread email from today', 'what's on my calendar tomorrow', 'draft a \
            follow-up to the Acme thread', or 'find our latest deal with Contoso in HubSpot'. \
            Returns the assistant's reply (and a conversation_id you can pass back to continue the \
            same thread). IMPORTANT: actions that SEND or CHANGE things (send an email, create a \
            calendar event) are drafted and returned for approval, not executed silently — relay \
            the reply and let the user confirm. Mailbox / calendar access needs a connected \
            Microsoft account; if it isn't connected the reply says so.",
        "parameters": {
            "type": "object",
            "properties": {
                "task": {
                    "type": "string",
                    "description": "The plain-language M365 task or question."
                },
                "conversation_id": {
                    "type": "string",
                    "description": "Optional id from a previous m365_task reply, to continue the same thread."
                }
            },
            "required": ["task"]
        },
        "mutating": true,
        "tier": M365_TOOL_TIER
    })]
}

#[async_trait]
impl ToolExecutor for M365Tools {
    async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
        match tool {
            "m365_task" => car_parslee::parslee_m365::chat_task(params).await,
            // The prefix must be exactly "unknown tool" so ChainedDelegate falls
            // through to the next executor.
            other => Err(format!("unknown tool: '{other}'")),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn m365_task_requires_full_access_tier() {
        let defs = m365_tool_defs();
        assert_eq!(defs.len(), 1);
        assert_eq!(defs[0]["name"], "m365_task");
        assert_eq!(
            defs[0]["tier"], M365_TOOL_TIER,
            "m365_task must require full-access approval because it delegates to an external \
             agent that can act on the user's mailbox / calendar"
        );
        assert_eq!(defs[0]["mutating"], true);
    }

    #[tokio::test]
    async fn unknown_tool_falls_through() {
        let tools = M365Tools::new();
        let err = tools.execute("nope", &json!({})).await.unwrap_err();
        assert!(err.starts_with("unknown tool"), "{err}");
    }
}