tea-tools 0.1.0

Portable tool specifications and execution contracts for tea-rs
Documentation
use std::str::FromStr;

use serde_json::json;
use tea_protocol::ToolIdempotency;
use tea_tools::{
    SchedulerClass, ToolConcurrency, ToolEffect, ToolExecutionSemantics, ToolName, ToolRetrySafety,
    ToolSource, ToolSourceKind, ToolSpec, ToolSpecError, ToolTimeout, ToolTrust, ToolVersion,
};

const DIGEST: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";

fn object_schema() -> serde_json::Value {
    json!({
        "$schema": "https://json-schema.org/draft/2020-12/schema",
        "type": "object",
        "properties": {
            "path": { "type": "string" }
        },
        "required": ["path"],
        "additionalProperties": false
    })
}

fn semantics(
    idempotency: ToolIdempotency,
    retry: ToolRetrySafety,
    concurrency: ToolConcurrency,
) -> ToolExecutionSemantics {
    ToolExecutionSemantics::new(
        idempotency,
        retry,
        concurrency,
        ToolTimeout::from_millis(30_000).unwrap(),
    )
    .unwrap()
}

fn spec(
    name: &str,
    effects: impl IntoIterator<Item = ToolEffect>,
    execution: ToolExecutionSemantics,
) -> ToolSpec {
    ToolSpec::new(
        ToolName::from_str(name).unwrap(),
        ToolVersion::from_str("1.2.3").unwrap(),
        "Operates on a declared resource.",
        object_schema(),
        object_schema(),
        effects,
        execution,
    )
    .unwrap()
}

#[test]
fn tool_identity_is_canonical_and_versioned() {
    assert_eq!(
        ToolName::from_str("read_file").unwrap().as_str(),
        "read_file"
    );
    assert!(ToolName::from_str("").is_err());
    assert!(ToolName::from_str("ReadFile").is_err());
    assert!(ToolName::from_str("read file").is_err());
    assert!(ToolName::from_str(&"x".repeat(129)).is_err());

    let version = ToolVersion::from_str("1.2.3-beta.1").unwrap();
    assert_eq!(version.to_string(), "1.2.3-beta.1");
    assert!(ToolVersion::from_str("v1.2.3").is_err());
    assert!(ToolVersion::from_str("1.2").is_err());
}

#[test]
fn known_and_unknown_effects_have_stable_values() {
    let expected = [
        ("fs.read", ToolEffect::FsRead),
        ("fs.write", ToolEffect::FsWrite),
        ("fs.delete", ToolEffect::FsDelete),
        ("process.spawn", ToolEffect::ProcessSpawn),
        ("network.request", ToolEffect::NetworkRequest),
        ("credential.read", ToolEffect::CredentialRead),
        ("clipboard.read", ToolEffect::ClipboardRead),
        ("user.interaction", ToolEffect::UserInteraction),
        ("external.mutation", ToolEffect::ExternalMutation),
    ];
    for (wire, effect) in expected {
        assert_eq!(ToolEffect::from_str(wire).unwrap(), effect);
        assert_eq!(effect.as_str(), wire);
    }

    let unknown = ToolEffect::from_str("com.example.device.control").unwrap();
    assert_eq!(unknown.as_str(), "com.example.device.control");
    assert!(unknown.is_unknown());
    assert!(ToolEffect::from_str("unknown").is_err());
    assert!(ToolEffect::from_str("Bad.effect").is_err());
}

#[test]
fn execution_semantics_reject_unsafe_automatic_retry() {
    assert_eq!(
        ToolExecutionSemantics::new(
            ToolIdempotency::NonIdempotent,
            ToolRetrySafety::Automatic,
            ToolConcurrency::Parallel,
            ToolTimeout::from_millis(1_000).unwrap(),
        )
        .unwrap_err(),
        ToolSpecError::UnsafeAutomaticRetry
    );

    let safe = semantics(
        ToolIdempotency::Idempotent,
        ToolRetrySafety::Automatic,
        ToolConcurrency::Parallel,
    );
    assert_eq!(safe.idempotency(), ToolIdempotency::Idempotent);
    assert_eq!(safe.retry_safety(), ToolRetrySafety::Automatic);
    assert_eq!(safe.concurrency(), ToolConcurrency::Parallel);
    assert_eq!(safe.timeout().as_millis(), 30_000);

    assert!(ToolTimeout::from_millis(0).is_err());
    assert!(ToolTimeout::from_millis(86_400_001).is_err());
}

#[test]
fn specification_is_bounded_and_projects_to_model_definition() {
    let tool = spec(
        "read_file",
        [ToolEffect::FsRead],
        semantics(
            ToolIdempotency::Idempotent,
            ToolRetrySafety::Automatic,
            ToolConcurrency::Parallel,
        ),
    )
    .with_label("Read file")
    .unwrap()
    .with_prompt_snippet("Use for bounded workspace reads.")
    .unwrap()
    .with_prompt_guidelines([
        "Keep every path within the workspace.",
        "Request only the data needed for the task.",
    ])
    .unwrap()
    .with_ui_renderer("file-preview")
    .unwrap();

    assert_eq!(tool.name().as_str(), "read_file");
    assert_eq!(tool.version().to_string(), "1.2.3");
    assert_eq!(tool.effects(), &[ToolEffect::FsRead]);
    assert_eq!(tool.source(), &ToolSource::native_product());
    assert_eq!(tool.label(), Some("Read file"));
    assert_eq!(tool.prompt_hint(), Some("Use for bounded workspace reads."));
    assert_eq!(
        tool.prompt_snippet(),
        Some("Use for bounded workspace reads.")
    );
    assert_eq!(
        tool.prompt_guidelines(),
        [
            "Keep every path within the workspace.",
            "Request only the data needed for the task.",
        ]
    );
    assert_eq!(tool.ui_renderer(), Some("file-preview"));

    let model = tool.to_model_definition().unwrap();
    assert_eq!(model.name(), "read_file");
    assert_eq!(model.description(), "Operates on a declared resource.");
    assert_eq!(model.input_schema(), tool.input_schema());

    assert_eq!(
        ToolSpec::new(
            ToolName::from_str("empty_effects").unwrap(),
            ToolVersion::from_str("1.0.0").unwrap(),
            "description",
            object_schema(),
            object_schema(),
            [],
            semantics(
                ToolIdempotency::Idempotent,
                ToolRetrySafety::ExplicitOnly,
                ToolConcurrency::Serial,
            ),
        )
        .unwrap_err(),
        ToolSpecError::MissingEffects
    );
}

#[test]
fn tool_presentation_metadata_is_bounded() {
    let execution = semantics(
        ToolIdempotency::Idempotent,
        ToolRetrySafety::Automatic,
        ToolConcurrency::Parallel,
    );
    assert_eq!(
        spec("invalid_label", [ToolEffect::FsRead], execution)
            .with_label("")
            .unwrap_err(),
        ToolSpecError::InvalidLabel
    );
    assert_eq!(
        spec("too_many_guidelines", [ToolEffect::FsRead], execution)
            .with_prompt_guidelines((0..17).map(|_| "Guideline"))
            .unwrap_err(),
        ToolSpecError::TooManyPromptGuidelines
    );
    assert_eq!(
        spec("oversized_guidelines", [ToolEffect::FsRead], execution)
            .with_prompt_guidelines(["x".repeat(1_025)])
            .unwrap_err(),
        ToolSpecError::InvalidPromptGuideline
    );
}

#[test]
fn specification_accepts_an_explicit_frozen_source() {
    let source = ToolSource::new(
        ToolSourceKind::Mcp,
        "workspace.files",
        ToolTrust::Workspace,
        DIGEST,
    )
    .unwrap();
    let tool = spec(
        "read_file",
        [ToolEffect::FsRead],
        semantics(
            ToolIdempotency::Idempotent,
            ToolRetrySafety::Automatic,
            ToolConcurrency::Parallel,
        ),
    )
    .with_source(source.clone());
    assert_eq!(tool.source(), &source);
}

#[test]
fn scheduler_classification_uses_metadata_not_tool_names() {
    let read_a = spec(
        "read_file",
        [ToolEffect::FsRead],
        semantics(
            ToolIdempotency::Idempotent,
            ToolRetrySafety::Automatic,
            ToolConcurrency::Parallel,
        ),
    );
    let read_b = spec(
        "totally_different_name",
        [ToolEffect::FsRead],
        semantics(
            ToolIdempotency::Idempotent,
            ToolRetrySafety::Automatic,
            ToolConcurrency::Parallel,
        ),
    );
    assert_eq!(read_a.scheduler_class(), SchedulerClass::ParallelReadOnly);
    assert_eq!(read_a.scheduler_class(), read_b.scheduler_class());

    let retry_safe_mutation = spec(
        "write_file",
        [ToolEffect::FsWrite],
        semantics(
            ToolIdempotency::Idempotent,
            ToolRetrySafety::Automatic,
            ToolConcurrency::Parallel,
        ),
    );
    assert_eq!(
        retry_safe_mutation.scheduler_class(),
        SchedulerClass::ParallelRetrySafe
    );

    let non_idempotent = spec(
        "send_message",
        [ToolEffect::ExternalMutation],
        semantics(
            ToolIdempotency::NonIdempotent,
            ToolRetrySafety::Never,
            ToolConcurrency::Parallel,
        ),
    );
    assert_eq!(non_idempotent.scheduler_class(), SchedulerClass::Serial);

    let exclusive = spec(
        "exclusive_read",
        [ToolEffect::FsRead],
        semantics(
            ToolIdempotency::Idempotent,
            ToolRetrySafety::ExplicitOnly,
            ToolConcurrency::Exclusive,
        ),
    );
    assert_eq!(exclusive.scheduler_class(), SchedulerClass::Exclusive);

    let unknown = spec(
        "future_tool",
        [ToolEffect::from_str("com.example.future.effect").unwrap()],
        semantics(
            ToolIdempotency::Idempotent,
            ToolRetrySafety::Automatic,
            ToolConcurrency::Parallel,
        ),
    );
    assert_eq!(unknown.scheduler_class(), SchedulerClass::PolicyRequired);
    assert!(unknown.scheduler_class().requires_policy());
    assert!(!unknown.scheduler_class().allows_parallel_execution());
}