diff --git a/README.md b/README.md
index 4bf5cf1..350e1b3 100644
--- a/README.md
+++ b/README.md
@@ -50,6 +50,20 @@ Deployment profiles and hardening checklists:
+Latest completion: automation composer endpoint, safer payload drafting, and full verification pass
+
+- Completed the automation payload composer flow for safe draft generation:
+ - added a new admin-only compose endpoint for trigger/webhook payload drafts (generate-only, no execution side effects)
+ - added strict server-side validation and normalization for trigger/webhook draft payloads
+ - added tool-calling schema support for automation payload composition with deterministic fallback behavior
+ - exposed composer capability flag for frontend/runtime feature probing
+ - added and extended backend tests for API handler, composer service, schema/validator coverage, and capability contract
+ - completed full validation gate pass (detect-secrets, pre-commit, backend test lanes, adversarial smoke gate, and frontend Playwright E2E)
+
+
+
+
+
Slack app support closeout: secure Events API ingress, connector parity, and no-skip verification lanes
- Completed Slack implementation hardening chain with full SOP validation:
diff --git a/api/assist.py b/api/assist.py
index bfcd290..9c784dd 100644
--- a/api/assist.py
+++ b/api/assist.py
@@ -5,6 +5,7 @@ from aiohttp import web
try:
from ..services.access_control import require_admin_token
from ..services.async_utils import run_in_thread
+ from ..services.automation_composer import AutomationComposerService
from ..services.planner import PlannerService
from ..services.rate_limit import check_rate_limit
from ..services.refiner import RefinerService
@@ -12,6 +13,7 @@ except ImportError:
# Fallback for ComfyUI's non-package loader or ad-hoc imports.
from services.access_control import require_admin_token
from services.async_utils import run_in_thread
+ from services.automation_composer import AutomationComposerService
from services.planner import PlannerService
from services.rate_limit import check_rate_limit
from services.refiner import RefinerService
@@ -44,6 +46,7 @@ class AssistHandlers:
def __init__(self):
self.planner = PlannerService()
self.refiner = RefinerService()
+ self.composer = AutomationComposerService()
@endpoint_metadata(
auth=AuthTier.ADMIN,
@@ -171,3 +174,96 @@ class AssistHandlers:
except Exception as e:
logger.exception("Refiner API failed")
return web.json_response({"error": "Internal server error"}, status=500)
+
+ @endpoint_metadata(
+ auth=AuthTier.ADMIN,
+ risk=RiskTier.MEDIUM,
+ summary="Compose automation payload",
+ description="Generate-only automation payload draft for trigger/webhook endpoints.",
+ audit="assist.compose",
+ plane=RoutePlane.ADMIN,
+ )
+ async def compose_handler(self, request):
+ """
+ POST /openclaw/assist/automation/compose (legacy: /moltbot/assist/automation/compose)
+ JSON:
+ {
+ kind: "trigger" | "webhook",
+ template_id: str,
+ intent: str,
+ inputs_hint?: object,
+ profile_id?: str,
+ require_approval?: bool,
+ trace_id?: str,
+ callback?: object
+ }
+ """
+ authorized, err_msg = require_admin_token(request)
+ if not authorized:
+ return web.json_response({"error": "Unauthorized"}, status=401)
+
+ if not check_rate_limit(request, "admin"):
+ return web.json_response({"error": "Rate limit exceeded"}, status=429)
+
+ try:
+ data = await request.json()
+ except Exception:
+ return web.json_response({"error": "Invalid JSON"}, status=400)
+
+ kind = data.get("kind")
+ template_id = data.get("template_id")
+ intent = data.get("intent")
+ inputs_hint = data.get("inputs_hint", {})
+ profile_id = data.get("profile_id")
+ require_approval = data.get("require_approval")
+ trace_id = data.get("trace_id")
+ callback = data.get("callback")
+
+ if not isinstance(kind, str) or kind.strip().lower() not in {
+ "trigger",
+ "webhook",
+ }:
+ return web.json_response(
+ {"error": "kind must be 'trigger' or 'webhook'"}, status=400
+ )
+ if not isinstance(template_id, str) or not template_id.strip():
+ return web.json_response({"error": "template_id is required"}, status=400)
+ if not isinstance(intent, str) or not intent.strip():
+ return web.json_response({"error": "intent is required"}, status=400)
+ if len(intent) > MAX_REQUIREMENTS_LEN:
+ return web.json_response(
+ {"error": f"intent exceeds {MAX_REQUIREMENTS_LEN} chars"}, status=400
+ )
+ if not isinstance(inputs_hint, dict):
+ return web.json_response(
+ {"error": "inputs_hint must be object"}, status=400
+ )
+ if profile_id is not None and not isinstance(profile_id, str):
+ return web.json_response({"error": "profile_id must be string"}, status=400)
+ if require_approval is not None and not isinstance(require_approval, bool):
+ return web.json_response(
+ {"error": "require_approval must be boolean"}, status=400
+ )
+ if trace_id is not None and not isinstance(trace_id, str):
+ return web.json_response({"error": "trace_id must be string"}, status=400)
+ if callback is not None and not isinstance(callback, dict):
+ return web.json_response({"error": "callback must be object"}, status=400)
+
+ try:
+ result = await run_in_thread(
+ self.composer.compose_payload,
+ kind=kind,
+ template_id=template_id,
+ intent=intent,
+ inputs_hint=inputs_hint,
+ profile_id=profile_id,
+ require_approval=require_approval,
+ trace_id=trace_id,
+ callback=callback,
+ )
+ return web.json_response({"ok": True, **result})
+ except ValueError as e:
+ return web.json_response({"error": str(e)}, status=400)
+ except Exception:
+ logger.exception("Automation compose API failed")
+ return web.json_response({"error": "Internal server error"}, status=500)
diff --git a/api/routes.py b/api/routes.py
index b9d1a4b..7a0cd06 100644
--- a/api/routes.py
+++ b/api/routes.py
@@ -774,6 +774,12 @@ def register_routes(server) -> None:
register_dual_route(
server, "POST", f"{prefix}/assist/refiner", assist.refiner_handler
)
+ register_dual_route(
+ server,
+ "POST",
+ f"{prefix}/assist/automation/compose",
+ assist.compose_handler,
+ )
# F10 Bridge Routes (Sidecar)
# R84 Boot Boundary: BRIDGE
diff --git a/services/automation_composer.py b/services/automation_composer.py
new file mode 100644
index 0000000..42b32ee
--- /dev/null
+++ b/services/automation_composer.py
@@ -0,0 +1,282 @@
+"""
+F25 Phase B: Automation Payload Composer Service.
+
+Generates safe, validated payload drafts for:
+- /openclaw/triggers/fire
+- /openclaw/webhook/submit
+
+This service is generate-only and never executes workflows.
+"""
+
+import copy
+import json
+import logging
+import os
+from typing import Any, Dict, List, Optional
+
+from .llm_client import LLMClient
+from .templates import is_template_allowed
+
+try:
+ from .tool_calling import (
+ TOOL_CALLING_AVAILABLE,
+ TRIGGER_TOOL_SCHEMA,
+ WEBHOOK_TOOL_SCHEMA,
+ extract_tool_call_by_name,
+ validate_trigger_request,
+ validate_webhook_request,
+ )
+except Exception:
+ TOOL_CALLING_AVAILABLE = False
+
+logger = logging.getLogger("ComfyUI-OpenClaw.services.automation_composer")
+
+MAX_INTENT_LEN = 4000
+
+
+class AutomationComposerService:
+ """Compose validated automation payload drafts without side effects."""
+
+ def __init__(self):
+ self.llm_client = LLMClient()
+
+ def compose_payload(
+ self,
+ *,
+ kind: str,
+ template_id: str,
+ intent: str,
+ inputs_hint: Optional[Dict[str, Any]] = None,
+ profile_id: Optional[str] = None,
+ require_approval: Optional[bool] = None,
+ trace_id: Optional[str] = None,
+ callback: Optional[Dict[str, Any]] = None,
+ ) -> Dict[str, Any]:
+ normalized_kind = str(kind or "").strip().lower()
+ if normalized_kind not in {"trigger", "webhook"}:
+ raise ValueError("kind must be 'trigger' or 'webhook'")
+
+ if not isinstance(template_id, str) or not template_id.strip():
+ raise ValueError("template_id is required")
+ template_id = template_id.strip()
+ if len(template_id) > 64:
+ raise ValueError("template_id exceeds max length (64)")
+ if not is_template_allowed(template_id):
+ raise ValueError(f"template_id '{template_id}' not found")
+
+ if not isinstance(intent, str) or not intent.strip():
+ raise ValueError("intent is required")
+ if len(intent) > MAX_INTENT_LEN:
+ raise ValueError(f"intent exceeds {MAX_INTENT_LEN} chars")
+
+ if inputs_hint is None:
+ inputs_hint = {}
+ if not isinstance(inputs_hint, dict):
+ raise ValueError("inputs_hint must be an object")
+
+ fallback_payload = self._build_fallback_payload(
+ kind=normalized_kind,
+ template_id=template_id,
+ inputs_hint=inputs_hint,
+ profile_id=profile_id,
+ require_approval=require_approval,
+ trace_id=trace_id,
+ callback=callback,
+ )
+
+ warnings: List[str] = []
+ used_tool_calling = False
+
+ if (
+ TOOL_CALLING_AVAILABLE
+ and os.getenv("OPENCLAW_ENABLE_TOOL_CALLING", "0") == "1"
+ ):
+ candidate = self._try_tool_call_compose(
+ kind=normalized_kind,
+ template_id=template_id,
+ profile_id=profile_id,
+ intent=intent,
+ inputs_hint=inputs_hint,
+ fallback_payload=fallback_payload,
+ )
+ if candidate.get("payload") is not None:
+ used_tool_calling = True
+ warnings.extend(candidate.get("warnings", []))
+ return {
+ "kind": normalized_kind,
+ "payload": candidate["payload"],
+ "warnings": warnings,
+ "used_tool_calling": used_tool_calling,
+ }
+ warnings.extend(candidate.get("warnings", []))
+
+ validated = self._validate_payload(normalized_kind, fallback_payload)
+
+ return {
+ "kind": normalized_kind,
+ "payload": validated,
+ "warnings": warnings,
+ "used_tool_calling": used_tool_calling,
+ }
+
+ def _build_fallback_payload(
+ self,
+ *,
+ kind: str,
+ template_id: str,
+ inputs_hint: Dict[str, Any],
+ profile_id: Optional[str],
+ require_approval: Optional[bool],
+ trace_id: Optional[str],
+ callback: Optional[Dict[str, Any]],
+ ) -> Dict[str, Any]:
+ payload: Dict[str, Any]
+ if kind == "trigger":
+ payload = {
+ "template_id": template_id,
+ "inputs": inputs_hint,
+ "require_approval": bool(require_approval),
+ }
+ if trace_id is not None:
+ payload["trace_id"] = trace_id
+ if callback is not None:
+ payload["callback"] = callback
+ return payload
+
+ payload = {
+ "version": 1,
+ "template_id": template_id,
+ "profile_id": profile_id or "default",
+ "inputs": inputs_hint,
+ }
+ if trace_id is not None:
+ payload["trace_id"] = trace_id
+ if callback is not None:
+ payload["callback"] = callback
+ return payload
+
+ def _try_tool_call_compose(
+ self,
+ *,
+ kind: str,
+ template_id: str,
+ profile_id: Optional[str],
+ intent: str,
+ inputs_hint: Dict[str, Any],
+ fallback_payload: Dict[str, Any],
+ ) -> Dict[str, Any]:
+ if kind == "trigger":
+ schema = TRIGGER_TOOL_SCHEMA
+ tool_name = "openclaw_trigger_request"
+ else:
+ schema = WEBHOOK_TOOL_SCHEMA
+ tool_name = "openclaw_webhook_request"
+
+ warnings: List[str] = []
+
+ try:
+ try:
+ from .schema_sanitizer import sanitize_tools
+
+ tools = sanitize_tools([schema])
+ except Exception:
+ tools = [schema]
+
+ system = self._build_system_prompt(kind, template_id, profile_id)
+ user = self._build_user_prompt(intent, inputs_hint)
+
+ response = self.llm_client.complete(
+ system=system,
+ user_message=user,
+ tools=tools,
+ tool_choice="auto",
+ )
+
+ tool_args, tool_error = extract_tool_call_by_name(
+ response.get("raw", {}), tool_name
+ )
+ if tool_error:
+ warnings.append(f"tool_call_fallback: {tool_error}")
+ return {"payload": None, "warnings": warnings}
+
+ candidate = self._merge_tool_args(
+ kind=kind,
+ fallback_payload=fallback_payload,
+ tool_args=tool_args or {},
+ )
+ validated = self._validate_payload(kind, candidate)
+ return {"payload": validated, "warnings": warnings}
+
+ except Exception as e:
+ logger.warning(f"F25 compose tool-call fallback: {e}")
+ warnings.append(f"tool_call_fallback: {e}")
+ return {"payload": None, "warnings": warnings}
+
+ def _merge_tool_args(
+ self,
+ *,
+ kind: str,
+ fallback_payload: Dict[str, Any],
+ tool_args: Dict[str, Any],
+ ) -> Dict[str, Any]:
+ merged = copy.deepcopy(fallback_payload)
+ if not isinstance(tool_args, dict):
+ return merged
+
+ if isinstance(tool_args.get("inputs"), dict):
+ merged["inputs"] = tool_args.get("inputs")
+
+ if kind == "trigger":
+ if isinstance(tool_args.get("require_approval"), bool):
+ merged["require_approval"] = tool_args.get("require_approval")
+ if "trace_id" in tool_args:
+ merged["trace_id"] = tool_args.get("trace_id")
+ if isinstance(tool_args.get("callback"), dict):
+ merged["callback"] = tool_args.get("callback")
+ return merged
+
+ if "version" in tool_args:
+ merged["version"] = tool_args.get("version")
+ if "job_id" in tool_args:
+ merged["job_id"] = tool_args.get("job_id")
+ if "trace_id" in tool_args:
+ merged["trace_id"] = tool_args.get("trace_id")
+ if isinstance(tool_args.get("callback"), dict):
+ merged["callback"] = tool_args.get("callback")
+ return merged
+
+ def _validate_payload(self, kind: str, payload: Dict[str, Any]) -> Dict[str, Any]:
+ if kind == "trigger":
+ validated, error = validate_trigger_request(payload)
+ else:
+ validated, error = validate_webhook_request(payload)
+
+ if error or validated is None:
+ raise ValueError(error or "invalid compose payload")
+
+ return validated
+
+ @staticmethod
+ def _build_system_prompt(
+ kind: str, template_id: str, profile_id: Optional[str]
+ ) -> str:
+ if kind == "trigger":
+ return (
+ "You are composing a SAFE draft payload for /openclaw/triggers/fire. "
+ "Return only tool arguments. Never include secrets. Keep template_id unchanged. "
+ f"Target template_id: {template_id}."
+ )
+
+ return (
+ "You are composing a SAFE draft payload for /openclaw/webhook/submit. "
+ "Return only tool arguments. Never include secrets. Keep template_id/profile_id unchanged unless missing. "
+ f"Target template_id: {template_id}. Target profile_id: {profile_id or 'default'}."
+ )
+
+ @staticmethod
+ def _build_user_prompt(intent: str, inputs_hint: Dict[str, Any]) -> str:
+ return (
+ "Compose a payload draft from this intent and hints.\\n"
+ f"Intent: {intent}\\n"
+ f"Inputs hint: {json.dumps(inputs_hint, ensure_ascii=False)}"
+ )
diff --git a/services/capabilities.py b/services/capabilities.py
index a53b57a..4e399a1 100644
--- a/services/capabilities.py
+++ b/services/capabilities.py
@@ -68,6 +68,7 @@ def get_capabilities() -> dict:
"approvals": True,
"assist_planner": True,
"assist_refiner": True,
+ "assist_automation_compose": True,
"scheduler": True,
"triggers": True,
"packs": True,
diff --git a/services/tool_calling.py b/services/tool_calling.py
index d54886c..dd00620 100644
--- a/services/tool_calling.py
+++ b/services/tool_calling.py
@@ -11,12 +11,14 @@ Also provides helpers for safe tool call extraction.
import json
import logging
+import re
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger("ComfyUI-OpenClaw.services.tool_calling")
# F25: Maximum size for tool call arguments (prevent DoS)
MAX_TOOL_ARGS_BYTES = 65536 # 64 KB
+TOOL_CALLING_AVAILABLE = True
# F25: Keep tool calling params aligned with the runtime GenerationParams schema.
GENERATION_PARAM_KEYS = {
@@ -110,6 +112,87 @@ REFINER_TOOL_SCHEMA = {
},
}
+# F25 Phase B: Automation payload composer tool schemas
+TRIGGER_TOOL_SCHEMA = {
+ "type": "function",
+ "function": {
+ "name": "openclaw_trigger_request",
+ "description": "Compose a safe trigger fire payload for /openclaw/triggers/fire (generate-only, no execution)",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "template_id": {"type": "string"},
+ "inputs": {
+ "type": "object",
+ "properties": {
+ "requirements": {"type": "string"},
+ "goal": {"type": "string"},
+ "seed": {"type": "integer"},
+ "positive_prompt": {"type": "string"},
+ "negative_prompt": {"type": "string"},
+ },
+ "additionalProperties": False,
+ },
+ "require_approval": {"type": "boolean"},
+ "trace_id": {"type": "string"},
+ "callback": {
+ "type": "object",
+ "properties": {
+ "url": {"type": "string"},
+ "method": {"type": "string"},
+ "headers": {"type": "object"},
+ "mode": {"type": "string"},
+ },
+ "additionalProperties": False,
+ },
+ },
+ "required": ["template_id"],
+ "additionalProperties": False,
+ },
+ },
+}
+
+WEBHOOK_TOOL_SCHEMA = {
+ "type": "function",
+ "function": {
+ "name": "openclaw_webhook_request",
+ "description": "Compose a safe webhook submit payload for /openclaw/webhook/submit (generate-only, no execution)",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "version": {"type": "integer"},
+ "template_id": {"type": "string"},
+ "profile_id": {"type": "string"},
+ "inputs": {
+ "type": "object",
+ "properties": {
+ "requirements": {"type": "string"},
+ "goal": {"type": "string"},
+ "seed": {"type": "integer"},
+ "positive_prompt": {"type": "string"},
+ "negative_prompt": {"type": "string"},
+ },
+ "additionalProperties": False,
+ },
+ "job_id": {"type": "string"},
+ "trace_id": {"type": "string"},
+ "callback": {
+ "type": "object",
+ "properties": {
+ "url": {"type": "string"},
+ "method": {"type": "string"},
+ "headers": {"type": "object"},
+ "mode": {"type": "string"},
+ },
+ "additionalProperties": False,
+ },
+ },
+ "required": ["template_id", "profile_id"],
+ "additionalProperties": False,
+ },
+ },
+}
+
# ========== Tool Call Extraction Helpers ==========
@@ -241,6 +324,52 @@ def _normalize_generation_params(data: Any) -> Dict[str, Any]:
return normalized
+AUTOMATION_INPUT_KEYS = {
+ "requirements",
+ "goal",
+ "seed",
+ "positive_prompt",
+ "negative_prompt",
+}
+_TRACE_ID_RE = re.compile(r"^[a-zA-Z0-9_-]+$")
+_ALLOWED_CALLBACK_KEYS = {"url", "method", "headers", "mode"}
+
+
+def _normalize_automation_inputs(data: Any) -> Dict[str, Any]:
+ if not isinstance(data, dict):
+ return {}
+
+ normalized: Dict[str, Any] = {}
+ for key in AUTOMATION_INPUT_KEYS:
+ if key in data:
+ value = data.get(key)
+ if isinstance(value, (str, int, float, bool)):
+ normalized[key] = value
+ return normalized
+
+
+def _normalize_callback(data: Any) -> Optional[Dict[str, Any]]:
+ if not isinstance(data, dict):
+ return None
+ callback: Dict[str, Any] = {}
+ for key in _ALLOWED_CALLBACK_KEYS:
+ if key in data:
+ callback[key] = data[key]
+ return callback or None
+
+
+def _validate_trace_id(value: Any) -> Tuple[Optional[str], Optional[str]]:
+ if value is None:
+ return None, None
+ if not isinstance(value, str):
+ return None, "trace_id must be string"
+ if len(value) > 64:
+ return None, "trace_id exceeds max length (64)"
+ if not _TRACE_ID_RE.match(value):
+ return None, "trace_id contains invalid characters"
+ return value, None
+
+
def validate_planner_output(
tool_args: Dict[str, Any],
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
@@ -326,3 +455,94 @@ def validate_refiner_output(
"param_patch": patch_dict,
"rationale": rationale,
}, None
+
+
+def validate_trigger_request(
+ tool_args: Dict[str, Any],
+) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
+ """
+ Validate trigger compose payload arguments.
+ """
+ if not isinstance(tool_args, dict):
+ return None, "tool arguments must be dict"
+
+ template_id = tool_args.get("template_id")
+ if not isinstance(template_id, str) or not template_id.strip():
+ return None, "template_id is required"
+ template_id = template_id.strip()
+ if len(template_id) > 64:
+ return None, "template_id exceeds max length (64)"
+
+ require_approval = tool_args.get("require_approval", False)
+ if not isinstance(require_approval, bool):
+ require_approval = False
+
+ trace_id, trace_error = _validate_trace_id(tool_args.get("trace_id"))
+ if trace_error:
+ return None, trace_error
+
+ payload: Dict[str, Any] = {
+ "template_id": template_id,
+ "inputs": _normalize_automation_inputs(tool_args.get("inputs", {})),
+ "require_approval": require_approval,
+ }
+
+ if trace_id:
+ payload["trace_id"] = trace_id
+
+ callback = _normalize_callback(tool_args.get("callback"))
+ if callback:
+ payload["callback"] = callback
+
+ return payload, None
+
+
+def validate_webhook_request(
+ tool_args: Dict[str, Any],
+) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
+ """
+ Validate webhook compose payload arguments against WebhookJobRequest schema.
+ """
+ if not isinstance(tool_args, dict):
+ return None, "tool arguments must be dict"
+
+ template_id = tool_args.get("template_id")
+ if not isinstance(template_id, str) or not template_id.strip():
+ return None, "template_id is required"
+ profile_id = tool_args.get("profile_id")
+ if not isinstance(profile_id, str) or not profile_id.strip():
+ return None, "profile_id is required"
+
+ candidate: Dict[str, Any] = {
+ "version": 1,
+ "template_id": template_id.strip(),
+ "profile_id": profile_id.strip(),
+ "inputs": _normalize_automation_inputs(tool_args.get("inputs", {})),
+ }
+
+ if "version" in tool_args:
+ candidate["version"] = tool_args.get("version")
+
+ if "job_id" in tool_args:
+ candidate["job_id"] = tool_args.get("job_id")
+
+ trace_id, trace_error = _validate_trace_id(tool_args.get("trace_id"))
+ if trace_error:
+ return None, trace_error
+ if trace_id:
+ candidate["trace_id"] = trace_id
+
+ callback = _normalize_callback(tool_args.get("callback"))
+ if callback:
+ candidate["callback"] = callback
+
+ try:
+ try:
+ from ..models.schemas import WebhookJobRequest
+ except ImportError:
+ from models.schemas import WebhookJobRequest
+
+ validated = WebhookJobRequest.from_dict(candidate).to_normalized()
+ return validated, None
+ except Exception as e:
+ return None, f"invalid webhook request: {e}"
diff --git a/tests/test_api_assist.py b/tests/test_api_assist.py
index 26927cf..b3c7055 100644
--- a/tests/test_api_assist.py
+++ b/tests/test_api_assist.py
@@ -27,6 +27,7 @@ class TestAssistAPI(unittest.IsolatedAsyncioTestCase):
# Mock services to avoid LLM calls
self.handler.planner = MagicMock()
self.handler.refiner = MagicMock()
+ self.handler.composer = MagicMock()
async def test_planner_no_auth(self):
"""Test that planner rejects unauthenticated requests."""
@@ -112,6 +113,72 @@ class TestAssistAPI(unittest.IsolatedAsyncioTestCase):
self.assertEqual(body["refined_positive"], "new_pos")
self.assertEqual(body["rationale"], "Fixed hands")
+ async def test_compose_no_auth(self):
+ """Test compose rejects unauthenticated requests."""
+ request = AsyncMock()
+ request.headers = {}
+
+ with patch("api.assist.require_admin_token", return_value=(False, "Denied")):
+ resp = await self.handler.compose_handler(request)
+ self.assertEqual(resp.status, 401)
+
+ async def test_compose_invalid_kind(self):
+ """Test compose validates kind field."""
+ request = AsyncMock()
+ request.json = AsyncMock(
+ return_value={
+ "kind": "unknown",
+ "template_id": "portrait_v1",
+ "intent": "make draft",
+ }
+ )
+
+ with patch("api.assist.require_admin_token", return_value=(True, None)):
+ resp = await self.handler.compose_handler(request)
+ self.assertEqual(resp.status, 400)
+ body = json.loads(resp.body)
+ self.assertIn("kind must be", body["error"])
+
+ async def test_compose_success(self):
+ """Test compose returns draft payload on success."""
+ request = AsyncMock()
+ request.json = AsyncMock(
+ return_value={
+ "kind": "webhook",
+ "template_id": "portrait_v1",
+ "profile_id": "SDXL-v1",
+ "intent": "render portrait with soft light",
+ "inputs_hint": {"requirements": "portrait"},
+ "trace_id": "trace_123",
+ }
+ )
+
+ with (
+ patch("api.assist.require_admin_token", return_value=(True, None)),
+ patch("api.assist.run_in_thread") as mock_run_in_thread,
+ ):
+ mock_run_in_thread.return_value = {
+ "kind": "webhook",
+ "payload": {
+ "version": 1,
+ "template_id": "portrait_v1",
+ "profile_id": "SDXL-v1",
+ "inputs": {"requirements": "portrait"},
+ "trace_id": "trace_123",
+ "job_id": None,
+ "callback": None,
+ },
+ "warnings": [],
+ "used_tool_calling": False,
+ }
+
+ resp = await self.handler.compose_handler(request)
+ self.assertEqual(resp.status, 200)
+ body = json.loads(resp.body)
+ self.assertTrue(body["ok"])
+ self.assertEqual(body["kind"], "webhook")
+ self.assertEqual(body["payload"]["template_id"], "portrait_v1")
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_automation_composer.py b/tests/test_automation_composer.py
new file mode 100644
index 0000000..9379b78
--- /dev/null
+++ b/tests/test_automation_composer.py
@@ -0,0 +1,155 @@
+"""
+F25 Phase B: Tests for automation payload composer service.
+"""
+
+import json
+import os
+import unittest
+from unittest.mock import patch
+
+from services.automation_composer import AutomationComposerService
+
+
+def _make_tool_call_raw(function_name: str, arguments_obj: dict) -> dict:
+ return {
+ "choices": [
+ {
+ "message": {
+ "tool_calls": [
+ {
+ "type": "function",
+ "function": {
+ "name": function_name,
+ "arguments": json.dumps(arguments_obj),
+ },
+ }
+ ]
+ }
+ }
+ ]
+ }
+
+
+class _FakeLLMClient:
+ def __init__(self, response):
+ self._response = response
+
+ def complete(self, *args, **kwargs):
+ return self._response
+
+
+class TestAutomationComposerService(unittest.TestCase):
+ def test_trigger_fallback_payload(self):
+ svc = AutomationComposerService()
+ with patch(
+ "services.automation_composer.is_template_allowed", return_value=True
+ ):
+ result = svc.compose_payload(
+ kind="trigger",
+ template_id="portrait_v1",
+ intent="render portrait draft",
+ inputs_hint={"requirements": "portrait", "junk": {"drop": True}},
+ require_approval=True,
+ trace_id="trace_1",
+ )
+
+ self.assertEqual(result["kind"], "trigger")
+ self.assertFalse(result["used_tool_calling"])
+ self.assertEqual(result["payload"]["template_id"], "portrait_v1")
+ self.assertEqual(result["payload"]["inputs"], {"requirements": "portrait"})
+ self.assertTrue(result["payload"]["require_approval"])
+ self.assertEqual(result["payload"]["trace_id"], "trace_1")
+
+ def test_webhook_fallback_defaults_profile(self):
+ svc = AutomationComposerService()
+ with patch(
+ "services.automation_composer.is_template_allowed", return_value=True
+ ):
+ result = svc.compose_payload(
+ kind="webhook",
+ template_id="portrait_v1",
+ intent="render portrait draft",
+ inputs_hint={"requirements": "portrait"},
+ )
+
+ self.assertEqual(result["kind"], "webhook")
+ self.assertEqual(result["payload"]["version"], 1)
+ self.assertEqual(result["payload"]["profile_id"], "default")
+ self.assertEqual(result["payload"]["inputs"], {"requirements": "portrait"})
+
+ def test_compose_rejects_unknown_template(self):
+ svc = AutomationComposerService()
+ with patch(
+ "services.automation_composer.is_template_allowed", return_value=False
+ ):
+ with self.assertRaises(ValueError) as ctx:
+ svc.compose_payload(
+ kind="trigger",
+ template_id="missing_template",
+ intent="anything",
+ )
+ self.assertIn("not found", str(ctx.exception))
+
+ def test_tool_calling_success(self):
+ response = {
+ "text": "",
+ "raw": _make_tool_call_raw(
+ "openclaw_trigger_request",
+ {
+ "template_id": "portrait_v1",
+ "inputs": {"requirements": "portrait"},
+ "require_approval": True,
+ },
+ ),
+ }
+ svc = AutomationComposerService()
+ svc.llm_client = _FakeLLMClient(response)
+
+ with (
+ patch(
+ "services.automation_composer.is_template_allowed", return_value=True
+ ),
+ patch("services.automation_composer.TOOL_CALLING_AVAILABLE", True),
+ patch.dict(os.environ, {"OPENCLAW_ENABLE_TOOL_CALLING": "1"}),
+ ):
+ result = svc.compose_payload(
+ kind="trigger",
+ template_id="portrait_v1",
+ intent="render portrait draft",
+ inputs_hint={"requirements": "from-fallback"},
+ require_approval=False,
+ )
+
+ self.assertTrue(result["used_tool_calling"])
+ self.assertEqual(result["payload"]["inputs"], {"requirements": "portrait"})
+ self.assertTrue(result["payload"]["require_approval"])
+ self.assertEqual(result["warnings"], [])
+
+ def test_tool_calling_missing_tool_falls_back(self):
+ response = {"text": "", "raw": {"choices": [{"message": {"content": "plain"}}]}}
+ svc = AutomationComposerService()
+ svc.llm_client = _FakeLLMClient(response)
+
+ with (
+ patch(
+ "services.automation_composer.is_template_allowed", return_value=True
+ ),
+ patch("services.automation_composer.TOOL_CALLING_AVAILABLE", True),
+ patch.dict(os.environ, {"OPENCLAW_ENABLE_TOOL_CALLING": "1"}),
+ ):
+ result = svc.compose_payload(
+ kind="webhook",
+ template_id="portrait_v1",
+ intent="render portrait draft",
+ profile_id="SDXL-v1",
+ inputs_hint={"requirements": "from-fallback"},
+ )
+
+ self.assertFalse(result["used_tool_calling"])
+ self.assertEqual(result["payload"]["profile_id"], "SDXL-v1")
+ self.assertEqual(result["payload"]["inputs"], {"requirements": "from-fallback"})
+ self.assertTrue(any("tool_call_fallback" in w for w in result["warnings"]))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py
index d84b423..14beb2a 100644
--- a/tests/test_capabilities.py
+++ b/tests/test_capabilities.py
@@ -40,6 +40,7 @@ class TestCapabilities(unittest.TestCase):
"doctor",
"job_monitor",
"callback_delivery",
+ "assist_automation_compose",
]
for feat in expected_features:
self.assertIn(feat, features, f"Missing feature: {feat}")
diff --git a/tests/test_f25_tool_calling.py b/tests/test_f25_tool_calling.py
index f45d02d..6f4b820 100644
--- a/tests/test_f25_tool_calling.py
+++ b/tests/test_f25_tool_calling.py
@@ -9,11 +9,15 @@ from services.tool_calling import (
MAX_TOOL_ARGS_BYTES,
PLANNER_TOOL_SCHEMA,
REFINER_TOOL_SCHEMA,
+ TRIGGER_TOOL_SCHEMA,
+ WEBHOOK_TOOL_SCHEMA,
extract_tool_call_by_name,
extract_tool_calls,
parse_tool_arguments,
validate_planner_output,
validate_refiner_output,
+ validate_trigger_request,
+ validate_webhook_request,
)
@@ -295,6 +299,70 @@ class TestSchemas(unittest.TestCase):
REFINER_TOOL_SCHEMA["function"]["name"], "openclaw_refiner_output"
)
+ def test_trigger_schema_valid(self):
+ """Trigger schema should be valid JSON"""
+ schema_str = json.dumps(TRIGGER_TOOL_SCHEMA)
+ self.assertGreater(len(schema_str), 100)
+ self.assertEqual(TRIGGER_TOOL_SCHEMA["type"], "function")
+ self.assertEqual(
+ TRIGGER_TOOL_SCHEMA["function"]["name"], "openclaw_trigger_request"
+ )
+
+ def test_webhook_schema_valid(self):
+ """Webhook schema should be valid JSON"""
+ schema_str = json.dumps(WEBHOOK_TOOL_SCHEMA)
+ self.assertGreater(len(schema_str), 100)
+ self.assertEqual(WEBHOOK_TOOL_SCHEMA["type"], "function")
+ self.assertEqual(
+ WEBHOOK_TOOL_SCHEMA["function"]["name"], "openclaw_webhook_request"
+ )
+
+
+class TestValidateAutomationRequests(unittest.TestCase):
+ def test_validate_trigger_request_success(self):
+ args = {
+ "template_id": "portrait_v1",
+ "inputs": {"requirements": "portrait", "unknown": "drop-me"},
+ "require_approval": True,
+ "trace_id": "trace_123",
+ "callback": {"url": "https://example.com/cb", "foo": "drop"},
+ }
+
+ validated, error = validate_trigger_request(args)
+ self.assertIsNone(error)
+ self.assertEqual(validated["template_id"], "portrait_v1")
+ self.assertEqual(validated["inputs"], {"requirements": "portrait"})
+ self.assertTrue(validated["require_approval"])
+ self.assertEqual(validated["trace_id"], "trace_123")
+ self.assertEqual(validated["callback"], {"url": "https://example.com/cb"})
+
+ def test_validate_trigger_request_invalid_trace_id(self):
+ args = {"template_id": "portrait_v1", "trace_id": "bad trace id"}
+ validated, error = validate_trigger_request(args)
+ self.assertIsNone(validated)
+ self.assertIn("trace_id contains invalid characters", error)
+
+ def test_validate_webhook_request_success(self):
+ args = {
+ "template_id": "portrait_v1",
+ "profile_id": "SDXL-v1",
+ "inputs": {"requirements": "portrait", "unknown": "drop-me"},
+ "trace_id": "trace_ok_1",
+ }
+ validated, error = validate_webhook_request(args)
+ self.assertIsNone(error)
+ self.assertEqual(validated["version"], 1)
+ self.assertEqual(validated["template_id"], "portrait_v1")
+ self.assertEqual(validated["profile_id"], "SDXL-v1")
+ self.assertEqual(validated["inputs"], {"requirements": "portrait"})
+ self.assertEqual(validated["trace_id"], "trace_ok_1")
+
+ def test_validate_webhook_request_missing_profile(self):
+ args = {"template_id": "portrait_v1"}
+ validated, error = validate_webhook_request(args)
+ self.assertIsNone(validated)
+ self.assertIn("profile_id is required", error)
+
if __name__ == "__main__":
unittest.main()