mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +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,155 @@
|
||||
"""IronClaw Programmatic Tool Calling SDK for container scripts.
|
||||
|
||||
Thin wrapper using only Python stdlib. Reads connection details from
|
||||
environment variables injected by the orchestrator:
|
||||
|
||||
IRONCLAW_ORCHESTRATOR_URL - Base URL of the orchestrator API
|
||||
IRONCLAW_JOB_ID - UUID of the current job
|
||||
IRONCLAW_WORKER_TOKEN - Bearer token scoped to this job
|
||||
|
||||
Usage:
|
||||
from ironclaw_tools import call_tool, shell, read_file, write_file, http_get
|
||||
|
||||
# Call any registered tool by name
|
||||
result = call_tool("echo", {"message": "hello"})
|
||||
print(result) # "hello"
|
||||
|
||||
# Convenience wrappers
|
||||
output = shell("ls -la")
|
||||
content = read_file("/workspace/README.md")
|
||||
write_file("/workspace/output.txt", "results here")
|
||||
body = http_get("https://api.example.com/data")
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
|
||||
def _env(name):
|
||||
"""Get a required environment variable."""
|
||||
value = os.environ.get(name)
|
||||
if not value:
|
||||
raise RuntimeError(
|
||||
f"Missing required environment variable: {name}. "
|
||||
"This SDK must be run inside an IronClaw container."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _base_url():
|
||||
"""Build the base URL for tool call requests."""
|
||||
orchestrator = _env("IRONCLAW_ORCHESTRATOR_URL").rstrip("/")
|
||||
job_id = _env("IRONCLAW_JOB_ID")
|
||||
return f"{orchestrator}/worker/{job_id}"
|
||||
|
||||
|
||||
def _token():
|
||||
"""Get the bearer token."""
|
||||
return _env("IRONCLAW_WORKER_TOKEN")
|
||||
|
||||
|
||||
def call_tool(name, params=None, timeout_secs=None):
|
||||
"""Call a tool on the orchestrator by name.
|
||||
|
||||
Args:
|
||||
name: Tool name (e.g., "echo", "shell", "read_file").
|
||||
params: Dictionary of parameters to pass to the tool.
|
||||
timeout_secs: Optional timeout in seconds (max 300).
|
||||
|
||||
Returns:
|
||||
Tool output as a string.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the tool call fails.
|
||||
"""
|
||||
url = f"{_base_url()}/tools/call"
|
||||
body = {
|
||||
"tool_name": name,
|
||||
"parameters": params or {},
|
||||
}
|
||||
if timeout_secs is not None:
|
||||
body["timeout_secs"] = min(int(timeout_secs), 300)
|
||||
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {_token()}",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=max(timeout_secs or 60, 60) + 5) as resp:
|
||||
result = json.loads(resp.read().decode("utf-8"))
|
||||
except urllib.error.HTTPError as e:
|
||||
body_text = e.read().decode("utf-8", errors="replace") if e.fp else ""
|
||||
raise RuntimeError(
|
||||
f"Tool call failed: HTTP {e.code}: {body_text}"
|
||||
) from None
|
||||
except urllib.error.URLError as e:
|
||||
raise RuntimeError(f"Connection to orchestrator failed: {e.reason}") from None
|
||||
|
||||
if not result.get("success"):
|
||||
raise RuntimeError(f"Tool '{name}' failed: {result.get('error', 'unknown error')}")
|
||||
|
||||
return result.get("output", "")
|
||||
|
||||
|
||||
def shell(command, timeout_secs=60):
|
||||
"""Execute a shell command via the orchestrator.
|
||||
|
||||
Args:
|
||||
command: Shell command string to execute.
|
||||
timeout_secs: Timeout in seconds (default 60).
|
||||
|
||||
Returns:
|
||||
Command output as a string.
|
||||
"""
|
||||
return call_tool("shell", {"command": command}, timeout_secs=timeout_secs)
|
||||
|
||||
|
||||
def read_file(path):
|
||||
"""Read a file via the orchestrator.
|
||||
|
||||
Args:
|
||||
path: Absolute path to the file.
|
||||
|
||||
Returns:
|
||||
File contents as a string.
|
||||
"""
|
||||
return call_tool("read_file", {"path": path})
|
||||
|
||||
|
||||
def write_file(path, content):
|
||||
"""Write a file via the orchestrator.
|
||||
|
||||
Args:
|
||||
path: Absolute path to write to.
|
||||
content: String content to write.
|
||||
|
||||
Returns:
|
||||
Write confirmation message.
|
||||
"""
|
||||
return call_tool("write_file", {"path": path, "content": content})
|
||||
|
||||
|
||||
def http_get(url, headers=None, timeout_secs=30):
|
||||
"""Make an HTTP GET request via the orchestrator's HTTP tool.
|
||||
|
||||
Args:
|
||||
url: URL to fetch.
|
||||
headers: Optional dictionary of headers.
|
||||
timeout_secs: Timeout in seconds (default 30).
|
||||
|
||||
Returns:
|
||||
Response body as a string.
|
||||
"""
|
||||
params = {"url": url, "method": "GET"}
|
||||
if headers:
|
||||
params["headers"] = headers
|
||||
return call_tool("http", params, timeout_secs=timeout_secs)
|
||||
@@ -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