mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 16:19:21 +00:00
feat(ptc): programmatic tool calling -- executor, SDK, and E2E tests
Add ToolExecutor for standalone tool dispatch used by both the
orchestrator HTTP RPC endpoint and the WASM tool_invoke host function.
Includes Python SDK for container scripts, WASM test fixture, and
comprehensive E2E test coverage across all PTC paths.
Implementation:
- ToolExecutor with timeout, nesting depth limit, safety sanitization
- Orchestrator POST /worker/{job_id}/tools/call endpoint with SSE events
- WASM tool_invoke host function with alias resolution
- Python SDK (stdlib-only) with call_tool + convenience wrappers
Tests (16 new):
- 6 orchestrator HTTP RPC tests (auth, echo, not-found, timeout, SSE, no-executor)
- 3 executor integration tests (sanitization, invalid params, sequential)
- 4 Python SDK tests (env vars, request format, HTTP error, wrappers)
- 3 WASM E2E tests (echo via alias, alias not granted, no capability)
Refs #407
Co-Authored-By: Claude Opus 4.6 <[email protected]>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
"""Tests for the IronClaw Programmatic Tool Calling Python SDK."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import urllib.error
|
||||
|
||||
# Ensure ironclaw_tools is importable regardless of working directory.
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
class TestEnvMissing(unittest.TestCase):
|
||||
"""Test that missing env vars produce clear errors."""
|
||||
|
||||
def setUp(self):
|
||||
# Clear all relevant env vars
|
||||
for var in ["IRONCLAW_ORCHESTRATOR_URL", "IRONCLAW_JOB_ID", "IRONCLAW_WORKER_TOKEN"]:
|
||||
os.environ.pop(var, None)
|
||||
|
||||
def test_env_missing(self):
|
||||
from ironclaw_tools import call_tool
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
call_tool("echo", {"message": "hello"})
|
||||
# Should mention the missing variable
|
||||
self.assertIn("IRONCLAW_ORCHESTRATOR_URL", str(ctx.exception))
|
||||
|
||||
|
||||
class TestCallToolRequestFormat(unittest.TestCase):
|
||||
"""Test that call_tool sends correctly formatted requests."""
|
||||
|
||||
def setUp(self):
|
||||
os.environ["IRONCLAW_ORCHESTRATOR_URL"] = "http://localhost:50051"
|
||||
os.environ["IRONCLAW_JOB_ID"] = "550e8400-e29b-41d4-a716-446655440000"
|
||||
os.environ["IRONCLAW_WORKER_TOKEN"] = "test-token-123"
|
||||
|
||||
def tearDown(self):
|
||||
for var in ["IRONCLAW_ORCHESTRATOR_URL", "IRONCLAW_JOB_ID", "IRONCLAW_WORKER_TOKEN"]:
|
||||
os.environ.pop(var, None)
|
||||
|
||||
@patch("ironclaw_tools.urllib.request.urlopen")
|
||||
def test_call_tool_request_format(self, mock_urlopen):
|
||||
from ironclaw_tools import call_tool
|
||||
|
||||
# Mock successful response
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps({
|
||||
"success": True,
|
||||
"output": "hello",
|
||||
"duration_ms": 5,
|
||||
"was_sanitized": False,
|
||||
}).encode("utf-8")
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
result = call_tool("echo", {"message": "hello"}, timeout_secs=30)
|
||||
|
||||
# Verify the request was made
|
||||
mock_urlopen.assert_called_once()
|
||||
call_args = mock_urlopen.call_args
|
||||
req = call_args[0][0] # First positional arg is the Request object
|
||||
|
||||
# Check URL
|
||||
self.assertIn("/worker/550e8400-e29b-41d4-a716-446655440000/tools/call", req.full_url)
|
||||
|
||||
# Check headers
|
||||
self.assertEqual(req.get_header("Content-type"), "application/json")
|
||||
self.assertEqual(req.get_header("Authorization"), "Bearer test-token-123")
|
||||
|
||||
# Check body
|
||||
body = json.loads(req.data.decode("utf-8"))
|
||||
self.assertEqual(body["tool_name"], "echo")
|
||||
self.assertEqual(body["parameters"], {"message": "hello"})
|
||||
self.assertEqual(body["timeout_secs"], 30)
|
||||
|
||||
# Check return value
|
||||
self.assertEqual(result, "hello")
|
||||
|
||||
|
||||
class TestCallToolHttpError(unittest.TestCase):
|
||||
"""Test HTTP error handling."""
|
||||
|
||||
def setUp(self):
|
||||
os.environ["IRONCLAW_ORCHESTRATOR_URL"] = "http://localhost:50051"
|
||||
os.environ["IRONCLAW_JOB_ID"] = "550e8400-e29b-41d4-a716-446655440000"
|
||||
os.environ["IRONCLAW_WORKER_TOKEN"] = "test-token-123"
|
||||
|
||||
def tearDown(self):
|
||||
for var in ["IRONCLAW_ORCHESTRATOR_URL", "IRONCLAW_JOB_ID", "IRONCLAW_WORKER_TOKEN"]:
|
||||
os.environ.pop(var, None)
|
||||
|
||||
@patch("ironclaw_tools.urllib.request.urlopen")
|
||||
def test_call_tool_http_error(self, mock_urlopen):
|
||||
from ironclaw_tools import call_tool
|
||||
|
||||
mock_urlopen.side_effect = urllib.error.HTTPError(
|
||||
url="http://localhost:50051/worker/test/tools/call",
|
||||
code=500,
|
||||
msg="Internal Server Error",
|
||||
hdrs=None,
|
||||
fp=None,
|
||||
)
|
||||
|
||||
with self.assertRaises(RuntimeError) as ctx:
|
||||
call_tool("echo", {"message": "hello"})
|
||||
|
||||
self.assertIn("500", str(ctx.exception))
|
||||
|
||||
|
||||
class TestConvenienceWrappers(unittest.TestCase):
|
||||
"""Test that convenience wrappers call call_tool correctly."""
|
||||
|
||||
def setUp(self):
|
||||
os.environ["IRONCLAW_ORCHESTRATOR_URL"] = "http://localhost:50051"
|
||||
os.environ["IRONCLAW_JOB_ID"] = "550e8400-e29b-41d4-a716-446655440000"
|
||||
os.environ["IRONCLAW_WORKER_TOKEN"] = "test-token-123"
|
||||
|
||||
def tearDown(self):
|
||||
for var in ["IRONCLAW_ORCHESTRATOR_URL", "IRONCLAW_JOB_ID", "IRONCLAW_WORKER_TOKEN"]:
|
||||
os.environ.pop(var, None)
|
||||
|
||||
@patch("ironclaw_tools.call_tool")
|
||||
def test_convenience_wrappers(self, mock_call_tool):
|
||||
from ironclaw_tools import shell, read_file, write_file, http_get
|
||||
|
||||
mock_call_tool.return_value = "output"
|
||||
|
||||
# Test shell
|
||||
shell("ls -la")
|
||||
mock_call_tool.assert_called_with("shell", {"command": "ls -la"}, timeout_secs=60)
|
||||
|
||||
# Test read_file
|
||||
read_file("/workspace/README.md")
|
||||
mock_call_tool.assert_called_with("read_file", {"path": "/workspace/README.md"})
|
||||
|
||||
# Test write_file
|
||||
write_file("/workspace/out.txt", "content")
|
||||
mock_call_tool.assert_called_with("write_file", {"path": "/workspace/out.txt", "content": "content"})
|
||||
|
||||
# Test http_get
|
||||
http_get("https://api.example.com/data")
|
||||
mock_call_tool.assert_called_with("http", {"url": "https://api.example.com/data", "method": "GET"}, timeout_secs=30)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user