a3s-code-core 6.8.0

A3S Code Core - Embeddable AI agent library with tool execution
Documentation
use super::tests::MockLlmClient;
use super::{AgentConfig, AgentLoop};
use crate::hitl::{ConfirmationManager, ConfirmationPolicy, ConfirmationProvider};
use crate::permissions::{PermissionChecker, PermissionDecision};
use crate::tools::{AgentDirScriptTool, Tool, ToolContext, ToolExecutor, ToolOutput};
use anyhow::Result;
use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;

struct SideEffectTool {
    calls: Arc<AtomicUsize>,
}

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

    fn description(&self) -> &str {
        "Records a test-only side effect"
    }

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

    async fn execute(&self, _args: &serde_json::Value, _ctx: &ToolContext) -> Result<ToolOutput> {
        self.calls.fetch_add(1, Ordering::SeqCst);
        Ok(ToolOutput::success("side-effect-ok"))
    }
}

struct RecordingTargetPermission {
    decision: PermissionDecision,
    checks: Arc<AtomicUsize>,
}

impl PermissionChecker for RecordingTargetPermission {
    fn check(&self, tool_name: &str, _args: &serde_json::Value) -> PermissionDecision {
        if tool_name == "side_effect" {
            self.checks.fetch_add(1, Ordering::SeqCst);
            self.decision
        } else {
            PermissionDecision::Allow
        }
    }
}

fn agent_dir_script_agent(
    nested_decision: PermissionDecision,
    confirmation_manager: Option<Arc<dyn ConfirmationProvider>>,
) -> (
    AgentLoop,
    Arc<AtomicUsize>,
    Arc<AtomicUsize>,
    tempfile::TempDir,
) {
    let directory = tempfile::tempdir().unwrap();
    std::fs::create_dir(directory.path().join("tools")).unwrap();
    std::fs::write(directory.path().join("instructions.md"), "test role").unwrap();
    std::fs::write(
        directory.path().join("tools/project-script.md"),
        "---\nkind: script\nname: project_script\npath: project-script.js\nallowed_tools: [side_effect]\n---\nCall the test side-effect tool.\n",
    )
    .unwrap();
    std::fs::write(
        directory.path().join("project-script.js"),
        r#"async function run(ctx) {
               return await ctx.tool("side_effect", {});
           }"#,
    )
    .unwrap();

    let loaded = crate::config::AgentDir::load(directory.path()).unwrap();
    let crate::config::ToolSpec::Script(spec) = &loaded.tools[0] else {
        panic!("expected project script tool");
    };
    let calls = Arc::new(AtomicUsize::new(0));
    let permission_checks = Arc::new(AtomicUsize::new(0));
    let executor = Arc::new(ToolExecutor::new(
        directory.path().to_string_lossy().to_string(),
    ));
    executor.register_dynamic_tool(Arc::new(SideEffectTool {
        calls: Arc::clone(&calls),
    }));
    executor.register_dynamic_tool(Arc::new(AgentDirScriptTool::new(
        spec.clone(),
        Arc::clone(executor.registry()),
    )));
    let responses = vec![
        MockLlmClient::tool_call_response(
            "project-script-1",
            "project_script",
            serde_json::json!({}),
        ),
        MockLlmClient::text_response("done"),
    ];
    let agent = AgentLoop::new(
        Arc::new(MockLlmClient::new(responses)),
        executor,
        ToolContext::new(directory.path().to_path_buf()),
        AgentConfig {
            permission_checker: Some(Arc::new(RecordingTargetPermission {
                decision: nested_decision,
                checks: Arc::clone(&permission_checks),
            })),
            confirmation_manager,
            ..Default::default()
        },
    );

    (agent, calls, permission_checks, directory)
}

#[tokio::test]
async fn agent_dir_script_nested_tool_obeys_permission_deny_without_side_effects() {
    let (agent, calls, permission_checks, _directory) =
        agent_dir_script_agent(PermissionDecision::Deny, None);

    agent
        .execute_with_session(
            &[],
            "run project script",
            Some("agent-dir-permission"),
            None,
            None,
        )
        .await
        .unwrap();

    assert_eq!(
        calls.load(Ordering::SeqCst),
        0,
        "the allow-list must not bypass the session permission denial"
    );
    assert!(
        permission_checks.load(Ordering::SeqCst) > 0,
        "the nested call must reach the session permission checker"
    );
}

#[tokio::test]
async fn agent_dir_script_nested_tool_waits_for_hitl_before_side_effects() {
    let (confirmation_events, _) = tokio::sync::broadcast::channel(8);
    let confirmations = Arc::new(ConfirmationManager::new(
        ConfirmationPolicy::enabled(),
        confirmation_events,
    ));
    let confirmation_provider = confirmations.clone() as Arc<dyn ConfirmationProvider>;
    let (agent, calls, permission_checks, _directory) =
        agent_dir_script_agent(PermissionDecision::Ask, Some(confirmation_provider));

    let run = agent.execute_with_session(
        &[],
        "run project script",
        Some("agent-dir-hitl"),
        None,
        None,
    );
    let reject_nested_call = async {
        let request = tokio::time::timeout(std::time::Duration::from_secs(1), async {
            loop {
                if let Some(request) = confirmations.pending_confirmations().await.first().cloned()
                {
                    return request;
                }
                tokio::task::yield_now().await;
            }
        })
        .await
        .expect("the nested project tool call must request confirmation");
        assert_eq!(request.1, "side_effect");
        assert!(permission_checks.load(Ordering::SeqCst) > 0);
        assert_eq!(calls.load(Ordering::SeqCst), 0);
        confirmations
            .confirm(
                &request.0,
                false,
                Some("rejected nested project tool in test".to_string()),
            )
            .await
            .unwrap();
    };

    let (result, ()) = tokio::join!(run, reject_nested_call);
    result.unwrap();
    assert_eq!(
        calls.load(Ordering::SeqCst),
        0,
        "HITL rejection must happen before the nested side effect"
    );
}