holon 0.14.1

A headless, event-driven runtime for long-lived agents
Documentation
use anyhow::Result;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::path::PathBuf;

use crate::{
    runtime::RuntimeHandle,
    system::{workspace_projection_kind_label, WorkspaceAccessMode, WorkspaceProjectionKind},
    tool::spec::typed_spec,
    types::{ToolCapabilityFamily, TrustLevel, UseWorkspaceResult, AGENT_HOME_WORKSPACE_ID},
};

use super::{serialize_success, BuiltinToolDefinition};
use crate::tool::helpers::{
    invalid_tool_input, normalize_optional_non_empty, parse_tool_args, validate_non_empty,
};

pub(crate) const NAME: &str = "UseWorkspace";

#[derive(Clone, Copy, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
#[allow(dead_code)]
pub(crate) enum UseWorkspaceModeArgs {
    Direct,
    Isolated,
}

#[derive(Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub(crate) struct UseWorkspaceArgs {
    pub(crate) path: Option<String>,
    pub(crate) workspace_id: Option<String>,
    pub(crate) mode: Option<UseWorkspaceModeArgs>,
    pub(crate) cwd: Option<String>,
    pub(crate) isolation_label: Option<String>,
}

pub(crate) fn definition() -> Result<BuiltinToolDefinition> {
    Ok(BuiltinToolDefinition {
        family: ToolCapabilityFamily::LocalEnvironment,
        spec: typed_spec::<UseWorkspaceArgs>(
            NAME,
            "Make a workspace active. Provide exactly one of `path` or `workspace_id`: use `path` to discover, attach, and activate a project directory; use `workspace_id` to switch to a known workspace, including `agent_home` for the built-in fallback workspace. Shell `cd` does not change this active workspace for ApplyPatch or future commands.",
        )?,
    })
}

fn mode_arg_label(mode: UseWorkspaceModeArgs) -> &'static str {
    match mode {
        UseWorkspaceModeArgs::Direct => "direct",
        UseWorkspaceModeArgs::Isolated => "isolated",
    }
}

pub(crate) async fn execute(
    runtime: &RuntimeHandle,
    _agent_id: &str,
    _trust: &TrustLevel,
    input: &Value,
) -> Result<crate::tool::ToolResult> {
    let args: UseWorkspaceArgs = parse_tool_args(NAME, input)?;
    let path = normalize_optional_non_empty(args.path);
    let workspace_id = normalize_optional_non_empty(args.workspace_id);
    if path.is_some() == workspace_id.is_some() {
        return Err(invalid_tool_input(
            NAME,
            "UseWorkspace requires exactly one of `path` or `workspace_id`",
            json!({
                "fields": ["path", "workspace_id"],
                "validation_error": "mutually exclusive workspace selector required",
            }),
            "call UseWorkspace with either {\"path\":\"/repo\"} or {\"workspace_id\":\"agent_home\"}, not both",
        ));
    }

    let mode = args.mode.unwrap_or(UseWorkspaceModeArgs::Direct);
    let projection_kind = match mode {
        UseWorkspaceModeArgs::Direct => WorkspaceProjectionKind::CanonicalRoot,
        UseWorkspaceModeArgs::Isolated => WorkspaceProjectionKind::GitWorktreeRoot,
    };
    let access_mode = match projection_kind {
        WorkspaceProjectionKind::CanonicalRoot => WorkspaceAccessMode::SharedRead,
        WorkspaceProjectionKind::GitWorktreeRoot => WorkspaceAccessMode::ExclusiveWrite,
    };
    let cwd = normalize_optional_non_empty(args.cwd).map(PathBuf::from);
    let branch_name = match projection_kind {
        WorkspaceProjectionKind::CanonicalRoot => None,
        WorkspaceProjectionKind::GitWorktreeRoot => Some(
            normalize_optional_non_empty(args.isolation_label)
                .unwrap_or_else(|| "workspace".into()),
        ),
    };

    if let Some(workspace_id) = workspace_id {
        let workspace_id = validate_non_empty(workspace_id, NAME, "workspace_id")?;
        if workspace_id == AGENT_HOME_WORKSPACE_ID {
            if projection_kind != WorkspaceProjectionKind::CanonicalRoot {
                return Err(invalid_tool_input(
                    NAME,
                    "AgentHome can only be activated in direct mode",
                    json!({
                        "workspace_id": workspace_id,
                        "mode": mode_arg_label(mode),
                    }),
                    "call UseWorkspace with {\"workspace_id\":\"agent_home\"} and omit `mode`",
                ));
            }
            runtime.activate_agent_home(access_mode, cwd).await?;
        } else {
            let workspace = runtime
                .workspace_entry_for_use(&workspace_id)
                .await?
                .ok_or_else(|| {
                    invalid_tool_input(
                        NAME,
                        format!("workspace `{workspace_id}` was not found"),
                        json!({
                            "field": "workspace_id",
                            "workspace_id": workspace_id,
                            "validation_error": "workspace not found",
                        }),
                        "inspect the current agent state for attached workspace ids, or call UseWorkspace with a path",
                    )
                })?;
            runtime
                .enter_workspace(&workspace, projection_kind, access_mode, cwd, branch_name)
                .await?;
        }
    } else if let Some(path) = path {
        let path = validate_non_empty(path, NAME, "path")?;
        let path = PathBuf::from(&path);
        // Normalize and reject nonexistent paths before any state mutation.
        let normalized = crate::tool::helpers::normalize_path(&path)?;
        if !normalized.try_exists()? {
            return Err(invalid_tool_input(
                NAME,
                format!("path does not exist: {}", normalized.display()),
                json!({
                    "field": "path",
                    "path": normalized.display().to_string(),
                    "validation_error": "path does not exist",
                }),
                "call UseWorkspace with an existing directory path",
            ));
        }
        if !normalized.is_dir() {
            return Err(invalid_tool_input(
                NAME,
                format!("path is not a directory: {}", normalized.display()),
                json!({
                    "field": "path",
                    "path": normalized.display().to_string(),
                    "validation_error": "path is not a directory",
                }),
                "call UseWorkspace with an existing directory path",
            ));
        }
        if projection_kind == WorkspaceProjectionKind::CanonicalRoot {
            if let Some(existing_worktree) = runtime
                .attached_workspace_for_existing_git_worktree(&path)
                .await?
            {
                let default_cwd = crate::system::workspace::normalize_path(&path)?;
                runtime
                    .enter_existing_git_worktree(
                        &existing_worktree.workspace,
                        existing_worktree.worktree_root,
                        access_mode,
                        cwd.or(Some(default_cwd)),
                    )
                    .await?;
                let snapshot = runtime.execution_snapshot().await?;
                let projection_kind_label = workspace_projection_kind_label(
                    snapshot
                        .projection_kind
                        .unwrap_or(WorkspaceProjectionKind::GitWorktreeRoot),
                );
                let isolation_label = existing_worktree
                    .suggested_isolation_label
                    .unwrap_or_else(|| "worktree".into());
                return serialize_success(
                    NAME,
                    &UseWorkspaceResult {
                        workspace_id: snapshot
                            .workspace_id
                            .unwrap_or_else(|| AGENT_HOME_WORKSPACE_ID.to_string()),
                        workspace_anchor: snapshot.workspace_anchor,
                        execution_root: snapshot.execution_root,
                        cwd: snapshot.cwd,
                        mode: mode_arg_label(mode).to_string(),
                        projection_kind: projection_kind_label.to_string(),
                        summary_text: Some(format!(
                            "detected an existing git worktree for workspace {}; using it as an external execution root. Prefer UseWorkspace with {{\"workspace_id\":\"{}\",\"mode\":\"isolated\",\"isolation_label\":\"{}\"}} so the runtime manages lifecycle.",
                            existing_worktree.workspace.workspace_id,
                            existing_worktree.workspace.workspace_id,
                            isolation_label
                        )),
                    },
                );
            }
        }
        let workspace = runtime.ensure_workspace_entry_for_path(path).await?;
        runtime.attach_workspace(&workspace).await?;
        runtime
            .enter_workspace(&workspace, projection_kind, access_mode, cwd, branch_name)
            .await?;
    }

    let snapshot = runtime.execution_snapshot().await?;
    let mode_label = mode_arg_label(mode);
    let projection_kind_label = workspace_projection_kind_label(projection_kind);
    serialize_success(
        NAME,
        &UseWorkspaceResult {
            workspace_id: snapshot
                .workspace_id
                .unwrap_or_else(|| AGENT_HOME_WORKSPACE_ID.to_string()),
            workspace_anchor: snapshot.workspace_anchor,
            execution_root: snapshot.execution_root,
            cwd: snapshot.cwd,
            mode: mode_label.to_string(),
            projection_kind: projection_kind_label.to_string(),
            summary_text: Some(format!(
                "using workspace with {mode_label} mode and {projection_kind_label} projection"
            )),
        },
    )
}

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

    #[test]
    fn use_workspace_rejects_both_selectors() {
        let error = parse_tool_args::<UseWorkspaceArgs>(
            NAME,
            &serde_json::json!({
                "path": "/repo",
                "workspace_id": "agent_home"
            }),
        )
        .and_then(|args| {
            let path = normalize_optional_non_empty(args.path);
            let workspace_id = normalize_optional_non_empty(args.workspace_id);
            if path.is_some() == workspace_id.is_some() {
                return Err(invalid_tool_input(
                    NAME,
                    "UseWorkspace requires exactly one of `path` or `workspace_id`",
                    json!({}),
                    "call UseWorkspace with either path or workspace_id",
                ));
            }
            Ok(())
        })
        .unwrap_err();
        let tool_error = crate::tool::ToolError::from_anyhow(&error);
        assert_eq!(tool_error.kind, "invalid_tool_input");
    }

    #[test]
    fn use_workspace_schema_exposes_path_and_workspace_id() {
        let spec = definition().unwrap().spec;
        let properties = spec.input_schema["properties"].as_object().unwrap();
        assert!(properties["path"].is_object());
        assert!(properties["workspace_id"].is_object());
        assert!(!properties.contains_key("access_mode"));
    }
}