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:
45
pipelines/steps/__init__.py
Normal file
45
pipelines/steps/__init__.py
Normal 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,
|
||||
)
|
||||
33
pipelines/steps/api_call.py
Normal file
33
pipelines/steps/api_call.py
Normal 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
|
||||
32
pipelines/steps/claude_prompt.py
Normal file
32
pipelines/steps/claude_prompt.py
Normal 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
27
pipelines/steps/script.py
Normal 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
|
||||
24
pipelines/steps/skyvern.py
Normal file
24
pipelines/steps/skyvern.py
Normal 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)")
|
||||
18
pipelines/steps/template.py
Normal file
18
pipelines/steps/template.py
Normal 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
|
||||
Reference in New Issue
Block a user