feat: add pipeline engine with approval flow and file triggers

Sequential step executor (script, claude_prompt, approval, api_call,
template, skyvern placeholder), reaction-based approvals, file upload
trigger matching, portal API state sync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Gick
2026-03-18 17:06:07 +02:00
parent f4feb3bfe1
commit bd8d96335e
12 changed files with 755 additions and 1 deletions

View File

@@ -0,0 +1,45 @@
"""Step type registry and dispatcher."""
import logging
from .script import execute_script
from .claude_prompt import execute_claude_prompt
from .template import execute_template
from .api_call import execute_api_call
from .skyvern import execute_skyvern
logger = logging.getLogger(__name__)
STEP_EXECUTORS = {
"script": execute_script,
"claude_prompt": execute_claude_prompt,
"template": execute_template,
"api_call": execute_api_call,
"skyvern": execute_skyvern,
}
async def execute_step(
step_type: str,
step_config: dict,
context: dict,
send_text,
target_room: str,
llm=None,
default_model: str = "claude-haiku",
escalation_model: str = "claude-sonnet",
) -> str:
"""Execute a pipeline step and return its output as a string."""
executor = STEP_EXECUTORS.get(step_type)
if not executor:
raise ValueError(f"Unknown step type: {step_type}")
return await executor(
config=step_config,
context=context,
send_text=send_text,
target_room=target_room,
llm=llm,
default_model=default_model,
escalation_model=escalation_model,
)

View File

@@ -0,0 +1,33 @@
"""API call step — make HTTP requests."""
import logging
import httpx
logger = logging.getLogger(__name__)
async def execute_api_call(config: dict, **_kwargs) -> str:
"""Make an HTTP request and return the response body."""
url = config.get("url", "")
if not url:
raise ValueError("api_call step requires 'url' field")
method = config.get("method", "GET").upper()
headers = config.get("headers", {})
body = config.get("body")
async with httpx.AsyncClient(timeout=30.0) as client:
if method == "GET":
resp = await client.get(url, headers=headers)
elif method == "POST":
resp = await client.post(url, headers=headers, content=body)
elif method == "PUT":
resp = await client.put(url, headers=headers, content=body)
elif method == "DELETE":
resp = await client.delete(url, headers=headers)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
resp.raise_for_status()
return resp.text

View File

@@ -0,0 +1,32 @@
"""Claude prompt step — call LLM via LiteLLM proxy."""
import logging
logger = logging.getLogger(__name__)
async def execute_claude_prompt(
config: dict,
llm=None,
default_model: str = "claude-haiku",
escalation_model: str = "claude-sonnet",
**_kwargs,
) -> str:
"""Send a prompt to Claude and return the response."""
if not llm:
raise RuntimeError("LLM client not configured")
prompt = config.get("prompt", "")
if not prompt:
raise ValueError("claude_prompt step requires 'prompt' field")
model_name = config.get("model", "default")
model = escalation_model if model_name == "escalation" else default_model
response = await llm.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
max_tokens=4096,
)
return response.choices[0].message.content or ""

27
pipelines/steps/script.py Normal file
View File

@@ -0,0 +1,27 @@
"""Script step — execute a shell command and capture output."""
import asyncio
import logging
logger = logging.getLogger(__name__)
async def execute_script(config: dict, **_kwargs) -> str:
"""Execute a shell script and return stdout."""
script = config.get("script", "")
if not script:
raise ValueError("Script step requires 'script' field")
proc = await asyncio.create_subprocess_shell(
script,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
output = stdout.decode("utf-8", errors="replace").strip()
if proc.returncode != 0:
err = stderr.decode("utf-8", errors="replace").strip()
raise RuntimeError(f"Script exited with code {proc.returncode}: {err or output}")
return output

View File

@@ -0,0 +1,24 @@
"""Skyvern step — browser automation via Skyvern API (Phase 2 placeholder)."""
import logging
logger = logging.getLogger(__name__)
async def execute_skyvern(config: dict, send_text=None, target_room: str = "", **_kwargs) -> str:
"""Dispatch a browser task to Skyvern.
Phase 2: Will integrate with self-hosted Skyvern on matrixhost.
"""
task = config.get("task", {})
url = task.get("url", "")
goal = task.get("goal", "")
if send_text and target_room:
await send_text(
target_room,
f"**Browser Task**: Skyvern integration pending setup.\n"
f"URL: {url}\nGoal: {goal}",
)
raise NotImplementedError("Skyvern step not yet implemented (Phase 2)")

View File

@@ -0,0 +1,18 @@
"""Template step — format and post a message to the target room."""
import logging
logger = logging.getLogger(__name__)
async def execute_template(config: dict, send_text=None, target_room: str = "", **_kwargs) -> str:
"""Render a template message and post it to the target room."""
template = config.get("template", config.get("message", ""))
if not template:
raise ValueError("template step requires 'template' or 'message' field")
# Template is already rendered by the engine before reaching here
if send_text and target_room:
await send_text(target_room, template)
return template