diff --git a/ai-hub/app/api/routes/agents.py b/ai-hub/app/api/routes/agents.py index 1281b78..5192d93 100644 --- a/ai-hub/app/api/routes/agents.py +++ b/ai-hub/app/api/routes/agents.py @@ -207,7 +207,21 @@ db.add(instance) db.flush() - # 4. Kick off agent loop if initial prompt was provided + # 4. Create primary trigger if specified + trigger = AgentTrigger( + instance_id=instance.id, + trigger_type=request.trigger_type or "manual", + cron_expression=request.cron_expression, + interval_seconds=request.interval_seconds, + default_prompt=request.default_prompt + ) + if trigger.trigger_type == "webhook": + import secrets + trigger.webhook_secret = secrets.token_hex(16) + db.add(trigger) + db.flush() + + # 5. Kick off agent loop if initial prompt was provided # (Message insertion is handled automatically by the RAG service execution) if request.initial_prompt: instance.status = "active" diff --git a/ai-hub/app/api/schemas.py b/ai-hub/app/api/schemas.py index 7eb9694..0a10522 100644 --- a/ai-hub/app/api/schemas.py +++ b/ai-hub/app/api/schemas.py @@ -575,11 +575,16 @@ instance_id: str trigger_type: str cron_expression: Optional[str] = None + interval_seconds: Optional[int] = None + default_prompt: Optional[str] = None webhook_secret: Optional[str] = None webhook_mapping_schema: Optional[dict] = None -class AgentTriggerCreate(AgentTriggerBase): - pass +class AgentTriggerCreate(BaseModel): + trigger_type: str + cron_expression: Optional[str] = None + interval_seconds: Optional[int] = None + default_prompt: Optional[str] = None class AgentTriggerResponse(AgentTriggerBase): id: str @@ -591,15 +596,19 @@ name: str description: Optional[str] = None system_prompt: Optional[str] = None - mesh_node_id: Optional[str] = None + mesh_node_id: str max_loop_iterations: int = 20 initial_prompt: Optional[str] = None # First message to kick off the loop provider_name: Optional[str] = None + trigger_type: Optional[str] = "manual" + cron_expression: Optional[str] = None + interval_seconds: Optional[int] = None + default_prompt: Optional[str] = None class AgentConfigUpdate(BaseModel): """Day 2 Agent Configuration edits""" name: Optional[str] = None system_prompt: Optional[str] = None max_loop_iterations: Optional[int] = None - mesh_node_id: Optional[str] = None + mesh_node_id: str provider_name: Optional[str] = None diff --git a/ai-hub/app/core/orchestration/agent_loop.py b/ai-hub/app/core/orchestration/agent_loop.py index 2af4415..2d3b18a 100644 --- a/ai-hub/app/core/orchestration/agent_loop.py +++ b/ai-hub/app/core/orchestration/agent_loop.py @@ -101,7 +101,9 @@ db.commit() except Exception as e: + import traceback print(f"[AgentExecutor] RAG execution failed for {agent_id}: {e}") + print(traceback.format_exc()) instance = db.query(AgentInstance).filter(AgentInstance.id == agent_id).first() instance.status = "error_suspended" db.commit() diff --git a/ai-hub/app/core/orchestration/scheduler.py b/ai-hub/app/core/orchestration/scheduler.py index 3f93dd0..d87ce8f 100644 --- a/ai-hub/app/core/orchestration/scheduler.py +++ b/ai-hub/app/core/orchestration/scheduler.py @@ -53,38 +53,29 @@ await asyncio.sleep(60) # Run every minute async def _cron_trigger_loop(self): - """Task 3: Handles periodic agent waking (e.g., your 30s test case).""" + """Task 3: Handles periodic agent waking for both CRON and Interval triggers.""" while self._running: try: db = SessionLocal() - # Fetch all agents with CRON triggers - triggers = db.query(AgentTrigger).filter(AgentTrigger.trigger_type == 'cron').all() - now = datetime.utcnow() - - for trigger in triggers: + + # --- Handle CRON triggers --- + cron_triggers = db.query(AgentTrigger).filter(AgentTrigger.trigger_type == 'cron').all() + for trigger in cron_triggers: instance_id = trigger.instance_id - cron_expr = trigger.cron_expression # e.g. "*/30 * * * * *" for 30s - + cron_expr = trigger.cron_expression if not cron_expr: continue - - # Custom handling for non-standard 30s test case if it's just an integer - # OR use croniter for standard cron - should_fire = False + should_fire = False try: - # Fallback for simple integer "30" representing seconds if cron_expr.isdigit(): interval = int(cron_expr) last_run = self._last_run_map.get(instance_id, datetime.min) if (now - last_run).total_seconds() >= interval: should_fire = True else: - # Standard CRON parsing iter = croniter.croniter(cron_expr, now) - # This is a bit simplistic, usually you'd check if we crossed a boundary - # since the last check (30s ago) last_run = self._last_run_map.get(instance_id, now - timedelta(seconds=35)) if iter.get_next(datetime) <= now: should_fire = True @@ -94,27 +85,43 @@ if should_fire: instance = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first() - if instance and instance.status != 'active': # Don't double-trigger if still active + if instance and instance.status != 'active': + prompt = trigger.default_prompt or "SYSTEM: CRON WAKEUP" logger.info(f"[Scheduler] CRON WAKEUP: Triggering Agent {instance_id} (Cron: {cron_expr})") - - # Update last run BEFORE firing to prevent racing self._last_run_map[instance_id] = now - - # Determine prompt (Default if none on trigger) - prompt = "SYSTEM: CRON WAKEUP" - # If it's your 2+2 test case, ensure the persona or prompt is correct. - # In future, we could store the prompt in the Trigger record. - - # Fire! (Asynchronous background task) asyncio.create_task(AgentExecutor.run( - instance_id, - prompt, - self.services.rag_service, - self.services.user_service + instance_id, prompt, + self.services.rag_service, self.services.user_service )) + + # --- Handle INTERVAL triggers --- + interval_triggers = db.query(AgentTrigger).filter(AgentTrigger.trigger_type == 'interval').all() + for trigger in interval_triggers: + instance_id = trigger.instance_id + wait_seconds = trigger.interval_seconds or 60 + + instance = db.query(AgentInstance).filter(AgentInstance.id == instance_id).first() + if not instance: + continue + + # Only fire if agent is idle (finished previous run) + if instance.status == 'active': + continue + + last_run = self._last_run_map.get(instance_id, datetime.min) + elapsed = (now - last_run).total_seconds() + + if elapsed >= wait_seconds: + prompt = trigger.default_prompt or "SYSTEM: INTERVAL WAKEUP" + logger.info(f"[Scheduler] INTERVAL WAKEUP: Triggering Agent {instance_id} (Wait: {wait_seconds}s, Elapsed: {elapsed:.0f}s)") + self._last_run_map[instance_id] = now + asyncio.create_task(AgentExecutor.run( + instance_id, prompt, + self.services.rag_service, self.services.user_service + )) db.close() except Exception as e: - logger.error(f"[Scheduler] CRON Trigger loop error: {e}") + logger.error(f"[Scheduler] CRON/Interval Trigger loop error: {e}") await asyncio.sleep(10) # Resolution: Check every 10 seconds diff --git a/ai-hub/app/core/skills/fs_loader.py b/ai-hub/app/core/skills/fs_loader.py index daed851..5c6fec2 100644 --- a/ai-hub/app/core/skills/fs_loader.py +++ b/ai-hub/app/core/skills/fs_loader.py @@ -3,14 +3,14 @@ import json import logging import collections -from typing import List, Dict, Any +from typing import List, Dict, Any, Optional from app.config import settings logger = logging.getLogger(__name__) class FileSystemSkillLoader: - def __init__(self, base_dir: str): - self.base_dir = base_dir + def __init__(self, base_dirs: List[str]): + self.base_dirs = base_dirs def _ensure_metadata(self, folder_path: str, skill_name: str, is_system: bool = False): """ @@ -41,93 +41,117 @@ def get_all_skills(self) -> List[Dict[str, Any]]: """ - Recursively walks the base direction and parses all SKILL.md rules + Recursively walks the base directories and parses all SKILL.md rules into the common dictionary schema used by the orchestrator. + Supports both nested hierarchy (feature/skill/SKILL.md) and flat (skill/SKILL.md). """ skills = [] - if not os.path.exists(self.base_dir): - return skills - - # Level 1 directories define Features (e.g., /data/skills/swarm_control) - for feature in os.listdir(self.base_dir): - feature_path = os.path.join(self.base_dir, feature) - if not os.path.isdir(feature_path): + for base_dir in self.base_dirs: + if not os.path.exists(base_dir): continue + + # Root entries can be features (nested) OR skills (flat) + for entry in os.listdir(base_dir): + entry_path = os.path.join(base_dir, entry) + if not os.path.isdir(entry_path): + continue - # Level 2 directories define the specific Skills - for skill_id in os.listdir(feature_path): - skill_path = os.path.join(feature_path, skill_id) - if not os.path.isdir(skill_path): - continue - - skill_md_path = os.path.join(skill_path, "SKILL.md") - if not os.path.exists(skill_md_path): + # Check if this is a Skill directly (Flat Structure) + skill_md_path = os.path.join(entry_path, "SKILL.md") + if os.path.exists(skill_md_path): + # It's a skill! Try to load it. + # We assume it belongs to the "chat" feature if not nested, + # but frontmatter in SKILL.md will override this later. + loaded = self._load_skill(entry_path, entry, default_features=[entry, "chat"]) + if loaded: + skills.append(loaded) continue - try: - with open(skill_md_path, "r", encoding='utf-8') as f: - skill_content = f.read() - - # Create virtual file schema - vfs_files = [] - for root, _, files in os.walk(skill_path): - for file in files: - # Hide .metadata.json and macOS .DS_Store from the VFS visible to LLMs - if file == ".metadata.json" or file.startswith('.'): - continue - file_abs = os.path.join(root, file) - file_rel = os.path.relpath(file_abs, skill_path).replace('\\', '/') - - class LazyFileContent(collections.UserString): - def __init__(self, fpath): - self._fpath = fpath - self._content = None - - @property - def data(self): - if self._content is None: - try: - with open(self._fpath, "r", encoding='utf-8') as ff: - self._content = ff.read() - except Exception: - self._content = "" - return self._content - - vfs_files.append({"file_path": file_rel, "content": LazyFileContent(file_abs), "absolute_path": file_abs}) - - # Extract or Generate Metadata - metadata = self._ensure_metadata(skill_path, skill_id, is_system=False) - - # Extract Description directly from SKILL.md (Fallback or yaml frontmatter) - skill_name = skill_id - skill_desc = "" - - if skill_content.startswith("---"): - parts = skill_content.split("---", 2) - if len(parts) >= 3: - frontmatter = yaml.safe_load(parts[1]) or {} - skill_name = frontmatter.get("name", skill_name) - skill_desc = frontmatter.get("description", "") - - # Generate the legacy internal dict schema - skill_def = { - "id": f"fs-{skill_id}", - "name": skill_name, - "description": skill_desc or f"Skill loaded dynamically from {skill_id}", - "skill_type": "local", - "is_enabled": True, - "features": [feature], # Sourced directly from folder hierarchy - "is_system": metadata.get("is_system", False), - "owner_id": metadata.get("owner_id", "admin"), - "extra_metadata": metadata.get("extra_metadata", {"emoji": "🛠️"}), - "files": vfs_files - } - skills.append(skill_def) - - except Exception as e: - logger.error(f"Error parsing FS Skill at {skill_path}: {e}") + # Check if it's a Feature folder (Nested Structure) + for skill_id in os.listdir(entry_path): + skill_path = os.path.join(entry_path, skill_id) + if not os.path.isdir(skill_path): + continue + + skill_md_path = os.path.join(skill_path, "SKILL.md") + if os.path.exists(skill_md_path): + loaded = self._load_skill(skill_path, skill_id, default_features=[entry]) + if loaded: + skills.append(loaded) return skills -# Create a global instance initialized with the data directory config -fs_loader = FileSystemSkillLoader(base_dir=os.path.join(settings.DATA_DIR, "skills")) + def _load_skill(self, skill_path: str, skill_id: str, default_features: List[str]) -> Optional[Dict[str, Any]]: + """Parses a skill folder into the standardized dictionary schema.""" + skill_md_path = os.path.join(skill_path, "SKILL.md") + try: + with open(skill_md_path, "r", encoding='utf-8') as f: + skill_content = f.read() + + # Create virtual file schema + vfs_files = [] + for root, _, files in os.walk(skill_path): + for file in files: + # Hide .metadata.json and macOS .DS_Store from the VFS visible to LLMs + if file == ".metadata.json" or file.startswith('.'): + continue + file_abs = os.path.join(root, file) + file_rel = os.path.relpath(file_abs, skill_path).replace('\\', '/') + + class LazyFileContent(collections.UserString): + def __init__(self, fpath): + self._fpath = fpath + self._content = None + + @property + def data(self): + if self._content is None: + try: + with open(self._fpath, "r", encoding='utf-8') as ff: + self._content = ff.read() + except Exception: + self._content = "" + return self._content + + vfs_files.append({"file_path": file_rel, "content": LazyFileContent(file_abs), "absolute_path": file_abs}) + + # Extract or Generate Metadata + metadata = self._ensure_metadata(skill_path, skill_id, is_system=False) + + # Extract Description directly from SKILL.md (Fallback or yaml frontmatter) + skill_name = skill_id + skill_desc = "" + skill_features = default_features + + if skill_content.startswith("---"): + parts = skill_content.split("---", 2) + if len(parts) >= 3: + frontmatter = yaml.safe_load(parts[1]) or {} + skill_name = frontmatter.get("name", skill_name) + skill_desc = frontmatter.get("description", "") + # M6: Prioritize features defined in SKILL.md frontmatter! + if frontmatter.get("features"): + skill_features = frontmatter.get("features") + + # Generate the legacy internal dict schema + return { + "id": f"fs-{skill_id}", + "name": skill_name, + "description": skill_desc or f"Skill loaded dynamically from {skill_id}", + "skill_type": "local", + "is_enabled": True, + "features": skill_features, + "is_system": metadata.get("is_system", False) or "/app/skills" in skill_path, + "owner_id": metadata.get("owner_id", "admin"), + "extra_metadata": metadata.get("extra_metadata", {"emoji": "🛠️"}), + "files": vfs_files + } + except Exception as e: + logger.error(f"Error parsing FS Skill at {skill_path}: {e}") + return None + +# Create a global instance initialized with both system and data directory configs +fs_loader = FileSystemSkillLoader(base_dirs=[ + os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../skills")), + os.path.join(settings.DATA_DIR, "skills") +]) diff --git a/ai-hub/app/db/migrate.py b/ai-hub/app/db/migrate.py index 69f34fa..79dc1a8 100644 --- a/ai-hub/app/db/migrate.py +++ b/ai-hub/app/db/migrate.py @@ -280,6 +280,8 @@ instance_id TEXT NOT NULL REFERENCES agent_instances(id), trigger_type TEXT NOT NULL, cron_expression TEXT, + interval_seconds INTEGER, + default_prompt TEXT, webhook_secret TEXT, webhook_mapping_schema JSON ) @@ -288,6 +290,23 @@ logger.info("Table 'agent_triggers' created.") except Exception as e: logger.error(f"Failed to create 'agent_triggers': {e}") + else: + # Table exists — ensure all columns are present + trigger_columns = [c["name"] for c in inspector.get_columns("agent_triggers")] + trigger_required_columns = [ + ("interval_seconds", "INTEGER"), + ("default_prompt", "TEXT"), + ("webhook_secret", "TEXT"), + ("webhook_mapping_schema", "JSON") + ] + for col_name, col_type in trigger_required_columns: + if col_name not in trigger_columns: + logger.info(f"Adding column '{col_name}' to 'agent_triggers' table...") + try: + conn.execute(text(f"ALTER TABLE agent_triggers ADD COLUMN {col_name} {col_type}")) + conn.commit() + except Exception as e: + logger.error(f"Failed to add column '{col_name}' to agent_triggers: {e}") logger.info("Database migrations complete.") diff --git a/ai-hub/app/db/models/agent.py b/ai-hub/app/db/models/agent.py index 60c652c..e2779e9 100644 --- a/ai-hub/app/db/models/agent.py +++ b/ai-hub/app/db/models/agent.py @@ -34,8 +34,10 @@ id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) instance_id = Column(String, ForeignKey('agent_instances.id'), nullable=False) - trigger_type = Column(String, nullable=False) # Enum: webhook, cron, manual + trigger_type = Column(String, nullable=False) # Enum: webhook, cron, interval, manual cron_expression = Column(String, nullable=True) + interval_seconds = Column(Integer, nullable=True) # For interval trigger + default_prompt = Column(String, nullable=True) # Predefined prompt for any trigger webhook_secret = Column(String, nullable=True) webhook_mapping_schema = Column(JSON, nullable=True) diff --git a/create_prod_cron_agent.py b/create_prod_cron_agent.py new file mode 100644 index 0000000..b47d696 --- /dev/null +++ b/create_prod_cron_agent.py @@ -0,0 +1,64 @@ +import sqlite3 +import uuid +import datetime + +db_path = "/app/data/ai_hub.db" + +def create_cron_agent(): + try: + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + + # 1. Create a dummy Agent Template for the CRON + template_id = str(uuid.uuid4()) + cursor.execute( + """INSERT INTO agent_templates (id, name, description, system_prompt_path, max_loop_iterations) + VALUES (?, ?, ?, ?, ?)""", + (template_id, "Prod CRON Agent", "Simple agent triggered every 1 minute via CRON", None, 1) + ) + + # 2. Get a user_id (just grab the first one) + cursor.execute("SELECT id FROM users LIMIT 1") + user_row = cursor.fetchone() + if not user_row: + print("No users found in database.") + return + user_id = user_row[0] + + # 3. Create a Session + cursor.execute( + """INSERT INTO sessions (title, user_id, provider_name, feature_name) + VALUES (?, ?, ?, ?)""", + ("CRON Session - Prod test", user_id, "gemini/gemini-2.5-flash", "test_feature") + ) + session_id = cursor.lastrowid + + # 4. Create the Agent Instance + instance_id = str(uuid.uuid4()) + now = datetime.datetime.utcnow().isoformat() + cursor.execute( + """INSERT INTO agent_instances + (id, name, template_id, session_id, user_id, status, current_workspace_jail, current_target_uid, last_heartbeat) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""", + (instance_id, "Prod CRON Instance", template_id, session_id, user_id, "idle", "/tmp", None, now) + ) + + # 5. Create the CRON Trigger + trigger_id = str(uuid.uuid4()) + # Let's run it every 1 minute "*/1 * * * *", or just use the literal "60" to mean 60 seconds + # based on our scheduler.py implementation (it parses integers as seconds inside AI Hub!). + # Based on previous tests, '30' means 30 seconds. So we use '60'. + cursor.execute( + """INSERT INTO agent_triggers (id, instance_id, trigger_type, cron_expression) + VALUES (?, ?, ?, ?)""", + (trigger_id, instance_id, "cron", "60") + ) + + conn.commit() + print(f"✅ Successfully provisioned Prod CRON Agent Instance: {instance_id}") + print(f"✅ Trigger Type: CRON | Interval: 60 seconds") + conn.close() + except Exception as e: + print(f"Database insertion failed: {e}") + +create_cron_agent() diff --git a/create_prod_cron_agent_orm.py b/create_prod_cron_agent_orm.py new file mode 100644 index 0000000..e8c7e78 --- /dev/null +++ b/create_prod_cron_agent_orm.py @@ -0,0 +1,63 @@ +import sys +import uuid +import datetime +from sqlalchemy.orm import Session +from app.db.session import SessionLocal +from app.db import models +from app.db.models import session as db_session +from app.db.models import agent as db_agent + +def create_cron(): + db = SessionLocal() + try: + user = db.query(models.User).first() + if not user: + print("No users found.") + return + + template = db_agent.AgentTemplate( + id=str(uuid.uuid4()), + name="Prod CRON Agent", + description="Simple agent triggered every 1 minute via CRON", + system_prompt_path="/app/data/skills/test_sys_prompt.md", + max_loop_iterations=1 + ) + db.add(template) + + session = db_session.Session( + title="CRON Session - Prod test", + user_id=user.id, + provider_name="gemini/gemini-2.5-flash", + feature_name="cron_test" + ) + db.add(session) + db.flush() + + instance_id = str(uuid.uuid4()) + instance = db_agent.AgentInstance( + id=instance_id, + template_id=template.id, + session_id=session.id, + status="idle", + current_workspace_jail="/tmp", + last_heartbeat=datetime.datetime.utcnow() + ) + db.add(instance) + + trigger = db_agent.AgentTrigger( + id=str(uuid.uuid4()), + instance_id=instance_id, + trigger_type="cron", + cron_expression="60" + ) + db.add(trigger) + db.commit() + + print(f"✅ Successfully provisioned Prod CRON Agent Instance: {instance_id}") + except Exception as e: + db.rollback() + print(f"Fail: {e}") + finally: + db.close() + +create_cron() diff --git a/docs/features/harness_engineering/harness_engineering_design.md b/docs/features/harness_engineering/harness_engineering_design.md index ccce4b5..ffe8f93 100644 --- a/docs/features/harness_engineering/harness_engineering_design.md +++ b/docs/features/harness_engineering/harness_engineering_design.md @@ -76,21 +76,31 @@ - **Time-Travel Log:** Since agents run autonomously for 4 hours while humans sleep, the terminal includes a "Playback Slider." Instead of just seeing the final successful result, users can scrub backward through the execution logs to pinpoint exactly where an obscure `pip install` loop failed before the agent eventually mitigated it. ### C. Trigger Configuration & Mechanics -Agents operate autonomously based on conditions defined by the user in the UI. +Agents operate autonomously based on conditions defined by the user in the UI, categorized into **Active** and **Passive** modes. -1. **Manual Triggers (The Play Button):** - - *UI:* A prominent "Start/Pause" toggle on the Agent Card. - - *Mechanics:* Kicks off the Agent loop exactly once with no external context, relying entirely on its `.md` prompt instructions (e.g., "Run a full system check"). -2. **Scheduled Triggers (CRON):** - - *UI:* the user selects a timetable or types a raw cron expression (e.g., `0 * * * *` for hourly). - - *Mechanics:* The Hub backend uses a lightweight scheduler (like `apscheduler`). Every hour, the Hub grabs the `AgentInstance` and pushes an empty message into its chat queue (e.g., "SYSTEM: CRON WAKEUP"). The Worker picks it up, runs the AI loop, completes the task, and returns the Agent to `🟡 Idle`. -3. **Event Webhooks (Push Data & Acknowledge-First Architecture):** - - *UI:* Clicking "Generate Webhook" produces a secure URL and secret token (e.g., `https://ai.jerxie.com/webhooks/agents/123/hit?token=abc`). You paste this into external systems like GitHub or Jira. - - *Mechanics:* Long-running agent workflows (like compiling code) guarantee a standard synchronous webhook will timeout (e.g., GitHub drops the connection after 30s) and retry rapidly, creating destructive duplicate loops. To solve this, the API strictly enforces an **Acknowledge-First** flow: - 1. The `ai-hub` API receives the raw JSON webhook. - 2. The Hub instantly maps the JSON to a User Message, drops the task into the background DB queue, and returns an immediate `HTTP 202 Accepted` back to GitHub, closing the connection. - 3. The background Agent worker wakes up (`🔵 Listening` --> `🟢 Active`). - 4. *Crucial:* The Agent reads its explicit "Hippocampus" (the Persistent Scratchpad `.txt` on the Node) to determine if this new payload is a continuation of a previously crashed/interrupted task, or a brand new one, before it starts working idempotently. +#### A. Active Triggers (Self-Triggering / Automated) +Active triggers are used for "Agent as a Service" background automation. They use a **Fixed Automation Prompt** that the agent executes on every wake-up. + +1. **Scheduled Triggers (CRON):** + - *UI:* The user selects a timetable or types a raw cron expression (e.g., `0 * * * *` for hourly). + - *Mechanics:* The Hub backend wakes the Agent according to the schedule. It pushes the *Fixed Automation Prompt* into the session context. +2. **Interval Triggers (Recurrent):** + - *UI:* The user defines a "Wait Time" (e.g., 600 seconds). + - *Mechanics:* A smart recurrent loop. Unlike CRON, the timer starts *after* the previous execution finishes successfully. This prevents overlap and ensures a guaranteed rest period between heavy workloads. + +#### B. Passive Triggers (Event-Driven / Integration) +Passive triggers wake the agent when external systems push data. They use a **Predefined Default Prompt** as a fallback, which can be **overridden** by the incoming request payload. + +3. **Manual / Off-hand Requests (Play Button):** + - *UI:* A prominent "Start/Pause" toggle or a manual "Trigger Now" button. + - *Mechanics:* Kicks off the Agent loop. If triggered via API with a specific prompt, it uses that; otherwise, it falls back to the *Predefined Default Prompt*. +4. **Event Webhooks (Acknowledge-First Architecture):** + - *UI:* Clicking "Generate Webhook" produces a secure URL and secret token. Includes a JSON mapping field. + - *Mechanics:* + 1. The Hub receives the raw JSON webhook. + 2. If mapping is defined, it transforms payload fields into a formatted prompt (e.g., `Issue #{{payload.id}} was created: {{payload.content}}`). + 3. If no payload mapping matches or the hit is empty, it uses the *Predefined Default Prompt*. + 4. Worker wakes up and processes the task. ### D. Dependency Graph (The "Orchestrator" View) As agents begin to natively Handoff tasks (passing JSON Manifests), they form a pipeline (e.g., *Frontend Dev* -> *Backend Dev* -> *QA Reviewer*). The UI provides a "Link View" visualizing these connections as edges between nodes. Real-time token flow and "Awaiting Dependencies" states are visualized here to help lead engineers spot pipeline bottlenecks instantly. @@ -108,8 +118,8 @@ ### CUJ 2: Manual Intervention (The Dashboard Quick-Play vs Drill-Down) **Goal:** The user wants to manually command an Agent that usually runs on a schedule. -1. **The Quick-Play:** The user sees a `Log_Archiver` Agent on the dashboard. They want to archive logs right now instead of waiting for the cron job. They hit the **Play Button** on the Agent Card. The Hub secretly sends an empty `` ping, forcing the Agent to run its defined `.md` loop immediately. -2. **The Drill-Down:** The user wants the `Log_Archiver` to ignore `syslog` today and focus on `nginx.log`. They **click the Agent Card**, opening the Drill-Down UI (which looks identically to the Swarm Control chat interface). The user types into the chat box: *"Ignore syslog today, only archive nginx.log."* and hits Enter. This custom user message wakes the Agent from `🟡 Idle` to `🟢 Active`, completely steering its next loop iteration. +1. **The Quick-Play:** The user sees a `Log_Archiver` Agent on the dashboard. They want to archive logs right now instead of waiting for the cron job. They hit the **Play Button** on the Agent Card. The Hub triggers the agent with its *Predefined Default Prompt* ("Analyze and archive system logs"). +2. **The Drill-Down:** The user wants the `Log_Archiver` to ignore `syslog` today and focus on `nginx.log`. They **click the Agent Card**, opening the Drill-Down UI. The user types into the chat box: *"Ignore syslog today, only archive nginx.log."* This specific manual request **overrides** the default prompt for this execution only. --- diff --git a/frontend/src/features/agents/components/AgentDrillDown.js b/frontend/src/features/agents/components/AgentDrillDown.js index fab7c47..fc0b17a 100644 --- a/frontend/src/features/agents/components/AgentDrillDown.js +++ b/frontend/src/features/agents/components/AgentDrillDown.js @@ -20,6 +20,8 @@ const [triggers, setTriggers] = useState([]); const [newTriggerType, setNewTriggerType] = useState('cron'); const [newCronValue, setNewCronValue] = useState('0 * * * *'); + const [newIntervalValue, setNewIntervalValue] = useState(600); + const [newDefaultPrompt, setNewDefaultPrompt] = useState(''); const [creatingTrigger, setCreatingTrigger] = useState(false); const [modalConfig, setModalConfig] = useState(null); const [nodes, setNodes] = useState([]); @@ -136,7 +138,7 @@ name: editConfig.name, system_prompt: editConfig.system_prompt, max_loop_iterations: parseInt(editConfig.max_loop_iterations, 10) || 20, - mesh_node_id: editConfig.mesh_node_id || null + mesh_node_id: editConfig.mesh_node_id }; if (editConfig.provider_name) { payload.provider_name = editConfig.provider_name; @@ -154,9 +156,17 @@ const handleAddTrigger = async () => { try { setCreatingTrigger(true); - const payload = { trigger_type: newTriggerType }; + const payload = { + trigger_type: newTriggerType, + default_prompt: newDefaultPrompt + }; if (newTriggerType === 'cron') payload.cron_expression = newCronValue; + if (newTriggerType === 'interval') payload.interval_seconds = parseInt(newIntervalValue) || 600; + await createAgentTrigger(agentId, payload); + setNewDefaultPrompt(''); + setNewCronValue('0 * * * *'); + setNewIntervalValue(600); fetchData(); } catch (err) { setModalConfig({ title: 'Trigger Failed', message: err.message, type: 'error' }); @@ -336,6 +346,7 @@ value={editConfig?.mesh_node_id || ""} onChange={(e) => setEditConfig({...editConfig, mesh_node_id: e.target.value})} className="w-full bg-white dark:bg-gray-950 border border-gray-300 dark:border-gray-700 text-sm rounded-md py-2.5 px-3 focus:outline-none focus:ring-1 focus:ring-indigo-500 text-gray-900 dark:text-gray-100 shadow-sm" + required > {nodes.length === 0 && } {nodes.map(n => )} @@ -359,12 +370,17 @@ {triggers.map(t => (
- {t.trigger_type} ID: {t.id.split('-')[0]} - - {t.trigger_type === 'cron' ? `Schedule: ${t.cron_expression}` : `Secret: ${t.webhook_secret}`} + + {({'manual': '🖐️ MANUAL', 'cron': '⏰ CRON', 'interval': '🔄 INTERVAL', 'webhook': '🔗 WEBHOOK'})[t.trigger_type] || t.trigger_type} · {t.id.split('-')[0]} - {t.trigger_type === 'cron' && ( - ⏰ {describeCron(t.cron_expression)} + + {t.trigger_type === 'cron' && `Schedule: ${t.cron_expression} (${describeCron(t.cron_expression)})`} + {t.trigger_type === 'interval' && `Every ${t.interval_seconds >= 3600 ? Math.round(t.interval_seconds/3600) + 'h' : t.interval_seconds >= 60 ? Math.round(t.interval_seconds/60) + 'min' : t.interval_seconds + 's'} after completion`} + {t.trigger_type === 'webhook' && `Secret: ${t.webhook_secret}`} + {t.trigger_type === 'manual' && `On-demand — Ready for requests`} + + {t.default_prompt && ( + Prompt: "{t.default_prompt.substring(0, 40)}{t.default_prompt.length > 40 ? '...' : ''}" )}
@@ -403,14 +421,40 @@ /> )} - - + + {newTriggerType === 'interval' && ( +
+ Wait Seconds + setNewIntervalValue(e.target.value)} + className="w-full bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 text-sm rounded-md py-2 px-3 font-mono focus:outline-none" + /> +
+ )} + + +
+ + {newTriggerType === 'cron' || newTriggerType === 'interval' ? 'Fixed Automation Prompt' : 'Default/Overridable Prompt'} + +
+ setNewDefaultPrompt(e.target.value)} + placeholder="Trigger instruction..." + className="flex-1 bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 text-sm rounded-md py-2 px-3 focus:outline-none" + /> + +
diff --git a/frontend/src/features/agents/components/AgentHarnessPage.js b/frontend/src/features/agents/components/AgentHarnessPage.js index a790bf8..3d9adf9 100644 --- a/frontend/src/features/agents/components/AgentHarnessPage.js +++ b/frontend/src/features/agents/components/AgentHarnessPage.js @@ -104,7 +104,11 @@ mesh_node_id: '', max_loop_iterations: 20, initial_prompt: '', - provider_name: '' // Dynamic LLM selection + provider_name: '', // Dynamic LLM selection + trigger_type: 'manual', + cron_expression: '', + interval_seconds: 0, + default_prompt: '' }); const [deploying, setDeploying] = useState(false); const [result, setResult] = useState(null); @@ -128,6 +132,9 @@ try { const fetchedNodes = await getUserAccessibleNodes(); setNodes(fetchedNodes); + if (fetchedNodes.length > 0) { + setForm(f => ({ ...f, mesh_node_id: fetchedNodes[0].node_id })); + } } catch (e) { console.warn("Failed to load nodes", e); } @@ -238,9 +245,9 @@ value={form.mesh_node_id} onChange={e => update('mesh_node_id', e.target.value)} className="w-full bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-lg px-4 py-2.5 text-sm focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500 text-gray-900 dark:text-gray-100" + required > - - {nodes.map(n => )} + {nodes.map(n => )}
@@ -266,6 +273,72 @@ />
+ {/* Automation & Triggers */} +
+

+ ⚙️ Automation & Triggers +

+ +
+
+ + +
+ {form.trigger_type === 'cron' && ( +
+ + update('cron_expression', e.target.value)} + placeholder="*/5 * * * *" + className="w-full bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 text-gray-900 dark:text-gray-100" + required={form.trigger_type === 'cron'} + /> +
+ )} + {form.trigger_type === 'interval' && ( +
+ + update('interval_seconds', parseInt(e.target.value) || 60)} + placeholder="60" + className="w-full bg-white dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-indigo-500 text-gray-900 dark:text-gray-100" + required={form.trigger_type === 'interval'} + /> +
+ )} +
+ +
+ +