For real. Tools schema generation and ux tweaks.
This commit is contained in:
185
core/tests/test_tool_schema.py
Normal file
185
core/tests/test_tool_schema.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Tests for castle_core.tool_schema — deriving neutral tool-call cores from --help."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from castle_core.tool_schema import (
|
||||
ToolSchemaError,
|
||||
_command_core,
|
||||
_extract_subcommands,
|
||||
_sanitize_name,
|
||||
_structured_core,
|
||||
collect_tool_help,
|
||||
derive_tool_schema,
|
||||
is_tool_schema_core,
|
||||
render_tool_schema,
|
||||
tool_executable,
|
||||
validate_tool_schema_core,
|
||||
)
|
||||
|
||||
FLAT_HELP = """\
|
||||
usage: widget [-h] [--deep] [--mode {a,b}] target
|
||||
|
||||
Do a widget thing.
|
||||
|
||||
positional arguments:
|
||||
target What to widget
|
||||
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
--deep Recurse
|
||||
--mode {a,b} Pick a mode
|
||||
"""
|
||||
|
||||
SUBCMD_HELP = "positional arguments:\n {build,deploy}\n build Build\n"
|
||||
|
||||
|
||||
def _fake_config(programs: dict) -> object:
|
||||
return SimpleNamespace(programs=programs)
|
||||
|
||||
|
||||
class TestPure:
|
||||
def test_sanitize_name(self) -> None:
|
||||
assert _sanitize_name("my.tool v2") == "my_tool_v2"
|
||||
assert len(_sanitize_name("x" * 100)) == 64
|
||||
|
||||
def test_flat_tool_has_no_subcommands(self) -> None:
|
||||
assert _extract_subcommands(FLAT_HELP) == []
|
||||
|
||||
def test_argparse_choice_row_subcommands(self) -> None:
|
||||
assert _extract_subcommands(SUBCMD_HELP) == ["build", "deploy"]
|
||||
|
||||
def test_structured_core_typed_params(self) -> None:
|
||||
core = _structured_core("widget", FLAT_HELP)
|
||||
assert core is not None
|
||||
props = core["parameters"]["properties"]
|
||||
assert set(props) == {"target", "deep", "mode"}
|
||||
assert props["deep"]["type"] == "boolean"
|
||||
assert props["mode"]["enum"] == ["a", "b"]
|
||||
assert core["parameters"]["required"] == ["target"]
|
||||
|
||||
def test_structured_none_without_options(self) -> None:
|
||||
assert _structured_core("x", "free-form help\n") is None
|
||||
|
||||
def test_command_core_shape(self) -> None:
|
||||
core = _command_core("jq", ["/usr/bin/jq"], "Usage: jq ...", deep=False)
|
||||
assert core["parameters"]["required"] == ["command"]
|
||||
|
||||
|
||||
class TestRender:
|
||||
def test_render_openai_default(self) -> None:
|
||||
core = _command_core("jq", ["/usr/bin/jq"], "help", deep=False)
|
||||
out = render_tool_schema(core)
|
||||
assert out["type"] == "function"
|
||||
assert out["function"]["parameters"] == core["parameters"]
|
||||
|
||||
def test_render_anthropic(self) -> None:
|
||||
core = _command_core("jq", ["/usr/bin/jq"], "help", deep=False)
|
||||
out = render_tool_schema(core, "anthropic")
|
||||
assert set(out) == {"name", "description", "input_schema"}
|
||||
assert out["input_schema"] == core["parameters"]
|
||||
|
||||
def test_render_neutral_is_identity(self) -> None:
|
||||
core = _command_core("jq", ["/usr/bin/jq"], "help", deep=False)
|
||||
assert render_tool_schema(core, "neutral") is core
|
||||
|
||||
|
||||
class TestResolution:
|
||||
def test_tool_executable_falls_back_to_name(self) -> None:
|
||||
cfg = _fake_config({"widget": SimpleNamespace(source=None)})
|
||||
assert tool_executable(cfg, "widget") == "widget"
|
||||
|
||||
def test_tool_executable_reads_pyproject_scripts(self, tmp_path) -> None:
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
'[project.scripts]\nintent-router = "x:main"\n'
|
||||
)
|
||||
cfg = _fake_config({"r": SimpleNamespace(source=str(tmp_path))})
|
||||
assert tool_executable(cfg, "r") == "intent-router"
|
||||
|
||||
|
||||
class TestDerive:
|
||||
def test_missing_executable_raises(self) -> None:
|
||||
cfg = _fake_config({"nope": SimpleNamespace(source=None)})
|
||||
with pytest.raises(ToolSchemaError, match="not on PATH"):
|
||||
derive_tool_schema(cfg, "nope")
|
||||
|
||||
def test_derive_real_tool_returns_neutral_core(self) -> None:
|
||||
cfg = _fake_config({"python3": SimpleNamespace(source=None)})
|
||||
core = derive_tool_schema(cfg, "python3")
|
||||
assert core["name"] == "python3"
|
||||
assert "parameters" in core # neutral shape, not input_schema
|
||||
assert core["description"]
|
||||
|
||||
|
||||
class TestLLMAssistHelpers:
|
||||
"""Deterministic helpers that feed / validate the (castle-api) LLM assist."""
|
||||
|
||||
def test_collect_tool_help_real_tool(self) -> None:
|
||||
cfg = _fake_config({"python3": SimpleNamespace(source=None)})
|
||||
help_text = collect_tool_help(cfg, "python3")
|
||||
assert help_text and "$ python3 --help" in help_text
|
||||
|
||||
def test_collect_tool_help_missing_raises(self) -> None:
|
||||
cfg = _fake_config({"nope": SimpleNamespace(source=None)})
|
||||
with pytest.raises(ToolSchemaError, match="not on PATH"):
|
||||
collect_tool_help(cfg, "nope")
|
||||
|
||||
def test_is_tool_schema_core(self) -> None:
|
||||
good = {
|
||||
"name": "jq",
|
||||
"description": "process json",
|
||||
"parameters": {"type": "object", "properties": {"filter": {"type": "string"}}},
|
||||
}
|
||||
assert is_tool_schema_core(good) is True
|
||||
assert is_tool_schema_core({"name": "x", "description": "y"}) is False # no params
|
||||
assert is_tool_schema_core("nope") is False
|
||||
|
||||
|
||||
class TestValidate:
|
||||
"""Deterministic validation — shape + JSON-Schema meta-validation."""
|
||||
|
||||
_GOOD = {
|
||||
"name": "jq",
|
||||
"description": "process json",
|
||||
"parameters": {"type": "object", "properties": {"filter": {"type": "string"}}},
|
||||
}
|
||||
|
||||
def test_valid_returns_no_errors(self) -> None:
|
||||
assert validate_tool_schema_core(self._GOOD) == []
|
||||
|
||||
def test_malformed_enum_is_caught(self) -> None:
|
||||
"""The qwen defect: `enum` as a count, not a list — a JSON-Schema error."""
|
||||
bad = {
|
||||
"name": "search",
|
||||
"description": "d",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"sub": {"type": "string", "enum": 4}},
|
||||
},
|
||||
}
|
||||
errors = validate_tool_schema_core(bad)
|
||||
assert errors and any("JSON Schema" in e for e in errors)
|
||||
|
||||
def test_missing_parameters(self) -> None:
|
||||
assert validate_tool_schema_core({"name": "x", "description": "y"})
|
||||
|
||||
def test_empty_properties(self) -> None:
|
||||
errors = validate_tool_schema_core(
|
||||
{"name": "x", "description": "y", "parameters": {"type": "object", "properties": {}}}
|
||||
)
|
||||
assert any("empty" in e for e in errors)
|
||||
|
||||
def test_bad_name_char(self) -> None:
|
||||
errors = validate_tool_schema_core(
|
||||
{**self._GOOD, "name": "has space"}
|
||||
)
|
||||
assert any("name" in e for e in errors)
|
||||
|
||||
def test_parameters_not_object_type(self) -> None:
|
||||
errors = validate_tool_schema_core(
|
||||
{"name": "x", "description": "y", "parameters": {"type": "array", "properties": {}}}
|
||||
)
|
||||
assert errors
|
||||
Reference in New Issue
Block a user