a3s-code-core 4.3.1

A3S Code Core - Embeddable AI agent library with tool execution
Documentation
//! Direct host tool access.
//!
//! These helpers expose the same tool executor used by the agent loop for host
//! control-plane calls. Keeping argument shaping and result projection here
//! prevents the public session facade from duplicating tool-specific knowledge.

use super::{AgentSession, ToolCallResult};
use crate::agent::AgentEvent;
use crate::error::Result;
use crate::llm::ToolDefinition;
use crate::tools::{ToolArtifact, ToolContext, ToolExecutor};
use std::sync::Arc;
use tokio::sync::{broadcast, mpsc};
use tokio::task::JoinHandle;

pub(super) struct DirectToolRuntime {
    tool_executor: Arc<ToolExecutor>,
    tool_context: ToolContext,
}

impl DirectToolRuntime {
    pub(super) fn from_session(session: &AgentSession) -> Self {
        Self {
            tool_executor: Arc::clone(&session.tool_executor),
            tool_context: session.tool_context.clone(),
        }
    }

    pub(super) fn definitions(&self) -> Vec<ToolDefinition> {
        self.tool_executor.definitions()
    }

    pub(super) fn names(&self) -> Vec<String> {
        self.tool_executor
            .definitions()
            .into_iter()
            .map(|tool| tool.name)
            .collect()
    }

    pub(super) fn artifact(&self, artifact_uri: &str) -> Option<ToolArtifact> {
        self.tool_executor.get_artifact(artifact_uri)
    }

    pub(super) async fn read_file(&self, path: &str) -> Result<String> {
        let args = serde_json::json!({ "file_path": path });
        let result = self.tool_executor.execute("read", &args).await?;
        Ok(result.output)
    }

    pub(super) async fn write_file(&self, path: &str, content: &str) -> Result<ToolCallResult> {
        let args = serde_json::json!({ "file_path": path, "content": content });
        self.call("write", args).await
    }

    pub(super) async fn ls(&self, path: Option<&str>) -> Result<ToolCallResult> {
        let args = match path {
            Some(path) => serde_json::json!({ "path": path }),
            None => serde_json::json!({}),
        };
        self.call("ls", args).await
    }

    pub(super) async fn edit_file(
        &self,
        path: &str,
        old_string: &str,
        new_string: &str,
        replace_all: bool,
    ) -> Result<ToolCallResult> {
        let args = serde_json::json!({
            "file_path": path,
            "old_string": old_string,
            "new_string": new_string,
            "replace_all": replace_all,
        });
        self.call("edit", args).await
    }

    pub(super) async fn patch_file(&self, path: &str, diff: &str) -> Result<ToolCallResult> {
        let args = serde_json::json!({ "file_path": path, "diff": diff });
        self.call("patch", args).await
    }

    pub(super) async fn bash(&self, command: &str) -> Result<String> {
        let args = serde_json::json!({ "command": command });
        let result = self
            .tool_executor
            .execute_with_context("bash", &args, &self.tool_context)
            .await?;
        Ok(result.output)
    }

    pub(super) async fn glob(&self, pattern: &str) -> Result<Vec<String>> {
        let args = serde_json::json!({ "pattern": pattern });
        let result = self.tool_executor.execute("glob", &args).await?;
        Ok(parse_glob_output(&result.output))
    }

    pub(super) async fn grep(&self, pattern: &str) -> Result<String> {
        let args = serde_json::json!({ "pattern": pattern });
        let result = self.tool_executor.execute("grep", &args).await?;
        Ok(result.output)
    }

    pub(super) async fn call(&self, name: &str, args: serde_json::Value) -> Result<ToolCallResult> {
        let result = self
            .tool_executor
            .execute_with_context(name, &args, &self.tool_context)
            .await?;
        Ok(ToolCallResult {
            name: name.to_string(),
            output: result.output,
            exit_code: result.exit_code,
            metadata: result.metadata,
            error_kind: result.error_kind,
        })
    }

    pub(super) fn spawn_call_with_agent_events(
        self,
        name: String,
        args: serde_json::Value,
    ) -> (
        mpsc::Receiver<AgentEvent>,
        JoinHandle<Result<ToolCallResult>>,
    ) {
        let (agent_tx, agent_rx) = mpsc::channel::<AgentEvent>(256);
        let (broadcast_tx, mut broadcast_rx) = broadcast::channel::<AgentEvent>(256);
        let forward_tx = agent_tx.clone();
        tokio::spawn(async move {
            loop {
                match broadcast_rx.recv().await {
                    Ok(event) => {
                        if forward_tx.send(event).await.is_err() {
                            break;
                        }
                    }
                    Err(broadcast::error::RecvError::Lagged(_)) => continue,
                    Err(broadcast::error::RecvError::Closed) => break,
                }
            }
        });

        let executor = Arc::clone(&self.tool_executor);
        let mut ctx = self.tool_context;
        ctx.agent_event_tx = Some(broadcast_tx);
        let tool_name = name.clone();
        let handle = tokio::spawn(async move {
            let result = executor
                .execute_with_context(&tool_name, &args, &ctx)
                .await?;
            Ok(ToolCallResult {
                name,
                output: result.output,
                exit_code: result.exit_code,
                metadata: result.metadata,
                error_kind: result.error_kind,
            })
        });

        drop(agent_tx);
        (agent_rx, handle)
    }
}

fn parse_glob_output(output: &str) -> Vec<String> {
    output
        .lines()
        .filter(|line| !line.is_empty())
        .map(str::to_string)
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tools::{Tool, ToolOutput};
    use anyhow::Result;
    use async_trait::async_trait;

    struct ContextProbeTool;

    #[async_trait]
    impl Tool for ContextProbeTool {
        fn name(&self) -> &str {
            "context_probe"
        }

        fn description(&self) -> &str {
            "Reports direct tool context for tests."
        }

        fn parameters(&self) -> serde_json::Value {
            serde_json::json!({ "type": "object" })
        }

        async fn execute(
            &self,
            _args: &serde_json::Value,
            ctx: &ToolContext,
        ) -> Result<ToolOutput> {
            Ok(ToolOutput::success(
                ctx.session_id.as_deref().unwrap_or("missing-session"),
            ))
        }
    }

    #[test]
    fn parse_glob_output_ignores_empty_lines() {
        assert_eq!(
            parse_glob_output("src/lib.rs\n\nsrc/main.rs\n"),
            vec!["src/lib.rs".to_string(), "src/main.rs".to_string()]
        );
    }

    #[tokio::test]
    async fn direct_tool_call_uses_session_tool_context() {
        let dir = tempfile::tempdir().unwrap();
        let tool_executor = Arc::new(ToolExecutor::new(dir.path().to_string_lossy().to_string()));
        tool_executor.register_dynamic_tool(Arc::new(ContextProbeTool));
        let runtime = DirectToolRuntime {
            tool_executor,
            tool_context: ToolContext::new(dir.path().to_path_buf()).with_session_id("session-123"),
        };

        let output = runtime
            .call("context_probe", serde_json::json!({}))
            .await
            .unwrap();

        assert_eq!(output.output, "session-123");
    }
}