diff --git a/append_ledger.py b/append_ledger.py deleted file mode 100644 index f6650acf..00000000 --- a/append_ledger.py +++ /dev/null @@ -1,11 +0,0 @@ -import json - -with open('/home/timothy/aden/hive/x_rapid_ledger.json', 'r') as f: - data = json.load(f) - -data['replies'].append({ - 'original_preview': 'Alright, I give in. Here’s my picture with the boss, courtesy of @johnkrausphotos. Oh, and hook ‘em!' -}) - -with open('/home/timothy/aden/hive/x_rapid_ledger.json', 'w') as f: - json.dump(data, f, indent=2) diff --git a/check_ledger.py b/check_ledger.py deleted file mode 100644 index a3035791..00000000 --- a/check_ledger.py +++ /dev/null @@ -1,11 +0,0 @@ -import json, sys - -with open('/home/timothy/aden/hive/x_rapid_ledger.json', 'r') as f: - ledger = json.load(f) - -text = sys.argv[1] -for r in ledger['replies']: - if r.get('original_preview') == text: - print("YES") - sys.exit(0) -print("NO") diff --git a/core/framework/agent_loop/agent_loop.py b/core/framework/agent_loop/agent_loop.py index 0d34facd..664bffd0 100644 --- a/core/framework/agent_loop/agent_loop.py +++ b/core/framework/agent_loop/agent_loop.py @@ -2557,9 +2557,7 @@ class AgentLoop(AgentProtocol): _legacy = self._config.llm_stream_inactivity_timeout_seconds if _legacy and _legacy > 0 and _legacy < _inter_event_limit: _inter_event_limit = _legacy - _watchdog_active = (_ttft_limit and _ttft_limit > 0) or ( - _inter_event_limit and _inter_event_limit > 0 - ) + _watchdog_active = (_ttft_limit and _ttft_limit > 0) or (_inter_event_limit and _inter_event_limit > 0) # Result of the watchdog: "ok" (stream finished), "ttft" (no first # event in budget), "inactive" (silence after first event). _watchdog_verdict: str = "ok" @@ -2578,9 +2576,7 @@ class AgentLoop(AgentProtocol): ) _check_interval = max(1.0, min(5.0, _tight / 2)) while True: - done, _pending = await asyncio.wait( - {self._stream_task}, timeout=_check_interval - ) + done, _pending = await asyncio.wait({self._stream_task}, timeout=_check_interval) if self._stream_task in done: break now = time.monotonic() @@ -2598,11 +2594,7 @@ class AgentLoop(AgentProtocol): # Post-first-event silence. A stream that produced # events and then went quiet is a real stall. idle = now - _stream_last_event_at - if ( - _inter_event_limit - and _inter_event_limit > 0 - and idle >= _inter_event_limit - ): + if _inter_event_limit and _inter_event_limit > 0 and idle >= _inter_event_limit: _watchdog_verdict = "inactive" _watchdog_elapsed = idle _watchdog_limit = _inter_event_limit diff --git a/core/framework/agents/queen/nodes/__init__.py b/core/framework/agents/queen/nodes/__init__.py index 94eea06c..2fa1fc46 100644 --- a/core/framework/agents/queen/nodes/__init__.py +++ b/core/framework/agents/queen/nodes/__init__.py @@ -4,7 +4,6 @@ import re from framework.orchestrator import NodeSpec - # Wraps prompt sections that should only be shown to vision-capable models. # Content inside `...` is kept for # vision models and stripped for text-only models. Applied once per session @@ -169,20 +168,24 @@ search_files, run_command, undo_changes - MUST Follow the browser-automation skill protocol before using browser tools. ## Persistent colony -- create_colony(colony_name, task, skill_path) — Fork this session into a \ +- create_colony(colony_name, task, skill_name, skill_description, \ + skill_body, skill_files?, tasks?) — Fork this session into a \ persistent colony for headless / recurring / background work. The colony \ has its own chat surface and runs `run_parallel_workers` from there. -- `skill_path` must point to a pre-authored skill folder with `SKILL.md`; \ - author it in a scratch location first, then call `create_colony`. -- **Two-step flow:** - 1. Write a skill folder with `SKILL.md` in a scratch location. - 2. Call `create_colony(colony_name, task, skill_path)` with a FULL, \ - self-contained task. -- The tool validates and installs the skill, forks this session into a \ - colony, and stores the task for later. Nothing runs immediately after \ - the call. -- The task must be FULL and self-contained because the future worker run \ - cannot rely on this live chat turn for missing context. +- **Atomic call — pass the skill INLINE.** Do NOT write SKILL.md with \ + `write_file` beforehand. Provide `skill_name`, `skill_description`, \ + and `skill_body` as arguments and the tool will materialize \ + `~/.hive/skills/{skill_name}/` for you, then fork. Use optional \ + `skill_files` (array of `{path, content}`) for supporting scripts \ + or references. Reusing an existing `skill_name` simply replaces that \ + skill with your latest content. +- The `task` must be FULL and self-contained because the future worker \ + run cannot rely on this live chat turn for missing context. +- The `skill_body` must be FULL and self-contained too — capture the \ + operational protocol (endpoints, auth, gotchas, pre-baked queries) so \ + the worker doesn't have to rediscover what you already know. +- Nothing runs immediately after the call. The user launches the \ + worker later from the new colony page. """ _queen_tools_working = """ @@ -298,13 +301,7 @@ queen_node = NodeSpec( output_keys=[], # Queen should never have this nullable_output_keys=[], # Queen should never have this skip_judge=True, # Queen is a conversational agent; suppress tool-use pressure feedback - tools=sorted( - set( - _QUEEN_INDEPENDENT_TOOLS - + _QUEEN_WORKING_TOOLS - + _QUEEN_REVIEWING_TOOLS - ) - ), + tools=sorted(set(_QUEEN_INDEPENDENT_TOOLS + _QUEEN_WORKING_TOOLS + _QUEEN_REVIEWING_TOOLS)), system_prompt=( _queen_character_core + _queen_role_independent @@ -315,13 +312,7 @@ queen_node = NodeSpec( ), ) -ALL_QUEEN_TOOLS = sorted( - set( - _QUEEN_INDEPENDENT_TOOLS - + _QUEEN_WORKING_TOOLS - + _QUEEN_REVIEWING_TOOLS - ) -) +ALL_QUEEN_TOOLS = sorted(set(_QUEEN_INDEPENDENT_TOOLS + _QUEEN_WORKING_TOOLS + _QUEEN_REVIEWING_TOOLS)) __all__ = [ "queen_node", diff --git a/core/framework/agents/queen/queen_profiles.py b/core/framework/agents/queen/queen_profiles.py index 7cf6fce9..ed80d9ae 100644 --- a/core/framework/agents/queen/queen_profiles.py +++ b/core/framework/agents/queen/queen_profiles.py @@ -1099,12 +1099,17 @@ def ensure_default_queens() -> None: Safe to call multiple times — skips any profile that already has a file. """ + created = 0 for queen_id, profile in DEFAULT_QUEENS.items(): queen_dir = QUEENS_DIR / queen_id profile_path = queen_dir / "profile.yaml" + if profile_path.exists(): + continue queen_dir.mkdir(parents=True, exist_ok=True) profile_path.write_text(yaml.safe_dump(profile, sort_keys=False, allow_unicode=True)) - logger.info("Queen profiles ensured at %s", QUEENS_DIR) + created += 1 + if created: + logger.info("Created %d default queen profile(s) at %s", created, QUEENS_DIR) def list_queens() -> list[dict[str, str]]: @@ -1143,6 +1148,10 @@ def load_queen_profile(queen_id: str) -> dict[str, Any]: def update_queen_profile(queen_id: str, updates: dict[str, Any]) -> dict[str, Any]: """Merge partial updates into an existing queen profile and persist. + Performs a shallow merge at the top level, but deep-merges dict values + (e.g. world_lore, hidden_background) so partial sub-field updates don't + clobber sibling keys. + Returns the full updated profile. Raises FileNotFoundError if the profile doesn't exist. """ @@ -1150,7 +1159,11 @@ def update_queen_profile(queen_id: str, updates: dict[str, Any]) -> dict[str, An if not profile_path.exists(): raise FileNotFoundError(f"Queen profile not found: {queen_id}") data = yaml.safe_load(profile_path.read_text()) - data.update(updates) + for key, value in updates.items(): + if isinstance(value, dict) and isinstance(data.get(key), dict): + data[key].update(value) + else: + data[key] = value profile_path.write_text(yaml.safe_dump(data, sort_keys=False, allow_unicode=True)) return data @@ -1160,9 +1173,7 @@ def update_queen_profile(queen_id: str, updates: dict[str, Any]) -> dict[str, An # --------------------------------------------------------------------------- -def format_queen_identity_prompt( - profile: dict[str, Any], *, max_examples: int | None = None -) -> str: +def format_queen_identity_prompt(profile: dict[str, Any], *, max_examples: int | None = None) -> str: """Convert a queen profile into a high-dimensional character prompt. Uses the 5-pillar character construction system: core identity, diff --git a/core/framework/agents/queen/reference/gcu_guide.md b/core/framework/agents/queen/reference/gcu_guide.md index 2922f58e..03f4387e 100644 --- a/core/framework/agents/queen/reference/gcu_guide.md +++ b/core/framework/agents/queen/reference/gcu_guide.md @@ -33,9 +33,9 @@ All tools are prefixed with `browser_`: **`browser_snapshot`** — compact accessibility tree of interactive elements. Fast, cheap, good for static or form-heavy pages where the DOM matches what's visually rendered (documentation, simple dashboards, search results, settings pages). -**`browser_screenshot`** — visual capture + metadata (`cssWidth`, `devicePixelRatio`, scale fields). **Use this on any complex SPA** — LinkedIn, Twitter/X, Reddit, Gmail, Notion, Slack, Discord, any site using shadow DOM, virtual scrolling, React reconciliation, or dynamic layout. On these pages, snapshot refs go stale in seconds, shadow contents aren't in the AX tree, and virtual-scrolled elements disappear from the tree entirely. Screenshot is the **only** reliable way to orient yourself. +**`browser_screenshot`** — visual capture + metadata (`cssWidth`, `devicePixelRatio`, scale fields). Use this when `browser_snapshot` does not show the thing you need, when refs look stale, or when visual position/layout matters. This often happens on complex SPAs — LinkedIn, Twitter/X, Reddit, Gmail, Notion, Slack, Discord — and on sites using shadow DOM, virtual scrolling, React reconciliation, or dynamic layout. -Neither tool is "preferred" universally — they're for different jobs. Default to snapshot on text-heavy static pages, screenshot on SPAs and anything shadow-DOM-heavy. Activate the `browser-automation` skill for the full decision tree. +Neither tool is "preferred" universally — they're for different jobs. Start with snapshot for page structure and ordinary controls; use screenshot as the fallback when snapshot can't find or verify the visible target. Activate the `browser-automation` skill for the full decision tree. ## Coordinate rule @@ -44,9 +44,9 @@ Every browser tool that takes or returns coordinates operates in **fractions of ## System prompt tips for browser nodes ``` -1. On LinkedIn / X / Reddit / Gmail / any SPA — use browser_screenshot to orient, - not browser_snapshot. Shadow DOM and virtual scrolling make snapshots unreliable. -2. For static pages (docs, forms, search results), browser_snapshot is fine. +1. Start with browser_snapshot or the snapshot returned by the latest interaction. +2. If the target is missing, ambiguous, stale, or visibly present but absent from the tree, + use browser_screenshot to orient and then click by fractional coordinates. 3. Before typing into a rich-text editor (X compose, LinkedIn DM, Gmail, Reddit), click the input area first with browser_click_coordinate so React / Draft.js / Lexical register a native focus event, then use browser_type_focused(text=...) @@ -66,7 +66,7 @@ Every browser tool that takes or returns coordinates operates in **fractions of "tools": {"policy": "all"}, "input_keys": ["search_url"], "output_keys": ["profiles"], - "system_prompt": "Navigate to the search URL via browser_navigate(wait_until='load', timeout_ms=20000). Wait 3s for SPA hydration. On LinkedIn, use browser_screenshot to see the page — browser_snapshot misses shadow-DOM and virtual-scrolled content. Paginate through results by scrolling and screenshotting; extract each profile card by reading its visible layout..." + "system_prompt": "Navigate to the search URL via browser_navigate(wait_until='load', timeout_ms=20000). Wait 3s for SPA hydration. Use the returned snapshot to look for result cards first. If the cards are missing, stale, or visually present but absent from the tree, use browser_screenshot to orient; paginate through results by scrolling and use screenshots only when the snapshot cannot find or verify the visible cards..." } ``` diff --git a/core/framework/host/agent_host.py b/core/framework/host/agent_host.py index bdc4bd3b..3456641d 100644 --- a/core/framework/host/agent_host.py +++ b/core/framework/host/agent_host.py @@ -1672,7 +1672,7 @@ class AgentHost: entry_point_id: str, execution_id: str, graph_id: str | None = None, - ) -> bool: + ) -> str: """ Cancel a running execution. @@ -1682,11 +1682,11 @@ class AgentHost: graph_id: Graph to search (defaults to active graph) Returns: - True if cancelled, False if not found + Cancellation outcome from the stream. """ stream = self._resolve_stream(entry_point_id, graph_id) if stream is None: - return False + return "not_found" return await stream.cancel_execution(execution_id) # === QUERY OPERATIONS === diff --git a/core/framework/host/colony_runtime.py b/core/framework/host/colony_runtime.py index 96b7cd3d..bc639e3d 100644 --- a/core/framework/host/colony_runtime.py +++ b/core/framework/host/colony_runtime.py @@ -14,6 +14,7 @@ from __future__ import annotations import asyncio import json import logging +import os import time from collections import OrderedDict from collections.abc import Callable @@ -73,9 +74,28 @@ def _format_spawn_task_message(task: str, input_data: dict[str, Any]) -> str: return "\n".join(lines) +def _env_int(name: str, default: int) -> int: + """Read a positive int from env; fall back to default on missing/invalid.""" + raw = os.environ.get(name) + if not raw: + return default + try: + value = int(raw) + except ValueError: + logger.warning("Invalid %s=%r; using default %d", name, raw, default) + return default + return value if value > 0 else default + + +# Laptop-safe default. Each worker is a full AgentLoop (Claude SDK session + +# tool catalog), so ~4 concurrent is the realistic ceiling on a dev machine. +# Override via HIVE_MAX_CONCURRENT_WORKERS for servers. +_DEFAULT_MAX_CONCURRENT_WORKERS = _env_int("HIVE_MAX_CONCURRENT_WORKERS", 4) + + @dataclass class ColonyConfig: - max_concurrent_workers: int = 100 + max_concurrent_workers: int = _DEFAULT_MAX_CONCURRENT_WORKERS cache_ttl: float = 60.0 batch_interval: float = 0.1 max_history: int = 1000 @@ -660,7 +680,9 @@ class ColonyRuntime: ) _pre.load() _spawn_catalog = _pre.skills_catalog_prompt - _spawn_skill_dirs = list(_pre.allowlisted_dirs) if hasattr(_pre, "allowlisted_dirs") else self.skill_dirs + _spawn_skill_dirs = ( + list(_pre.allowlisted_dirs) if hasattr(_pre, "allowlisted_dirs") else self.skill_dirs + ) logger.info( "spawn: pre-activated hive.colony-progress-tracker " "(catalog %d → %d chars) for worker with db_path=%s", @@ -670,8 +692,7 @@ class ColonyRuntime: ) except Exception as exc: logger.warning( - "spawn: failed to pre-activate colony-progress-tracker " - "skill, falling back to base catalog: %s", + "spawn: failed to pre-activate colony-progress-tracker skill, falling back to base catalog: %s", exc, ) diff --git a/core/framework/host/execution_manager.py b/core/framework/host/execution_manager.py index 3ce548b3..18158b6a 100644 --- a/core/framework/host/execution_manager.py +++ b/core/framework/host/execution_manager.py @@ -16,7 +16,7 @@ from collections import OrderedDict from collections.abc import Callable from dataclasses import dataclass, field from datetime import datetime -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Literal from framework.host.event_bus import EventBus from framework.host.shared_state import IsolationLevel, SharedBufferManager @@ -48,6 +48,8 @@ class ExecutionAlreadyRunningError(RuntimeError): logger = logging.getLogger(__name__) +CancelExecutionResult = Literal["cancelled", "cancelling", "not_found"] + class GraphScopedEventBus(EventBus): """Proxy that stamps ``graph_id`` on every published event. @@ -130,7 +132,7 @@ class ExecutionContext: run_id: str | None = None # Unique ID per trigger() invocation started_at: datetime = field(default_factory=datetime.now) completed_at: datetime | None = None - status: str = "pending" # pending, running, completed, failed, paused + status: str = "pending" # pending, running, cancelling, completed, failed, paused, cancelled class ExecutionManager: @@ -315,6 +317,22 @@ class ExecutionManager: """Return IDs of all currently active executions.""" return list(self._active_executions.keys()) + def _get_blocking_execution_ids_locked(self) -> list[str]: + """Return executions that still block a replacement from starting. + + An execution continues to block replacement until its task has + terminated and the task's final cleanup has removed its bookkeeping. + This is intentional: a timed-out cancellation does not mean the old + task is harmless. If it is still alive, it can still write shared + session state, so letting a replacement start would guarantee + overlapping mutations on the same session. + """ + blocking_ids: list[str] = list(self._active_executions.keys()) + for execution_id, task in self._execution_tasks.items(): + if not task.done() and execution_id not in self._active_executions: + blocking_ids.append(execution_id) + return blocking_ids + @property def agent_idle_seconds(self) -> float: """Seconds since the last agent activity (LLM call, tool call, node transition). @@ -396,15 +414,22 @@ class ExecutionManager: async def stop(self) -> None: """Stop the execution stream and cancel active executions.""" - if not self._running: - return + async with self._lock: + if not self._running: + return - self._running = False + self._running = False - # Cancel all active executions - tasks_to_wait = [] - for _, task in self._execution_tasks.items(): - if not task.done(): + # Cancel all active executions, but keep bookkeeping until each + # task reaches its own cleanup path. + tasks_to_wait: list[asyncio.Task] = [] + for execution_id, task in self._execution_tasks.items(): + if task.done(): + continue + ctx = self._active_executions.get(execution_id) + if ctx is not None: + ctx.status = "cancelling" + self._cancel_reasons.setdefault(execution_id, "Execution cancelled") task.cancel() tasks_to_wait.append(task) @@ -418,9 +443,6 @@ class ExecutionManager: len(pending), ) - self._execution_tasks.clear() - self._active_executions.clear() - logger.info(f"ExecutionStream '{self.stream_id}' stopped") # Emit stream stopped event @@ -569,12 +591,16 @@ class ExecutionManager: ) async with self._lock: + if not self._running: + raise RuntimeError(f"ExecutionStream '{self.stream_id}' is not running") + + blocking_ids = self._get_blocking_execution_ids_locked() + if blocking_ids: + raise ExecutionAlreadyRunningError(self.stream_id, blocking_ids) + self._active_executions[execution_id] = ctx self._completion_events[execution_id] = asyncio.Event() - - # Start execution task - task = asyncio.create_task(self._run_execution(ctx)) - self._execution_tasks[execution_id] = task + self._execution_tasks[execution_id] = asyncio.create_task(self._run_execution(ctx)) logger.debug(f"Queued execution {execution_id} for stream {self.stream_id}") return execution_id @@ -1183,7 +1209,7 @@ class ExecutionManager: """Get execution context.""" return self._active_executions.get(execution_id) - async def cancel_execution(self, execution_id: str, *, reason: str | None = None) -> bool: + async def cancel_execution(self, execution_id: str, *, reason: str | None = None) -> CancelExecutionResult: """ Cancel a running execution. @@ -1194,33 +1220,38 @@ class ExecutionManager: provided, defaults to "Execution cancelled". Returns: - True if cancelled, False if not found + "cancelled" if the task fully exited within the grace period, + "cancelling" if cancellation was requested but the task is still + shutting down, or "not_found" if no active task exists. """ - task = self._execution_tasks.get(execution_id) - if task and not task.done(): + async with self._lock: + task = self._execution_tasks.get(execution_id) + if task is None or task.done(): + return "not_found" + # Store the reason so the CancelledError handler can use it # when emitting the pause/fail event. self._cancel_reasons[execution_id] = reason or "Execution cancelled" + ctx = self._active_executions.get(execution_id) + if ctx is not None: + ctx.status = "cancelling" task.cancel() - # Wait briefly for the task to finish. Don't block indefinitely — - # the task may be stuck in a long LLM API call that doesn't - # respond to cancellation quickly. - done, _ = await asyncio.wait({task}, timeout=5.0) - if not done: - # Task didn't finish within timeout — clean up bookkeeping now - # so the session doesn't think it still has running executions. - # The task will continue winding down in the background and its - # finally block will harmlessly pop already-removed keys. - logger.warning( - "Execution %s did not finish within cancel timeout; force-cleaning bookkeeping", - execution_id, - ) - async with self._lock: - self._active_executions.pop(execution_id, None) - self._execution_tasks.pop(execution_id, None) - self._active_executors.pop(execution_id, None) - return True - return False + + # Wait briefly for the task to finish. Don't block indefinitely — + # the task may be stuck in a long LLM API call that doesn't + # respond to cancellation quickly. + done, _ = await asyncio.wait({task}, timeout=5.0) + if not done: + # Keep bookkeeping in place until the task's own finally block runs. + # We intentionally do not add deferred cleanup keyed by execution_id + # here because resumed executions reuse the same id; a delayed pop + # could otherwise delete bookkeeping that belongs to the new run. + logger.warning( + "Execution %s did not finish within cancel timeout; leaving bookkeeping in place until task exit", + execution_id, + ) + return "cancelling" + return "cancelled" # === STATS AND MONITORING === diff --git a/core/framework/host/progress_db.py b/core/framework/host/progress_db.py index 99fad0d5..93c48fe1 100644 --- a/core/framework/host/progress_db.py +++ b/core/framework/host/progress_db.py @@ -33,7 +33,7 @@ import json import logging import sqlite3 import uuid -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -118,7 +118,7 @@ _PRAGMAS = ( def _now_iso() -> str: - return datetime.now(timezone.utc).isoformat(timespec="seconds") + return datetime.now(UTC).isoformat(timespec="seconds") def _new_id() -> str: @@ -167,13 +167,10 @@ def ensure_progress_db(colony_dir: Path) -> Path: con.executescript(_SCHEMA_V1) con.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") con.execute( - "INSERT OR REPLACE INTO colony_meta(key, value, updated_at) " - "VALUES (?, ?, ?)", + "INSERT OR REPLACE INTO colony_meta(key, value, updated_at) VALUES (?, ?, ?)", ("schema_version", str(SCHEMA_VERSION), _now_iso()), ) - logger.info( - "progress_db: initialized schema v%d at %s", SCHEMA_VERSION, db_path - ) + logger.info("progress_db: initialized schema v%d at %s", SCHEMA_VERSION, db_path) reclaimed = _reclaim_stale_inner(con, stale_after_minutes=15) if reclaimed: @@ -191,19 +188,28 @@ def ensure_progress_db(colony_dir: Path) -> Path: def _patch_worker_configs(colony_dir: Path, db_path: Path) -> int: - """Inject ``input_data.db_path`` + ``input_data.colony_id`` into - existing ``worker.json`` files in a colony directory. + """Inject ``input_data.db_path`` + ``input_data.colony_id`` + + ``input_data.colony_data_dir`` into existing ``worker.json`` files + in a colony directory. Runs on every ``ensure_progress_db`` call so colonies that were forked before this feature landed get their worker spawn messages patched in place. Idempotent: if ``input_data`` already contains - the correct ``db_path``, the file is not rewritten. + all three values, the file is not rewritten. Returns the number of files that were actually modified (0 on the common case of already-patched colonies). + + Why ``colony_data_dir``? ``db_path`` alone points agents at + ``progress.db``; for anything else (custom SQLite stores, JSON + ledgers, scraped artefacts) they need the *directory* so they + stop creating state under ``~/.hive/skills/`` — which holds skill + *definitions*, not runtime data. See + ``_default_skills/colony-storage-paths/SKILL.md``. """ colony_id = colony_dir.name abs_db = str(db_path) + abs_data_dir = str(db_path.parent) patched = 0 for worker_cfg in colony_dir.glob("*.json"): @@ -227,26 +233,24 @@ def _patch_worker_configs(colony_dir: Path, db_path: Path) -> int: if ( input_data.get("db_path") == abs_db and input_data.get("colony_id") == colony_id + and input_data.get("colony_data_dir") == abs_data_dir ): continue # already patched input_data["db_path"] = abs_db input_data["colony_id"] = colony_id + input_data["colony_data_dir"] = abs_data_dir data["input_data"] = input_data try: - worker_cfg.write_text( - json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" - ) + worker_cfg.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") patched += 1 except OSError as e: - logger.warning( - "progress_db: failed to patch worker config %s: %s", worker_cfg, e - ) + logger.warning("progress_db: failed to patch worker config %s: %s", worker_cfg, e) if patched: logger.info( - "progress_db: patched %d worker config(s) in colony '%s' with db_path", + "progress_db: patched %d worker config(s) in colony '%s' with db_path + colony_data_dir", patched, colony_id, ) @@ -271,9 +275,7 @@ def ensure_all_colony_dbs(colonies_root: Path | None = None) -> list[Path]: try: initialized.append(ensure_progress_db(entry)) except Exception as e: - logger.warning( - "progress_db: failed to ensure DB for colony '%s': %s", entry.name, e - ) + logger.warning("progress_db: failed to ensure DB for colony '%s': %s", entry.name, e) return initialized @@ -340,9 +342,7 @@ def seed_tasks( for step_seq, step in enumerate(task.get("steps") or [], start=1): if not step.get("title"): - raise ValueError( - f"task[{idx}].steps[{step_seq - 1}] missing required 'title'" - ) + raise ValueError(f"task[{idx}].steps[{step_seq - 1}] missing required 'title'") con.execute( """ INSERT INTO steps (id, task_id, seq, title, detail, status) @@ -361,9 +361,7 @@ def seed_tasks( key = sop.get("key") description = sop.get("description") if not key or not description: - raise ValueError( - f"task[{idx}].sop_items missing 'key' or 'description'" - ) + raise ValueError(f"task[{idx}].sop_items missing 'key' or 'description'") con.execute( """ INSERT INTO sop_checklist @@ -421,9 +419,7 @@ def enqueue_task( return ids[0] -def _reclaim_stale_inner( - con: sqlite3.Connection, *, stale_after_minutes: int -) -> int: +def _reclaim_stale_inner(con: sqlite3.Connection, *, stale_after_minutes: int) -> int: """Reclaim stale claims. Runs inside an existing open connection. Two-step: diff --git a/core/framework/llm/model_catalog.json b/core/framework/llm/model_catalog.json index c1711e20..411a5a36 100644 --- a/core/framework/llm/model_catalog.json +++ b/core/framework/llm/model_catalog.json @@ -317,6 +317,41 @@ "recommended": false, "max_tokens": 32768, "max_context_tokens": 163840 + }, + { + "id": "qwen/qwen3.6-plus", + "label": "Qwen 3.6 Plus - Strong reasoning", + "recommended": false, + "max_tokens": 32768, + "max_context_tokens": 131072 + }, + { + "id": "z-ai/glm-5v-turbo", + "label": "GLM-5V Turbo - Vision capable", + "recommended": false, + "max_tokens": 16384, + "max_context_tokens": 128000 + }, + { + "id": "x-ai/grok-4.20", + "label": "Grok 4.20 - xAI flagship", + "recommended": false, + "max_tokens": 32768, + "max_context_tokens": 131072 + }, + { + "id": "xiaomi/mimo-v2-pro", + "label": "MiMo V2 Pro - Xiaomi multimodal", + "recommended": false, + "max_tokens": 16384, + "max_context_tokens": 65536 + }, + { + "id": "stepfun/step-3.5-flash", + "label": "Step 3.5 Flash - Fast inference", + "recommended": false, + "max_tokens": 32768, + "max_context_tokens": 128000 } ] } diff --git a/core/framework/loader/cli.py b/core/framework/loader/cli.py index eb84946b..91ab1935 100644 --- a/core/framework/loader/cli.py +++ b/core/framework/loader/cli.py @@ -21,6 +21,7 @@ import os import shutil import subprocess import sys +import threading from pathlib import Path from typing import Any from urllib import error as urlerror, parse as urlparse, request as urlrequest @@ -214,7 +215,13 @@ def cmd_serve(args: argparse.Namespace) -> int: def cmd_open(args: argparse.Namespace) -> int: """Start the HTTP server and open the dashboard in the browser.""" - _ping_hive_gateway_availability("hive-open") + # Don't block local startup on a best-effort analytics probe. + threading.Thread( + target=_ping_hive_gateway_availability, + args=("hive-open",), + daemon=True, + name="hive-open-gateway-ping", + ).start() args.open = True return cmd_serve(args) diff --git a/core/framework/orchestrator/gcu.py b/core/framework/orchestrator/gcu.py index 1ac459cc..620a7ea5 100644 --- a/core/framework/orchestrator/gcu.py +++ b/core/framework/orchestrator/gcu.py @@ -26,18 +26,23 @@ Follow these rules for reliable, efficient browser interaction. - **`browser_snapshot`** — compact accessibility tree. Fast, cheap, good for static / text-heavy pages where the DOM matches what's visually rendered (docs, forms, search results, settings pages). -- **`browser_screenshot`** — visual capture + scale metadata. Use on any - complex SPA (LinkedIn, X / Twitter, Reddit, Gmail, Notion, Slack, - Discord) and on any site using shadow DOM or virtual scrolling. On - those pages, snapshot refs go stale in seconds, shadow contents - aren't in the AX tree, and virtual-scrolled elements disappear from - the tree entirely — screenshots are the only reliable way to orient. +- **`browser_screenshot`** — visual capture + scale metadata. Use when + the snapshot does not show the thing you need, when refs look stale, + or when you need visual position/layout to act. This is common on + complex SPAs (LinkedIn, X / Twitter, Reddit, Gmail, Notion, Slack, + Discord), shadow DOM, and virtual scrolling. -Neither tool is "preferred" universally — they're for different jobs. -Default to snapshot on static pages, screenshot on SPAs and -shadow-heavy sites. Interaction tools (click/type/fill/scroll) return -a snapshot automatically, so don't call `browser_snapshot` separately -after an interaction unless you need a fresh view. +Use snapshot first for structure and ordinary controls; switch to +screenshot when snapshot can't find or verify the target. Interaction +tools (`browser_click`, `browser_type`, `browser_type_focused`, +`browser_fill`, `browser_scroll`) wait 0.5 s for the page to settle +after a successful action, then attach a fresh snapshot under the +`snapshot` key of their result — so don't call `browser_snapshot` +separately after an interaction unless you need a newer view. Tune +with `auto_snapshot_mode`: `"default"` (full tree) is the default; +`"simple"` trims unnamed structural nodes; `"interactive"` returns +only controls (tightest token footprint); `"off"` skips the capture +entirely — use when batching several interactions. Only fall back to `browser_get_text` for extracting small elements by CSS selector. diff --git a/core/framework/runtime/tests/test_forced_cancel_dual_execution.py b/core/framework/runtime/tests/test_forced_cancel_dual_execution.py new file mode 100644 index 00000000..e76e4a60 --- /dev/null +++ b/core/framework/runtime/tests/test_forced_cancel_dual_execution.py @@ -0,0 +1,180 @@ +"""Regression tests for forced cancellation overlap in ExecutionStream.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from framework.host.event_bus import AgentEvent, EventBus, EventType +from framework.host.execution_manager import ( + EntryPointSpec, + ExecutionAlreadyRunningError, + ExecutionManager, +) +from framework.orchestrator.edge import GraphSpec +from framework.orchestrator.goal import Goal +from framework.orchestrator.orchestrator import ExecutionResult + + +def _build_stream(tmp_path, *, event_bus: EventBus | None = None) -> ExecutionManager: + graph = GraphSpec( + id="test-graph", + goal_id="goal-1", + version="1.0.0", + entry_node="start", + entry_points={"start": "start"}, + terminal_nodes=[], + pause_nodes=[], + nodes=[], + edges=[], + ) + goal = Goal(id="goal-1", name="goal-1", description="test goal") + entry_spec = EntryPointSpec( + id="webhook", + name="Webhook", + entry_node="start", + trigger_type="webhook", + isolation_level="shared", + max_concurrent=1, + ) + + storage = SimpleNamespace(base_path=tmp_path) + stream = ExecutionManager( + stream_id="webhook", + entry_spec=entry_spec, + graph=graph, + goal=goal, + state_manager=MagicMock(), + storage=storage, + outcome_aggregator=MagicMock(), + event_bus=event_bus, + ) + stream._running = True + return stream + + +def _install_blocking_executor(monkeypatch, release: asyncio.Event) -> None: + class BlockingExecutor: + def __init__(self, *args, **kwargs): + self.node_registry = {} + + async def execute(self, *args, **kwargs): + while True: + try: + await release.wait() + break + except asyncio.CancelledError: + continue + return ExecutionResult(success=True, output={"ok": True}) + + monkeypatch.setattr("framework.host.execution_manager.Orchestrator", BlockingExecutor) + + +@pytest.mark.asyncio +async def test_forced_cancel_timeout_keeps_stream_locked_until_task_exit(tmp_path, monkeypatch): + event_bus = EventBus() + stream = _build_stream(tmp_path, event_bus=event_bus) + release = asyncio.Event() + _install_blocking_executor(monkeypatch, release) + + started_events: list[AgentEvent] = [] + first_started = asyncio.Event() + second_started = asyncio.Event() + + async def on_started(event: AgentEvent) -> None: + started_events.append(event) + if len(started_events) == 1: + first_started.set() + elif len(started_events) == 2: + second_started.set() + + event_bus.subscribe( + event_types=[EventType.EXECUTION_STARTED], + handler=on_started, + filter_stream="webhook", + ) + + async def immediate_timeout(_tasks, timeout=None): + return set(), set(_tasks) + + execution_id = await stream.execute({}, session_state={"resume_session_id": "session-1"}) + await asyncio.wait_for(first_started.wait(), timeout=1) + + old_task = stream._execution_tasks[execution_id] + monkeypatch.setattr("framework.host.execution_manager.asyncio.wait", immediate_timeout) + + try: + cancelled = await stream.cancel_execution(execution_id, reason="forced timeout") + + assert cancelled == "cancelling" + assert execution_id in stream._execution_tasks + assert execution_id in stream._active_executions + assert execution_id in stream._completion_events + assert stream._active_executions[execution_id].status == "cancelling" + assert not old_task.done() + + with pytest.raises(ExecutionAlreadyRunningError): + await stream.execute({}, session_state={"resume_session_id": execution_id}) + + assert len(started_events) == 1 + + release.set() + await asyncio.wait_for(old_task, timeout=1) + + restarted_id = await stream.execute({}, session_state={"resume_session_id": execution_id}) + assert restarted_id == execution_id + await asyncio.wait_for(second_started.wait(), timeout=1) + finally: + release.set() + await asyncio.gather(*stream._execution_tasks.values(), return_exceptions=True) + + +@pytest.mark.asyncio +async def test_repeated_forced_restarts_do_not_accumulate_parallel_tasks(tmp_path, monkeypatch): + event_bus = EventBus() + stream = _build_stream(tmp_path, event_bus=event_bus) + release = asyncio.Event() + _install_blocking_executor(monkeypatch, release) + + started_events: list[AgentEvent] = [] + first_started = asyncio.Event() + + async def on_started(event: AgentEvent) -> None: + started_events.append(event) + first_started.set() + + event_bus.subscribe( + event_types=[EventType.EXECUTION_STARTED], + handler=on_started, + filter_stream="webhook", + ) + + async def immediate_timeout(_tasks, timeout=None): + return set(), set(_tasks) + + monkeypatch.setattr("framework.host.execution_manager.asyncio.wait", immediate_timeout) + + execution_id = await stream.execute({}, session_state={"resume_session_id": "session-1"}) + await asyncio.wait_for(first_started.wait(), timeout=1) + + first_task = stream._execution_tasks[execution_id] + + try: + assert await stream.cancel_execution(execution_id, reason="restart-1") == "cancelling" + + with pytest.raises(ExecutionAlreadyRunningError): + await stream.execute({}, session_state={"resume_session_id": execution_id}) + + with pytest.raises(ExecutionAlreadyRunningError): + await stream.execute({}, session_state={"resume_session_id": execution_id}) + + assert len(started_events) == 1 + assert list(stream._execution_tasks) == [execution_id] + assert stream._execution_tasks[execution_id] is first_task + assert not first_task.done() + finally: + release.set() + await asyncio.wait_for(first_task, timeout=1) diff --git a/core/framework/server/app.py b/core/framework/server/app.py index 6eb549c2..cd38a5bd 100644 --- a/core/framework/server/app.py +++ b/core/framework/server/app.py @@ -186,9 +186,7 @@ async def _probe_browser_bridge() -> dict: status_port = bridge_port + 1 try: - reader, writer = await asyncio.wait_for( - asyncio.open_connection("127.0.0.1", status_port), timeout=0.5 - ) + reader, writer = await asyncio.wait_for(asyncio.open_connection("127.0.0.1", status_port), timeout=0.5) writer.write(b"GET /status HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n") await writer.drain() raw = await asyncio.wait_for(reader.read(512), timeout=0.5) @@ -285,49 +283,22 @@ def create_app(model: str | None = None) -> web.Application: except Exception as exc: logger.warning("Could not auto-persist HIVE_CREDENTIAL_KEY: %s", exc) - credential_store = CredentialStore.with_aden_sync() + # Local server startup should not wait on an eager Aden sync. + # The store can still fetch/refresh credentials on demand. + credential_store = CredentialStore.with_aden_sync(auto_sync=False) except Exception: logger.debug("Encrypted credential store unavailable, using in-memory fallback") credential_store = CredentialStore.for_testing({}) app["credential_store"] = credential_store - # Pre-load queen MCP tools once at startup (cached for all sessions) - # This avoids rebuilding the tool registry for every queen session - from framework.loader.mcp_registry import MCPRegistry - from framework.loader.tool_registry import ToolRegistry - - _queen_tool_registry: ToolRegistry | None = None - try: - _queen_tool_registry = ToolRegistry() - import framework.agents.queen as _queen_pkg - - queen_pkg_dir = Path(_queen_pkg.__file__).parent - mcp_config = queen_pkg_dir / "mcp_servers.json" - if mcp_config.exists(): - _queen_tool_registry.load_mcp_config(mcp_config) - logger.info("Pre-loaded queen MCP tools from %s", mcp_config) - - registry = MCPRegistry() - registry.initialize() - registry.ensure_defaults() - if (queen_pkg_dir / "mcp_registry.json").is_file(): - _queen_tool_registry.set_mcp_registry_agent_path(queen_pkg_dir) - registry_configs, selection_max_tools = registry.load_agent_selection(queen_pkg_dir) - if registry_configs: - _queen_tool_registry.load_registry_servers( - registry_configs, - preserve_existing_tools=True, - log_collisions=True, - max_tools=selection_max_tools, - ) - logger.info("Pre-loaded queen tool registry with %d tools", len(_queen_tool_registry.get_tools())) - except Exception as e: - logger.warning("Failed to pre-load queen tool registry: %s", e) - - app["queen_tool_registry"] = _queen_tool_registry + # Let queen sessions build their registry lazily on first use instead of + # paying the MCP discovery cost during `hive open`. + app["queen_tool_registry"] = None app["manager"] = SessionManager( - model=model, credential_store=credential_store, queen_tool_registry=_queen_tool_registry + model=model, + credential_store=credential_store, + queen_tool_registry=None, ) # Register shutdown hook @@ -339,13 +310,14 @@ def create_app(model: str | None = None) -> web.Application: app.router.add_get("/api/browser/status/stream", handle_browser_status_stream) # Register route modules + from framework.server.routes_colony_workers import register_routes as register_colony_worker_routes from framework.server.routes_config import register_routes as register_config_routes from framework.server.routes_credentials import register_routes as register_credential_routes from framework.server.routes_events import register_routes as register_event_routes from framework.server.routes_execution import register_routes as register_execution_routes from framework.server.routes_logs import register_routes as register_log_routes from framework.server.routes_messages import register_routes as register_message_routes - from framework.server.routes_colony_workers import register_routes as register_colony_worker_routes + from framework.server.routes_prompts import register_routes as register_prompt_routes from framework.server.routes_queens import register_routes as register_queen_routes from framework.server.routes_sessions import register_routes as register_session_routes from framework.server.routes_workers import register_routes as register_worker_routes @@ -360,6 +332,7 @@ def create_app(model: str | None = None) -> web.Application: register_log_routes(app) register_queen_routes(app) register_colony_worker_routes(app) + register_prompt_routes(app) # Static file serving — Option C production mode # If frontend/dist/ exists, serve built frontend files on / diff --git a/core/framework/server/queen_orchestrator.py b/core/framework/server/queen_orchestrator.py index b5333a35..68606e46 100644 --- a/core/framework/server/queen_orchestrator.py +++ b/core/framework/server/queen_orchestrator.py @@ -429,13 +429,7 @@ async def create_queen( _has_vision, ) phase_state.prompt_working = finalize_queen_prompt( - ( - _queen_character_core - + _queen_role_working - + _queen_style - + _queen_tools_working - + _queen_behavior_always - ), + (_queen_character_core + _queen_role_working + _queen_style + _queen_tools_working + _queen_behavior_always), _has_vision, ) phase_state.prompt_reviewing = finalize_queen_prompt( diff --git a/core/framework/server/routes_colony_workers.py b/core/framework/server/routes_colony_workers.py index 22dd28d9..ba135ba3 100644 --- a/core/framework/server/routes_colony_workers.py +++ b/core/framework/server/routes_colony_workers.py @@ -6,16 +6,24 @@ profile panel. Distinct from ``routes_workers.py``, which deals with *graph nodes* inside a worker definition rather than live worker instances. +Session-scoped (bound to a live session's runtime): - GET /api/sessions/{session_id}/workers — live + completed workers - GET /api/sessions/{session_id}/colony/skills — colony's shared skills catalog - GET /api/sessions/{session_id}/colony/tools — colony's default tools -- GET /api/sessions/{session_id}/colony/progress/snapshot — progress.db tasks/steps snapshot -- GET /api/sessions/{session_id}/colony/progress/stream — SSE feed of upserts (polled) + +Colony-scoped (bound to the on-disk colony directory, independent of any +live session — one colony has exactly one progress.db): +- GET /api/colonies/{colony_name}/progress/snapshot — progress.db tasks/steps snapshot +- GET /api/colonies/{colony_name}/progress/stream — SSE feed of upserts (polled) +- GET /api/colonies/{colony_name}/data/tables — list user tables in progress.db +- GET /api/colonies/{colony_name}/data/tables/{table}/rows — paginated rows +- PATCH /api/colonies/{colony_name}/data/tables/{table}/rows — edit a row """ import asyncio import json import logging +import re import sqlite3 from pathlib import Path @@ -23,6 +31,11 @@ from aiohttp import web from framework.server.app import resolve_session +# Same validation used by create_colony — keep them in sync. Blocks path +# traversal (``..``) and shell-special chars; the endpoint would 400 on +# anything else anyway, but validating early avoids a disk hit. +_COLONY_NAME_RE = re.compile(r"^[a-z0-9_]+$") + logger = logging.getLogger(__name__) # Poll interval for the progress SSE stream. Progress rows flip on the @@ -92,9 +105,7 @@ async def handle_list_workers(request: web.Request) -> web.Response: storage_path = Path(session_dir) if storage_path is not None: - workers.extend( - await asyncio.to_thread(_walk_historical_workers, storage_path, known_ids) - ) + workers.extend(await asyncio.to_thread(_walk_historical_workers, storage_path, known_ids)) return web.json_response({"workers": workers}) @@ -180,6 +191,7 @@ def _extract_historical_task(worker_dir: Path) -> str: # ── Skills & tools ───────────────────────────────────────────────── + def _parsed_skill_to_dict(skill) -> dict: """Serialize a ParsedSkill for the frontend.""" return { @@ -271,14 +283,16 @@ async def handle_list_colony_tools(request: web.Request) -> web.Response: # ── Progress DB (tasks/steps) ────────────────────────────────────── -def _resolve_progress_db(session) -> Path | None: - """Resolve the colony's progress.db path for ``session``. - Returns ``None`` if the session is not bound to a colony yet or if - the DB file doesn't exist. +def _resolve_progress_db_by_name(colony_name: str) -> Path | None: + """Resolve a colony's progress.db path by directory name. + + Returns ``None`` when the name fails validation or the file does not + exist. Both conditions render as an empty Data tab in the UI rather + than a hard error so an operator can open the panel before any + workers have actually run. """ - colony_name = getattr(session, "colony_name", None) - if not colony_name: + if not _COLONY_NAME_RE.match(colony_name): return None db_path = Path.home() / ".hive" / "colonies" / colony_name / "data" / "progress.db" return db_path if db_path.exists() else None @@ -303,12 +317,8 @@ def _read_progress_snapshot(db_path: Path, worker_id: str | None) -> dict: (worker_id,), ).fetchall() else: - task_rows = con.execute( - "SELECT * FROM tasks ORDER BY updated_at DESC LIMIT 500" - ).fetchall() - step_rows = con.execute( - "SELECT * FROM steps ORDER BY task_id, seq LIMIT 2000" - ).fetchall() + task_rows = con.execute("SELECT * FROM tasks ORDER BY updated_at DESC LIMIT 500").fetchall() + step_rows = con.execute("SELECT * FROM steps ORDER BY task_id, seq LIMIT 2000").fetchall() return { "tasks": [dict(r) for r in task_rows], "steps": [dict(r) for r in step_rows], @@ -318,15 +328,12 @@ def _read_progress_snapshot(db_path: Path, worker_id: str | None) -> dict: async def handle_progress_snapshot(request: web.Request) -> web.Response: - """GET /api/sessions/{session_id}/colony/progress/snapshot + """GET /api/colonies/{colony_name}/progress/snapshot Optional ?worker_id=... to filter to rows touched by a specific worker. """ - session, err = resolve_session(request) - if err: - return err - - db_path = _resolve_progress_db(session) + colony_name = request.match_info["colony_name"] + db_path = _resolve_progress_db_by_name(colony_name) if db_path is None: return web.json_response({"tasks": [], "steps": []}) @@ -391,7 +398,7 @@ def _read_progress_upserts( async def handle_progress_stream(request: web.Request) -> web.StreamResponse: - """GET /api/sessions/{session_id}/colony/progress/stream + """GET /api/colonies/{colony_name}/progress/stream SSE feed that emits ``snapshot`` once (current state) followed by ``upsert`` events whenever a task/step row changes. Polls the DB @@ -399,10 +406,7 @@ async def handle_progress_stream(request: web.Request) -> web.StreamResponse: workers use for writes doesn't fire SQLite's update hook on our connection, so polling is the robust option. """ - session, err = resolve_session(request) - if err: - return err - + colony_name = request.match_info["colony_name"] worker_id = request.query.get("worker_id") or None resp = web.StreamResponse( @@ -420,7 +424,7 @@ async def handle_progress_stream(request: web.Request) -> web.StreamResponse: payload = f"event: {event}\ndata: {json.dumps(data)}\n\n" await resp.write(payload.encode("utf-8")) - db_path = _resolve_progress_db(session) + db_path = _resolve_progress_db_by_name(colony_name) if db_path is None: await _send("snapshot", {"tasks": [], "steps": []}) await _send("end", {"reason": "no_progress_db"}) @@ -447,9 +451,7 @@ async def handle_progress_stream(request: web.Request) -> web.StreamResponse: # required. while True: await asyncio.sleep(_PROGRESS_POLL_INTERVAL) - tasks, steps, new_since = await asyncio.to_thread( - _read_progress_upserts, db_path, worker_id, since - ) + tasks, steps, new_since = await asyncio.to_thread(_read_progress_upserts, db_path, worker_id, since) if tasks or steps: await _send("upsert", {"tasks": tasks, "steps": steps}) since = new_since @@ -465,20 +467,242 @@ async def handle_progress_stream(request: web.Request) -> web.StreamResponse: return resp +# ── Raw data grid (airtable-style view/edit of progress.db tables) ───── +# +# The Data tab lets the operator inspect and hand-edit SQLite rows. +# Identifier-quoting note: SQLite params can only bind values, never +# identifiers, so we have to interpolate table/column names into SQL. +# Every name is *validated against sqlite_master / PRAGMA table_info* +# before use and then wrapped with ``_q()`` which escapes embedded +# quotes. Do NOT accept raw names from the request without running them +# through ``_validate_ident`` first. + + +def _q(ident: str) -> str: + """Quote a SQLite identifier (table or column) safely.""" + return '"' + ident.replace('"', '""') + '"' + + +def _list_user_tables(con: sqlite3.Connection) -> list[str]: + return [ + r["name"] + for r in con.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ) + ] + + +def _table_columns(con: sqlite3.Connection, table: str) -> list[dict]: + """Return PRAGMA table_info rows as dicts. Empty list if no such table.""" + return [ + { + "name": r["name"], + "type": r["type"] or "", + "notnull": bool(r["notnull"]), + # pk>0 means the column is part of the primary key (ordinal); + # 0 means non-PK. + "pk": int(r["pk"]), + "dflt_value": r["dflt_value"], + } + for r in con.execute(f"PRAGMA table_info({_q(table)})") + ] + + +def _read_tables_overview(db_path: Path) -> list[dict]: + """List user tables with columns + row counts.""" + con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=5.0) + try: + con.row_factory = sqlite3.Row + out: list[dict] = [] + for name in _list_user_tables(con): + cols = _table_columns(con, name) + count_row = con.execute(f"SELECT COUNT(*) AS c FROM {_q(name)}").fetchone() + out.append( + { + "name": name, + "columns": cols, + "row_count": int(count_row["c"]), + "primary_key": [c["name"] for c in cols if c["pk"] > 0], + } + ) + return out + finally: + con.close() + + +def _validate_ident(name: str, known: set[str]) -> str | None: + """Return ``name`` if present in ``known``, else ``None``.""" + return name if name in known else None + + +def _read_table_rows( + db_path: Path, + table: str, + limit: int, + offset: int, + order_by: str | None, + order_dir: str, +) -> dict: + con = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=5.0) + try: + con.row_factory = sqlite3.Row + tables = set(_list_user_tables(con)) + if _validate_ident(table, tables) is None: + return {"error": f"unknown table: {table}"} + cols = _table_columns(con, table) + col_names = {c["name"] for c in cols} + + sql = f"SELECT * FROM {_q(table)}" + if order_by and order_by in col_names: + direction = "DESC" if order_dir.lower() == "desc" else "ASC" + sql += f" ORDER BY {_q(order_by)} {direction}" + sql += " LIMIT ? OFFSET ?" + rows = con.execute(sql, (int(limit), int(offset))).fetchall() + total = con.execute(f"SELECT COUNT(*) AS c FROM {_q(table)}").fetchone()["c"] + return { + "table": table, + "columns": cols, + "primary_key": [c["name"] for c in cols if c["pk"] > 0], + "rows": [dict(r) for r in rows], + "total": int(total), + "limit": int(limit), + "offset": int(offset), + } + finally: + con.close() + + +def _update_table_row( + db_path: Path, + table: str, + pk: dict, + updates: dict, +) -> dict: + """Apply ``updates`` (column->value) to the row matching ``pk``. + + Returns ``{"updated": n}`` with the number of rows affected (0 or 1), + or ``{"error": ...}`` on validation failure. + """ + if not updates: + return {"error": "no updates provided"} + con = sqlite3.connect(db_path, timeout=5.0) + try: + con.row_factory = sqlite3.Row + tables = set(_list_user_tables(con)) + if _validate_ident(table, tables) is None: + return {"error": f"unknown table: {table}"} + cols = _table_columns(con, table) + col_names = {c["name"] for c in cols} + pk_cols = [c["name"] for c in cols if c["pk"] > 0] + if not pk_cols: + return {"error": f"table {table!r} has no primary key; cannot edit by row"} + + # Validate pk has every pk column and all values are scalars. + missing = [p for p in pk_cols if p not in pk] + if missing: + return {"error": f"missing primary key columns: {missing}"} + + # Validate update columns exist and aren't part of the primary key + # (changing a PK column would silently break joins/foreign refs). + bad = [c for c in updates if c not in col_names] + if bad: + return {"error": f"unknown columns: {bad}"} + pk_update = [c for c in updates if c in pk_cols] + if pk_update: + return {"error": f"cannot edit primary key columns: {pk_update}"} + + set_sql = ", ".join(f"{_q(c)} = ?" for c in updates) + where_sql = " AND ".join(f"{_q(c)} = ?" for c in pk_cols) + sql = f"UPDATE {_q(table)} SET {set_sql} WHERE {where_sql}" + params = list(updates.values()) + [pk[c] for c in pk_cols] + cur = con.execute(sql, params) + con.commit() + return {"updated": cur.rowcount} + finally: + con.close() + + +async def handle_list_tables(request: web.Request) -> web.Response: + """GET /api/colonies/{colony_name}/data/tables""" + colony_name = request.match_info["colony_name"] + db_path = _resolve_progress_db_by_name(colony_name) + if db_path is None: + return web.json_response({"tables": []}) + tables = await asyncio.to_thread(_read_tables_overview, db_path) + return web.json_response({"tables": tables}) + + +async def handle_table_rows(request: web.Request) -> web.Response: + """GET /api/colonies/{colony_name}/data/tables/{table}/rows""" + colony_name = request.match_info["colony_name"] + db_path = _resolve_progress_db_by_name(colony_name) + if db_path is None: + return web.json_response({"error": "no progress.db"}, status=404) + + table = request.match_info["table"] + # Clamp limit: 500 is enough for the grid's virtualization window; + # a larger cap would make accidental full-table loads cheap. + try: + limit = max(1, min(500, int(request.query.get("limit", "100")))) + offset = max(0, int(request.query.get("offset", "0"))) + except ValueError: + return web.json_response({"error": "invalid limit/offset"}, status=400) + order_by = request.query.get("order_by") or None + order_dir = request.query.get("order_dir", "asc") + + result = await asyncio.to_thread(_read_table_rows, db_path, table, limit, offset, order_by, order_dir) + if "error" in result: + return web.json_response(result, status=400) + return web.json_response(result) + + +async def handle_update_row(request: web.Request) -> web.Response: + """PATCH /api/colonies/{colony_name}/data/tables/{table}/rows + + Body: ``{"pk": {col: value, ...}, "updates": {col: value, ...}}``. + """ + colony_name = request.match_info["colony_name"] + db_path = _resolve_progress_db_by_name(colony_name) + if db_path is None: + return web.json_response({"error": "no progress.db"}, status=404) + + try: + body = await request.json() + except Exception: + return web.json_response({"error": "invalid JSON body"}, status=400) + pk = body.get("pk") or {} + updates = body.get("updates") or {} + if not isinstance(pk, dict) or not isinstance(updates, dict): + return web.json_response({"error": "pk and updates must be objects"}, status=400) + + table = request.match_info["table"] + result = await asyncio.to_thread(_update_table_row, db_path, table, pk, updates) + if "error" in result: + return web.json_response(result, status=400) + return web.json_response(result) + + def register_routes(app: web.Application) -> None: """Register colony worker routes.""" + # Session-scoped — these read live runtime state from a session. app.router.add_get("/api/sessions/{session_id}/workers", handle_list_workers) + app.router.add_get("/api/sessions/{session_id}/colony/skills", handle_list_colony_skills) + app.router.add_get("/api/sessions/{session_id}/colony/tools", handle_list_colony_tools) + # Colony-scoped — one progress.db per colony, no session indirection. app.router.add_get( - "/api/sessions/{session_id}/colony/skills", handle_list_colony_skills - ) - app.router.add_get( - "/api/sessions/{session_id}/colony/tools", handle_list_colony_tools - ) - app.router.add_get( - "/api/sessions/{session_id}/colony/progress/snapshot", + "/api/colonies/{colony_name}/progress/snapshot", handle_progress_snapshot, ) app.router.add_get( - "/api/sessions/{session_id}/colony/progress/stream", + "/api/colonies/{colony_name}/progress/stream", handle_progress_stream, ) + app.router.add_get("/api/colonies/{colony_name}/data/tables", handle_list_tables) + app.router.add_get( + "/api/colonies/{colony_name}/data/tables/{table}/rows", + handle_table_rows, + ) + app.router.add_patch( + "/api/colonies/{colony_name}/data/tables/{table}/rows", + handle_update_row, + ) diff --git a/core/framework/server/routes_config.py b/core/framework/server/routes_config.py index 8302501c..74d710b4 100644 --- a/core/framework/server/routes_config.py +++ b/core/framework/server/routes_config.py @@ -6,6 +6,7 @@ Routes: - GET /api/config/models — curated provider→models list """ +import asyncio import json import logging import os @@ -301,6 +302,53 @@ def _hot_swap_sessions(request: web.Request, full_model: str, api_key: str | Non return swapped +async def _validate_provider_key( + provider: str, + api_key: str, + api_base: str | None = None, + model: str | None = None, +) -> dict: + """Validate an API key against the provider. Returns {"valid": bool, "message": str}. + + Runs the check in a thread pool to avoid blocking the event loop. + """ + from scripts.check_llm_key import ( + PROVIDERS as CHECK_PROVIDERS, + check_anthropic_compatible, + check_minimax, + check_openai_compatible, + check_openrouter, + check_openrouter_model, + ) + + def _check() -> dict: + pid = provider.lower() + try: + # Subscription providers with custom api_base + if pid == "openrouter" and model: + return check_openrouter_model(api_key, model=model, api_base=api_base or "https://openrouter.ai/api/v1") + if api_base and pid == "minimax": + return check_minimax(api_key, api_base) + if api_base and pid == "openrouter": + return check_openrouter(api_key, api_base) + if api_base and pid == "kimi": + return check_anthropic_compatible(api_key, api_base.rstrip("/") + "/v1/messages", "Kimi") + if api_base and pid == "hive": + return check_anthropic_compatible(api_key, api_base.rstrip("/") + "/v1/messages", "Hive") + if api_base: + endpoint = api_base.rstrip("/") + "/models" + name = {"zai": "ZAI"}.get(pid, "Custom provider") + return check_openai_compatible(api_key, endpoint, name) + if pid in CHECK_PROVIDERS: + return CHECK_PROVIDERS[pid](api_key) + # No check available — assume valid + return {"valid": True, "message": f"No health check for {pid}"} + except Exception as exc: + return {"valid": None, "message": f"Validation error: {exc}"} + + return await asyncio.get_event_loop().run_in_executor(None, _check) + + # ------------------------------------------------------------------ # Handlers # ------------------------------------------------------------------ @@ -324,9 +372,9 @@ async def handle_get_llm_config(request: web.Request) -> web.Response: if _resolve_api_key(pid, request) is not None: connected.append(pid) - # Subscription detection + # Subscription detection — only include subscriptions whose tokens exist active_subscription = _get_active_subscription(llm) - detected_subscriptions = _detect_subscriptions() + detected_subscriptions = [sid for sid in _detect_subscriptions() if _get_subscription_token(sid)] return web.json_response( { @@ -369,6 +417,21 @@ async def handle_update_llm_config(request: web.Request) -> web.Response: provider = sub["provider"] api_base = sub.get("api_base") + # Validate the subscription token before committing + token = _get_subscription_token(subscription_id) + if not token: + return web.json_response( + {"error": f"No credential found for {sub['name']}. Please check your subscription or API key."}, + status=400, + ) + + check = await _validate_provider_key(provider, token, api_base=api_base) + if check.get("valid") is False: + return web.json_response( + {"error": f"{sub['name']} key validation failed: {check.get('message', 'unknown error')}"}, + status=400, + ) + # Look up token limits from preset max_tokens: int | None = None max_context_tokens: int | None = None @@ -399,8 +462,7 @@ async def handle_update_llm_config(request: web.Request) -> web.Response: _write_config_atomic(config) - # Hot-swap with subscription token - token = _get_subscription_token(subscription_id) + # Hot-swap with subscription token (already validated above) full_model = f"{provider}/{model}" swapped = _hot_swap_sessions(request, full_model, api_key=token, api_base=api_base) @@ -430,15 +492,36 @@ async def handle_update_llm_config(request: web.Request) -> web.Response: if not provider or not model: return web.json_response({"error": "Both 'provider' and 'model' are required"}, status=400) - # Look up token limits from catalogue + # Verify model exists in the catalogue model_info = _find_model_info(provider, model) - max_tokens = model_info["max_tokens"] if model_info else 8192 - max_context_tokens = model_info["max_context_tokens"] if model_info else 120000 + if not model_info: + return web.json_response( + {"error": f"Model '{model}' is not available for provider '{provider}'."}, + status=400, + ) + + max_tokens = model_info["max_tokens"] + max_context_tokens = model_info["max_context_tokens"] # Determine env var and api_base env_var = PROVIDER_ENV_VARS.get(provider.lower(), "") api_base = _get_api_base_for_provider(provider) + # Validate the API key before committing + api_key = _resolve_api_key(provider, request) + if not api_key: + return web.json_response( + {"error": f"No API key found for {provider}. Please add one in Manage Keys."}, + status=400, + ) + + check = await _validate_provider_key(provider, api_key, api_base=api_base, model=model) + if check.get("valid") is False: + return web.json_response( + {"error": f"API key validation failed for {provider}: {check.get('message', 'unknown error')}"}, + status=400, + ) + # Update ~/.hive/configuration.json config = get_hive_config() llm_section = config.setdefault("llm", {}) @@ -458,8 +541,7 @@ async def handle_update_llm_config(request: web.Request) -> web.Response: _write_config_atomic(config) - # Hot-swap all running sessions - api_key = _resolve_api_key(provider, request) + # Hot-swap all running sessions (api_key already validated above) full_model = f"{provider}/{model}" swapped = _hot_swap_sessions(request, full_model, api_key=api_key, api_base=api_base) @@ -594,6 +676,64 @@ async def handle_get_models(request: web.Request) -> web.Response: return web.json_response({"models": MODELS_CATALOGUE}) +# ------------------------------------------------------------------ +# User avatar +# ------------------------------------------------------------------ + +MAX_AVATAR_BYTES = 2 * 1024 * 1024 # 2 MB +_ALLOWED_AVATAR_TYPES = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", +} + + +async def handle_upload_user_avatar(request: web.Request) -> web.Response: + """POST /api/config/profile/avatar — upload user profile picture.""" + reader = await request.multipart() + field = await reader.next() + if field is None or field.name != "avatar": + return web.json_response({"error": "Expected a file field named 'avatar'"}, status=400) + + content_type = getattr(field, "content_type", None) or field.headers.get("Content-Type", "") + ext = _ALLOWED_AVATAR_TYPES.get(content_type) + if not ext: + return web.json_response( + {"error": f"Unsupported image type: {content_type}. Use JPEG, PNG, or WebP."}, + status=400, + ) + + data = bytearray() + while True: + chunk = await field.read_chunk(8192) + if not chunk: + break + data.extend(chunk) + if len(data) > MAX_AVATAR_BYTES: + return web.json_response({"error": "Image too large. Maximum size is 2 MB."}, status=400) + + if not data: + return web.json_response({"error": "Empty file"}, status=400) + + # Remove existing avatar files + for existing in HIVE_CONFIG_FILE.parent.glob("avatar.*"): + existing.unlink(missing_ok=True) + + avatar_path = HIVE_CONFIG_FILE.parent / f"avatar{ext}" + avatar_path.write_bytes(data) + logger.info("User avatar uploaded: %s (%d bytes)", avatar_path.name, len(data)) + return web.json_response({"avatar_url": "/api/config/profile/avatar"}) + + +async def handle_get_user_avatar(request: web.Request) -> web.Response: + """GET /api/config/profile/avatar — serve user profile picture.""" + for ext in _ALLOWED_AVATAR_TYPES.values(): + avatar_path = HIVE_CONFIG_FILE.parent / f"avatar{ext}" + if avatar_path.exists(): + return web.FileResponse(avatar_path, headers={"Cache-Control": "public, max-age=3600"}) + return web.json_response({"error": "No avatar found"}, status=404) + + # ------------------------------------------------------------------ # Route registration # ------------------------------------------------------------------ @@ -606,3 +746,5 @@ def register_routes(app: web.Application) -> None: app.router.add_get("/api/config/models", handle_get_models) app.router.add_get("/api/config/profile", handle_get_profile) app.router.add_put("/api/config/profile", handle_update_profile) + app.router.add_post("/api/config/profile/avatar", handle_upload_user_avatar) + app.router.add_get("/api/config/profile/avatar", handle_get_user_avatar) diff --git a/core/framework/server/routes_execution.py b/core/framework/server/routes_execution.py index c603f08f..bf5614b9 100644 --- a/core/framework/server/routes_execution.py +++ b/core/framework/server/routes_execution.py @@ -10,6 +10,7 @@ from aiohttp import web from framework.agent_loop.conversation import LEGACY_RUN_ID from framework.credentials.validation import validate_agent_credentials +from framework.host.execution_manager import ExecutionAlreadyRunningError from framework.server.app import resolve_session, safe_path_segment, sessions_dir from framework.server.routes_sessions import _credential_error_response @@ -101,6 +102,17 @@ def _resolve_queen_only_tools() -> frozenset[str]: return frozenset(derived | _QUEEN_LIFECYCLE_EXTRAS) +def _execution_already_running_response(exc: ExecutionAlreadyRunningError) -> web.Response: + return web.json_response( + { + "error": str(exc), + "stream_id": exc.stream_id, + "active_execution_ids": exc.active_ids, + }, + status=409, + ) + + async def handle_trigger(request: web.Request) -> web.Response: """POST /api/sessions/{session_id}/trigger — start an execution. @@ -142,11 +154,14 @@ async def handle_trigger(request: web.Request) -> web.Response: if "resume_session_id" not in session_state: session_state["resume_session_id"] = session.id - execution_id = await session.colony_runtime.trigger( - entry_point_id, - input_data, - session_state=session_state, - ) + try: + execution_id = await session.colony_runtime.trigger( + entry_point_id, + input_data, + session_state=session_state, + ) + except ExecutionAlreadyRunningError as exc: + return _execution_already_running_response(exc) # Cancel queen's in-progress LLM turn so it picks up the phase change cleanly if session.queen_executor: @@ -435,11 +450,14 @@ async def handle_resume(request: web.Request) -> web.Response: input_data = state.get("input_data", {}) - execution_id = await session.colony_runtime.trigger( - entry_points[0].id, - input_data=input_data, - session_state=resume_session_state, - ) + try: + execution_id = await session.colony_runtime.trigger( + entry_points[0].id, + input_data=input_data, + session_state=resume_session_state, + ) + except ExecutionAlreadyRunningError as exc: + return _execution_already_running_response(exc) return web.json_response( { @@ -466,6 +484,7 @@ async def handle_pause(request: web.Request) -> web.Response: runtime = session.colony_runtime cancelled = [] + cancelling = [] for colony_id in runtime.list_graphs(): reg = runtime.get_graph_registration(colony_id) @@ -482,9 +501,11 @@ async def handle_pause(request: web.Request) -> web.Response: for exec_id in list(stream.active_execution_ids): try: - ok = await stream.cancel_execution(exec_id, reason="Execution paused by user") - if ok: + outcome = await stream.cancel_execution(exec_id, reason="Execution paused by user") + if outcome == "cancelled": cancelled.append(exec_id) + elif outcome == "cancelling": + cancelling.append(exec_id) except Exception: pass @@ -498,8 +519,9 @@ async def handle_pause(request: web.Request) -> web.Response: return web.json_response( { - "stopped": bool(cancelled), + "stopped": bool(cancelled) and not cancelling, "cancelled": cancelled, + "cancelling": cancelling, "timers_paused": True, } ) @@ -536,8 +558,9 @@ async def handle_stop(request: web.Request) -> web.Response: if hasattr(node, "cancel_current_turn"): node.cancel_current_turn() - cancelled = await stream.cancel_execution(execution_id, reason="Execution stopped by user") - if cancelled: + outcome = await stream.cancel_execution(execution_id, reason="Execution stopped by user") + + if outcome == "cancelled": # Cancel queen's in-progress LLM turn if session.queen_executor: node = session.queen_executor.node_registry.get("queen") @@ -552,9 +575,19 @@ async def handle_stop(request: web.Request) -> web.Response: return web.json_response( { "stopped": True, + "cancelling": False, "execution_id": execution_id, } ) + if outcome == "cancelling": + return web.json_response( + { + "stopped": False, + "cancelling": True, + "execution_id": execution_id, + }, + status=202, + ) return web.json_response({"stopped": False, "error": "Execution not found"}, status=404) @@ -597,11 +630,14 @@ async def handle_replay(request: web.Request) -> web.Response: "run_id": _load_checkpoint_run_id(cp_path), } - execution_id = await session.colony_runtime.trigger( - entry_points[0].id, - input_data={}, - session_state=replay_session_state, - ) + try: + execution_id = await session.colony_runtime.trigger( + entry_points[0].id, + input_data={}, + session_state=replay_session_state, + ) + except ExecutionAlreadyRunningError as exc: + return _execution_already_running_response(exc) return web.json_response( { @@ -712,15 +748,71 @@ async def fork_session_into_colony( from pathlib import Path from framework.agent_loop.agent_loop import AgentLoop, LoopConfig - from framework.agent_loop.types import AgentContext, AgentSpec + from framework.agent_loop.types import AgentContext from framework.host.progress_db import ensure_progress_db, seed_tasks from framework.server.session_manager import _queen_session_dir - queen_loop: AgentLoop = session.queen_executor.node_registry["queen"] - queen_ctx: AgentContext = getattr(queen_loop, "_last_ctx", None) + # Diagnostic capture: when the fork fails here we want to know which + # piece of queen state was missing (executor cleared vs. node missing + # vs. _last_ctx never stamped). Without this, callers only see + # "'NoneType' object has no attribute 'node_registry'" with no hint + # whether the queen loop exited, is mid-revive, or ran a different + # path that never ran AgentLoop._execute_impl. + queen_executor = getattr(session, "queen_executor", None) + queen_task = getattr(session, "queen_task", None) + phase_state_dbg = getattr(session, "phase_state", None) + logger.info( + "[fork_session_into_colony] session=%s colony=%s " + "queen_executor=%s queen_task=%s queen_task_done=%s " + "phase=%s queen_name=%s", + session.id, + colony_name, + queen_executor, + queen_task, + queen_task.done() if queen_task is not None else None, + getattr(phase_state_dbg, "phase", None), + getattr(session, "queen_name", None), + ) + if queen_executor is None: + raise RuntimeError( + f"queen_executor is None for session {session.id!r} — the " + "queen loop isn't running right now. Wait for the queen to " + "come back (or send her a chat message to revive her) and " + "retry create_colony. The skill folder is already written, " + "so the retry is free." + ) + + node_registry = getattr(queen_executor, "node_registry", None) + if not isinstance(node_registry, dict) or "queen" not in node_registry: + raise RuntimeError( + f"queen node is missing from the executor's registry for " + f"session {session.id!r} (registry keys=" + f"{list(node_registry.keys()) if isinstance(node_registry, dict) else type(node_registry).__name__}" + "). The queen loop is in an initialization or teardown " + "window; retry after a moment." + ) + + queen_loop: AgentLoop = node_registry["queen"] + queen_ctx: AgentContext = getattr(queen_loop, "_last_ctx", None) + if queen_ctx is None: + logger.warning( + "[fork_session_into_colony] queen_loop has no _last_ctx yet " + "(session=%s) — falling back to empty tool/skill snapshot; " + "the forked worker will inherit no tools.", + session.id, + ) + + # "is_new" keys off worker.json, not bare dir existence: the queen's + # create_colony tool now pre-creates colony_dir (so it can + # materialize the colony-scoped skill folder BEFORE the fork), which + # would wrongly flag every fresh colony as "already-exists" if we + # used ``not colony_dir.exists()``. A colony is "new" until its + # worker config has actually been written. colony_dir = Path.home() / ".hive" / "colonies" / colony_name - is_new = not colony_dir.exists() + worker_name = "worker" + worker_config_path = colony_dir / f"{worker_name}.json" + is_new = not worker_config_path.exists() colony_dir.mkdir(parents=True, exist_ok=True) (colony_dir / "data").mkdir(exist_ok=True) @@ -730,9 +822,7 @@ async def fork_session_into_colony( db_path = await asyncio.to_thread(ensure_progress_db, colony_dir) seeded_task_ids: list[str] = [] if tasks: - seeded_task_ids = await asyncio.to_thread( - seed_tasks, db_path, tasks, source="queen_create" - ) + seeded_task_ids = await asyncio.to_thread(seed_tasks, db_path, tasks, source="queen_create") logger.info( "progress_db: seeded %d task(s) into colony '%s'", len(seeded_task_ids), @@ -754,23 +844,20 @@ async def fork_session_into_colony( source="create_colony_auto", ) logger.info( - "progress_db: auto-seeded 1 task into colony '%s' " - "(task_id=%s, from single-task create_colony form)", + "progress_db: auto-seeded 1 task into colony '%s' (task_id=%s, from single-task create_colony form)", colony_name, seeded_task_ids[0] if seeded_task_ids else "?", ) except Exception as exc: logger.warning( - "progress_db: auto-seed failed for colony '%s' (continuing " - "without a pre-seeded row): %s", + "progress_db: auto-seed failed for colony '%s' (continuing without a pre-seeded row): %s", colony_name, exc, ) - # Fixed worker name -- sessions are the unit of parallelism, not workers - worker_name = "worker" - - worker_config_path = colony_dir / f"{worker_name}.json" + # Fixed worker name and config path are already computed above so + # ``is_new`` can be derived from worker.json rather than the colony + # directory (see comment on the ``is_new`` block). # ── 1. Gather queen state ───────────────────────────────────── # Queen-lifecycle + agent-management tools are registered ONLY against diff --git a/core/framework/server/routes_prompts.py b/core/framework/server/routes_prompts.py new file mode 100644 index 00000000..278f63af --- /dev/null +++ b/core/framework/server/routes_prompts.py @@ -0,0 +1,87 @@ +"""Custom user prompts — CRUD for user-uploaded prompts. + +- GET /api/prompts — list all custom prompts +- POST /api/prompts — add a new custom prompt +- DELETE /api/prompts/{id} — delete a custom prompt +""" + +import json +import logging +import time + +from aiohttp import web + +from framework.config import HIVE_HOME + +logger = logging.getLogger(__name__) + +CUSTOM_PROMPTS_FILE = HIVE_HOME / "custom_prompts.json" + + +def _load_custom_prompts() -> list[dict]: + if not CUSTOM_PROMPTS_FILE.exists(): + return [] + try: + data = json.loads(CUSTOM_PROMPTS_FILE.read_text(encoding="utf-8")) + return data if isinstance(data, list) else [] + except Exception: + return [] + + +def _save_custom_prompts(prompts: list[dict]) -> None: + CUSTOM_PROMPTS_FILE.parent.mkdir(parents=True, exist_ok=True) + CUSTOM_PROMPTS_FILE.write_text( + json.dumps(prompts, indent=2, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + + +async def handle_list_prompts(request: web.Request) -> web.Response: + """GET /api/prompts — list all custom prompts.""" + return web.json_response({"prompts": _load_custom_prompts()}) + + +async def handle_create_prompt(request: web.Request) -> web.Response: + """POST /api/prompts — add a new custom prompt.""" + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON body"}, status=400) + + title = (body.get("title") or "").strip() + category = (body.get("category") or "").strip() + content = (body.get("content") or "").strip() + + if not title or not content: + return web.json_response({"error": "Title and content are required"}, status=400) + + prompts = _load_custom_prompts() + new_prompt = { + "id": f"custom_{int(time.time() * 1000)}", + "title": title, + "category": category or "custom", + "content": content, + "custom": True, + } + prompts.append(new_prompt) + _save_custom_prompts(prompts) + logger.info("Custom prompt added: %s", title) + return web.json_response(new_prompt, status=201) + + +async def handle_delete_prompt(request: web.Request) -> web.Response: + """DELETE /api/prompts/{prompt_id} — delete a custom prompt.""" + prompt_id = request.match_info["prompt_id"] + prompts = _load_custom_prompts() + before = len(prompts) + prompts = [p for p in prompts if p.get("id") != prompt_id] + if len(prompts) == before: + return web.json_response({"error": "Prompt not found"}, status=404) + _save_custom_prompts(prompts) + return web.json_response({"deleted": prompt_id}) + + +def register_routes(app: web.Application) -> None: + app.router.add_get("/api/prompts", handle_list_prompts) + app.router.add_post("/api/prompts", handle_create_prompt) + app.router.add_delete("/api/prompts/{prompt_id}", handle_delete_prompt) diff --git a/core/framework/server/routes_queens.py b/core/framework/server/routes_queens.py index d98b2377..201943c4 100644 --- a/core/framework/server/routes_queens.py +++ b/core/framework/server/routes_queens.py @@ -3,6 +3,8 @@ - GET /api/queen/profiles -- list all queen profiles (id, name, title) - GET /api/queen/{queen_id}/profile -- get full queen profile - PATCH /api/queen/{queen_id}/profile -- update queen profile fields +- POST /api/queen/{queen_id}/avatar -- upload queen avatar image +- GET /api/queen/{queen_id}/avatar -- serve queen avatar image - POST /api/queen/{queen_id}/session -- get or create a persistent session for a queen - POST /api/queen/{queen_id}/session/select -- resume a specific session for a queen - POST /api/queen/{queen_id}/session/new -- create a fresh session for a queen @@ -166,6 +168,34 @@ async def handle_get_profile(request: web.Request) -> web.Response: return web.json_response({"id": queen_id, **api_profile}) +def _reverse_transform_for_yaml(body: dict) -> dict: + """Map API-format fields back to YAML profile fields. + + The API exposes a simplified view (summary, skills, signature_achievement) + that maps onto the underlying YAML structure (core_traits, hidden_background, + psychological_profile, world_lore, etc.). + """ + yaml_updates: dict[str, Any] = {} + + if "name" in body: + yaml_updates["name"] = body["name"] + if "title" in body: + yaml_updates["title"] = body["title"] + + if "summary" in body: + # Summary is displayed as core_traits + anti_stereotype joined by \n\n. + # Store the full text in core_traits for simplicity. + yaml_updates["core_traits"] = body["summary"] + + if "skills" in body: + yaml_updates["skills"] = body["skills"] + + if "signature_achievement" in body: + yaml_updates.setdefault("world_lore", {})["habitat"] = body["signature_achievement"] + + return yaml_updates + + async def handle_update_profile(request: web.Request) -> web.Response: """PATCH /api/queen/{queen_id}/profile — update queen profile fields.""" queen_id = request.match_info["queen_id"] @@ -175,11 +205,18 @@ async def handle_update_profile(request: web.Request) -> web.Response: return web.json_response({"error": "Invalid JSON body"}, status=400) if not isinstance(body, dict): return web.json_response({"error": "Body must be a JSON object"}, status=400) + + yaml_updates = _reverse_transform_for_yaml(body) + if not yaml_updates: + return web.json_response({"error": "No valid fields to update"}, status=400) + try: - updated = update_queen_profile(queen_id, body) + updated = update_queen_profile(queen_id, yaml_updates) except FileNotFoundError: return web.json_response({"error": f"Queen '{queen_id}' not found"}, status=404) - return web.json_response({"id": queen_id, **updated}) + + api_profile = _transform_profile_for_api(updated) + return web.json_response({"id": queen_id, **api_profile}) async def handle_queen_session(request: web.Request) -> web.Response: @@ -207,9 +244,12 @@ async def handle_queen_session(request: web.Request) -> web.Response: initial_prompt = body.get("initial_prompt") initial_phase = body.get("initial_phase") - # 1. Check for an existing live session bound to this queen. + # 1. Check for an existing live DM session bound to this queen. + # Skip colony sessions: a colony forked from this queen also carries + # queen_name == queen_id, but it has a worker loaded (colony_id / + # worker_path set) and is the colony's chat, not the queen's DM. for session in manager.list_sessions(): - if session.queen_name == queen_id: + if session.queen_name == queen_id and session.colony_id is None and session.worker_path is None: return web.json_response( { "session_id": session.id, @@ -365,11 +405,98 @@ async def handle_new_queen_session(request: web.Request) -> web.Response: ) +MAX_AVATAR_BYTES = 2 * 1024 * 1024 # 2 MB max after compression +_ALLOWED_AVATAR_TYPES = { + "image/jpeg": ".jpg", + "image/png": ".png", + "image/webp": ".webp", +} + + +async def handle_upload_avatar(request: web.Request) -> web.Response: + """POST /api/queen/{queen_id}/avatar — upload queen avatar image. + + Accepts multipart/form-data with a single file field named 'avatar'. + Stores as avatar.{ext} in the queen's profile directory. + """ + from framework.config import QUEENS_DIR + + queen_id = request.match_info["queen_id"] + queen_dir = QUEENS_DIR / queen_id + if not (queen_dir / "profile.yaml").exists(): + return web.json_response({"error": f"Queen '{queen_id}' not found"}, status=404) + + reader = await request.multipart() + field = await reader.next() + if field is None or field.name != "avatar": + return web.json_response({"error": "Expected a file field named 'avatar'"}, status=400) + + content_type = field.headers.get("Content-Type", "application/octet-stream") + # Also check by content_type from the field + if hasattr(field, "content_type"): + content_type = field.content_type or content_type + + ext = _ALLOWED_AVATAR_TYPES.get(content_type) + if not ext: + return web.json_response( + {"error": f"Unsupported image type: {content_type}. Use JPEG, PNG, or WebP."}, + status=400, + ) + + # Read the file data with size limit + data = bytearray() + while True: + chunk = await field.read_chunk(8192) + if not chunk: + break + data.extend(chunk) + if len(data) > MAX_AVATAR_BYTES: + return web.json_response( + {"error": f"Image too large. Maximum size is {MAX_AVATAR_BYTES // 1024 // 1024} MB."}, + status=400, + ) + + if not data: + return web.json_response({"error": "Empty file"}, status=400) + + # Remove any existing avatar files + for existing in queen_dir.glob("avatar.*"): + existing.unlink(missing_ok=True) + + # Write the new avatar + avatar_path = queen_dir / f"avatar{ext}" + avatar_path.write_bytes(data) + + logger.info("Avatar uploaded for queen %s: %s (%d bytes)", queen_id, avatar_path.name, len(data)) + return web.json_response({"avatar_url": f"/api/queen/{queen_id}/avatar"}) + + +async def handle_get_avatar(request: web.Request) -> web.Response: + """GET /api/queen/{queen_id}/avatar — serve queen avatar image.""" + from framework.config import QUEENS_DIR + + queen_id = request.match_info["queen_id"] + queen_dir = QUEENS_DIR / queen_id + + # Find avatar file with any supported extension + for ext in _ALLOWED_AVATAR_TYPES.values(): + avatar_path = queen_dir / f"avatar{ext}" + if avatar_path.exists(): + return web.FileResponse( + avatar_path, + headers={"Cache-Control": "public, max-age=3600"}, + ) + + return web.json_response({"error": "No avatar found"}, status=404) + + def register_routes(app: web.Application) -> None: """Register queen profile routes.""" app.router.add_get("/api/queen/profiles", handle_list_profiles) app.router.add_get("/api/queen/{queen_id}/profile", handle_get_profile) app.router.add_patch("/api/queen/{queen_id}/profile", handle_update_profile) + app.router.add_post("/api/queen/{queen_id}/avatar", handle_upload_avatar) + app.router.add_get("/api/queen/{queen_id}/avatar", handle_get_avatar) app.router.add_post("/api/queen/{queen_id}/session", handle_queen_session) app.router.add_post("/api/queen/{queen_id}/session/select", handle_select_queen_session) app.router.add_post("/api/queen/{queen_id}/session/new", handle_new_queen_session) diff --git a/core/framework/server/routes_workers.py b/core/framework/server/routes_workers.py index 1371c5fb..9b156efe 100644 --- a/core/framework/server/routes_workers.py +++ b/core/framework/server/routes_workers.py @@ -465,9 +465,7 @@ def register_routes(app: web.Application) -> None: # actually live) and returns the WorkerSummary shape the frontend # types against. Registering a duplicate here shadowed it in # aiohttp's router and broke the Sessions tab. - app.router.add_get( - "/api/sessions/{session_id}/workers/stream", handle_live_workers_stream - ) + app.router.add_get("/api/sessions/{session_id}/workers/stream", handle_live_workers_stream) app.router.add_post( "/api/sessions/{session_id}/workers/stop-all", handle_stop_all_live_workers, diff --git a/core/framework/server/session_manager.py b/core/framework/server/session_manager.py index a8948c35..d32fcb43 100644 --- a/core/framework/server/session_manager.py +++ b/core/framework/server/session_manager.py @@ -149,13 +149,9 @@ class SessionManager: try: ensured = ensure_all_colony_dbs() if ensured: - logger.info( - "progress_db: ensured %d colony DB(s) at startup", len(ensured) - ) + logger.info("progress_db: ensured %d colony DB(s) at startup", len(ensured)) except Exception: - logger.warning( - "progress_db: backfill at startup failed (non-fatal)", exc_info=True - ) + logger.warning("progress_db: backfill at startup failed (non-fatal)", exc_info=True) def build_llm(self, model: str | None = None): """Construct an LLM provider using the server's configured defaults.""" diff --git a/core/framework/server/tests/test_api.py b/core/framework/server/tests/test_api.py index 6a1cc85e..53f3820f 100644 --- a/core/framework/server/tests/test_api.py +++ b/core/framework/server/tests/test_api.py @@ -14,6 +14,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest from aiohttp.test_utils import TestClient, TestServer +from framework.host.execution_manager import ExecutionAlreadyRunningError from framework.host.triggers import TriggerDefinition from framework.llm.model_catalog import get_models_catalogue from framework.server import ( @@ -89,8 +90,8 @@ class MockStream: _active_executors: dict = field(default_factory=dict) active_execution_ids: set = field(default_factory=set) - async def cancel_execution(self, execution_id: str, reason: str | None = None) -> bool: - return execution_id in self._execution_tasks + async def cancel_execution(self, execution_id: str, reason: str | None = None) -> str: + return "cancelled" if execution_id in self._execution_tasks else "not_found" @dataclass @@ -780,6 +781,21 @@ class TestExecution: data = await resp.json() assert data["execution_id"] == "exec_test_123" + @pytest.mark.asyncio + async def test_trigger_returns_409_when_execution_still_running(self): + session = _make_session() + session.colony_runtime.trigger = AsyncMock(side_effect=ExecutionAlreadyRunningError("default", ["session-1"])) + app = _make_app_with_session(session) + async with TestClient(TestServer(app)) as client: + resp = await client.post( + "/api/sessions/test_agent/trigger", + json={"entry_point_id": "default", "input_data": {"msg": "hi"}}, + ) + assert resp.status == 409 + data = await resp.json() + assert data["stream_id"] == "default" + assert data["active_execution_ids"] == ["session-1"] + @pytest.mark.asyncio async def test_trigger_not_found(self): app = create_app() @@ -918,6 +934,7 @@ class TestExecution: data = await resp.json() assert data["stopped"] is False assert data["cancelled"] == [] + assert data["cancelling"] == [] assert data["timers_paused"] is True @pytest.mark.asyncio @@ -1027,6 +1044,22 @@ class TestStop: assert resp.status == 200 data = await resp.json() assert data["stopped"] is True + assert data["cancelling"] is False + + @pytest.mark.asyncio + async def test_stop_returns_accepted_while_execution_is_still_cancelling(self): + session = _make_session() + session.colony_runtime._mock_streams["default"].cancel_execution = AsyncMock(return_value="cancelling") + app = _make_app_with_session(session) + async with TestClient(TestServer(app)) as client: + resp = await client.post( + "/api/sessions/test_agent/stop", + json={"execution_id": "exec_abc"}, + ) + assert resp.status == 202 + data = await resp.json() + assert data["stopped"] is False + assert data["cancelling"] is True @pytest.mark.asyncio async def test_stop_not_found(self): diff --git a/core/framework/skills/_default_skills/browser-automation/SKILL.md b/core/framework/skills/_default_skills/browser-automation/SKILL.md index 041f64c4..fb52ac18 100644 --- a/core/framework/skills/_default_skills/browser-automation/SKILL.md +++ b/core/framework/skills/_default_skills/browser-automation/SKILL.md @@ -31,7 +31,7 @@ browser_shadow_query(...) → rect → same ## Screenshot + coordinates is shadow-agnostic — prefer it on shadow-heavy sites -On sites that use Shadow DOM heavily (Reddit's faceplate Web Components, LinkedIn's `#interop-outlet` messaging overlay, some X custom elements), **coordinate-based operations reach elements that selector-based tools can't see.** +Start with `browser_snapshot` when you need to inspect the page structure or find ordinary controls. If the snapshot does not show the thing you need, shows stale or misleading refs, or cannot prove where a visible target is, take `browser_screenshot` and use the screenshot + coordinate path. This is especially useful on sites that use Shadow DOM heavily Why: @@ -113,8 +113,8 @@ Even after `wait_until="load"`, React/Vue SPAs often render their real chrome in ### Reading pages efficiently - **Prefer `browser_snapshot` over `browser_get_text("body")`** — returns a compact ~1–5 KB accessibility tree vs 100+ KB of raw HTML. -- Interaction tools (`browser_click`, `browser_type`, `browser_type_focused`, `browser_fill`, `browser_scroll`, etc.) return a page snapshot automatically in their result. Use it to decide your next action — do NOT call `browser_snapshot` separately after every action. Only call `browser_snapshot` when you need a fresh view without performing an action, or after setting `auto_snapshot=false`. -- Complex pages (LinkedIn, Twitter/X, SPAs with virtual scrolling) have DOMs that don't match what's visually rendered — snapshot refs may be stale, missing, or misaligned with visible layout. On these pages, `browser_screenshot` is the only reliable way to orient yourself. +- Interaction tools `browser_click`, `browser_type`, `browser_type_focused`, `browser_fill`, and `browser_scroll` wait 0.5 s for the page to settle after a successful action, then attach a fresh accessibility snapshot under the `snapshot` key of their result. Use it to decide your next action — do NOT call `browser_snapshot` separately after every action. Tune the capture via `auto_snapshot_mode`: `"default"` (full tree, the default), `"simple"` (trims unnamed structural nodes), `"interactive"` (only controls — tightest token footprint), or `"off"` to skip the capture entirely (useful when batching several interactions and you don't need the intermediate trees). Call `browser_snapshot` explicitly only when you need a newer view or a different mode than what was auto-captured. +- Complex pages (LinkedIn, Twitter/X, SPAs with virtual scrolling) can have DOMs that don't match what's visually rendered — snapshot refs may be stale, missing, or misaligned with visible layout. Try the available snapshot first; when the target is not present in that snapshot or visual position matters, switch to `browser_screenshot` to orient yourself. - Only fall back to `browser_get_text` for extracting specific small elements by CSS selector. ## Typing and keyboard input diff --git a/core/framework/skills/_default_skills/linkedin-automation/SKILL.md b/core/framework/skills/_default_skills/linkedin-automation/SKILL.md index e1659afc..a2e49655 100644 --- a/core/framework/skills/_default_skills/linkedin-automation/SKILL.md +++ b/core/framework/skills/_default_skills/linkedin-automation/SKILL.md @@ -381,24 +381,15 @@ is_logged_in = browser_evaluate(""" ## Deduplication pattern -For any daily loop (connection acceptance, profile visits, DMs), maintain a ledger file: +Dedup is handled by the colony progress queue, not a separate JSON file. For any daily loop (connection acceptance, profile visits, DMs), the queen enqueues one row in the `tasks` table per `(profile_url, action)` pair; workers claim, act, and mark done. Already-`done` rows are skipped on the next claim — that's your crash-resume and cross-day dedup. See `hive.colony-progress-tracker` for the full claim/update protocol. -``` -# data/linkedin_contacts.json -{ - "contacts": [ - { - "profile_url": "https://www.linkedin.com/in/username/", - "name": "First Last", - "action": "connection_accepted+message_sent", - "timestamp": "2026-04-13T09:30:00Z", - "message_preview": "first 50 chars of message sent" - } - ] -} +If you need to check whether a given `(profile_url, action)` has already been handled in a prior run before enqueuing a new row, query the queue directly: + +```bash +sqlite3 "" "SELECT status FROM tasks WHERE payload LIKE '%\"profile_url\":\"\"%' AND payload LIKE '%\"action\":\"\"%';" ``` -Before any action, check if the profile URL already has a recent entry for the same action. Skip if yes. Atomic-write the ledger after each success so crash-resume works. +Empty → not yet enqueued, safe to add. Otherwise honor the existing row's status. ## See also diff --git a/core/framework/skills/_default_skills/writing-hive-skills/SKILL.md b/core/framework/skills/_default_skills/writing-hive-skills/SKILL.md index 6a4dbdbf..aa418857 100644 --- a/core/framework/skills/_default_skills/writing-hive-skills/SKILL.md +++ b/core/framework/skills/_default_skills/writing-hive-skills/SKILL.md @@ -21,10 +21,32 @@ Each skill is a directory containing a `SKILL.md`. At startup, only the frontmat ### Choosing where to put a new skill -- **Project-scoped**: put under `/.hive/skills/` when the skill is tied to that codebase's APIs, conventions, or infra. -- **User-scoped**: put under `~/.hive/skills/` when the skill is reusable across projects for this machine/user. +- **Colony-scoped (via `create_colony`)**: when the skill is the operational protocol a single colony needs — its API auth, DOM selectors, DB schema, task-queue conventions — do NOT place it under `~/.hive/skills/` or `/.hive/skills/` yourself. Those roots are SHARED and every colony on the machine will see it. Instead, pass the skill content INLINE to the `create_colony` tool (`skill_name`, `skill_description`, `skill_body`, optional `skill_files`). The tool materializes the folder under `~/.hive/colonies//.hive/skills//` where it is discovered as **project scope** by only that colony's workers. See the subsection below. +- **Project-scoped**: put under `/.hive/skills/` when the skill is tied to that codebase's APIs, conventions, or infra and multiple agents in the project should share it. +- **User-scoped**: put under `~/.hive/skills/` when the skill is reusable across projects for this machine/user and all agents should see it. - **Framework default**: add under `core/framework/skills/_default_skills/` AND register in `framework/skills/defaults.py::SKILL_REGISTRY` only when the skill is a universal operational protocol shipped with Hive. Default skills use the `hive.` naming convention and include `type: default-skill` in metadata. +### Colony-scoped skills via `create_colony` + +A colony-scoped skill is one that belongs to exactly ONE colony — e.g. it encodes the HoneyComb staging API the `honeycomb_research` colony polls, or the LinkedIn outbound flow the `linkedin_outbound_campaign` colony runs. Writing such a skill at `~/.hive/skills/` or `/.hive/skills/` leaks it to every other colony, which will then see it at selection time. + +**Do not reach for `write_file` to create the folder.** The `create_colony` tool takes the skill content INLINE and places it for you: + +``` +create_colony( + colony_name="honeycomb_research", + task="Build a daily honeycomb market report…", + skill_name="honeycomb-api-protocol", + skill_description="How to query the HoneyComb staging API…", + skill_body="## Operational Protocol\n\nAuth: …", + skill_files=[{"path": "scripts/fetch_tickers.py", "content": "…"}], # optional +) +``` + +The tool writes `~/.hive/colonies/honeycomb_research/.hive/skills/honeycomb-api-protocol/SKILL.md` (plus any `skill_files`), which `SkillDiscovery` picks up as project scope when that colony's workers start — and ONLY that colony's workers. No cross-colony leakage. + +Do not write colony-bound skill folders by hand under `~/.hive/skills/`. A skill placed there is user-scoped and becomes visible to every colony on the machine — defeating the isolation you wanted. + ### Directory layout ``` @@ -124,8 +146,8 @@ For Python scripts in a Hive project, prefer `uv run scripts/foo.py ...`. ### Creating a new skill — workflow 1. Pick a `` (lowercase-hyphenated). -2. Decide scope: project (`/.hive/skills/`), user (`~/.hive/skills/`), or framework default (`core/framework/skills/_default_skills/` + registry entry). -3. Create the directory and write `SKILL.md` with frontmatter + body. +2. Decide scope: **colony** (pass content INLINE to `create_colony` — STOP here, do not hand-author the folder), project (`/.hive/skills/`), user (`~/.hive/skills/`), or framework default (`core/framework/skills/_default_skills/` + registry entry). +3. For the non-colony scopes: create the directory and write `SKILL.md` with frontmatter + body. 4. Add `scripts/`, `references/`, `assets/` only if needed. 5. Validate the frontmatter: name matches dir, description is specific, no forbidden characters. 6. Validate using the Hive CLI: diff --git a/core/framework/skills/_default_skills/x-automation/SKILL.md b/core/framework/skills/_default_skills/x-automation/SKILL.md index dbe15894..0632fbb8 100644 --- a/core/framework/skills/_default_skills/x-automation/SKILL.md +++ b/core/framework/skills/_default_skills/x-automation/SKILL.md @@ -203,7 +203,7 @@ for c in candidates: else: browser_click("[data-testid='tweetButton']") sleep(2) - record_sent(c['preview'], reply_text) # append to ledger + # Mark the task done in progress.db — see hive.colony-progress-tracker # Close the composer (press Escape or click the Close button) browser_press("Escape") @@ -307,24 +307,9 @@ If any of these appear, **stop the run, screenshot the state, and surface the is ## Deduplication pattern -Every daily loop should maintain a ledger file. Append after each successful reply/post, atomic-write to survive crashes. +Dedup is handled by the colony progress queue, not a separate JSON file. The queen enqueues one row in the `tasks` table per reply target (keyed by tweet URL); workers claim, reply, and mark done. Already-`done` rows are skipped on the next claim — that's your crash-resume and cross-day dedup, for free. See `hive.colony-progress-tracker` for the full claim/update protocol. -``` -# data/x_replies_ledger.json -{ - "replies": [ - { - "tweet_url": "https://x.com//status/", - "author": "username", - "original_preview": "first 100 chars of the tweet", - "reply_text": "what you sent", - "timestamp": "2026-04-13T09:30:00Z" - } - ] -} -``` - -Extract the tweet URL via `browser_evaluate`: +Extract the tweet URL via `browser_evaluate` so the queen can use it as the task key: ``` url = browser_evaluate(""" @@ -337,7 +322,13 @@ url = browser_evaluate(""" """, article_index) ``` -Before each reply, check if the URL already has a ledger entry. If yes, skip. This survives across runs and across days. +If you need to check whether a given tweet URL has already been replied to in a prior run (e.g., scanning live search results before enqueuing), query the queue directly: + +```bash +sqlite3 "" "SELECT status FROM tasks WHERE payload LIKE '%\"tweet_url\":\"\"%';" +``` + +Empty → not yet enqueued, safe to add. Otherwise honor the existing row's status. ## Reply style guidelines diff --git a/core/framework/tools/queen_lifecycle_tools.py b/core/framework/tools/queen_lifecycle_tools.py index d02f764b..ebf399e4 100644 --- a/core/framework/tools/queen_lifecycle_tools.py +++ b/core/framework/tools/queen_lifecycle_tools.py @@ -36,6 +36,7 @@ from __future__ import annotations import asyncio import json import logging +import os import time from dataclasses import dataclass, field from datetime import UTC, datetime @@ -45,13 +46,10 @@ from typing import TYPE_CHECKING, Any from framework.credentials.models import CredentialError from framework.host.event_bus import AgentEvent, EventType from framework.loader.preload_validation import credential_errors_to_json -from framework.server.app import validate_agent_path from framework.tools.flowchart_utils import ( FLOWCHART_TYPES, classify_flowchart_node, - load_flowchart_file, save_flowchart_file, - synthesize_draft_from_runtime, ) if TYPE_CHECKING: @@ -498,6 +496,7 @@ async def _start_trigger_webhook(session: Any, trigger_id: str, tdef: Any) -> No await server.start() server.is_running = True + def _update_meta_json(session_manager, manager_session_id, updates: dict) -> None: """Merge updates into the queen session's meta.json.""" if session_manager is None or not manager_session_id: @@ -617,8 +616,7 @@ def register_queen_lifecycle_tools( ) except Exception as exc: logger.warning( - "%s: compute_unavailable_tools raised, proceeding without " - "credential-based tool filtering: %s", + "%s: compute_unavailable_tools raised, proceeding without credential-based tool filtering: %s", tool_label, exc, ) @@ -658,7 +656,6 @@ def register_queen_lifecycle_tools( the queen called this tool. """ stopped_unified = 0 - stopped_legacy = 0 errors: list[str] = [] # 1. Stop everything on the unified ColonyRuntime. This is @@ -682,9 +679,7 @@ def register_queen_lifecycle_tools( if legacy is not None: try: legacy_workers = legacy.list_workers() - stopped_legacy = len(legacy_workers) if isinstance(legacy_workers, list) else 0 - await legacy.stop_all_workers() - legacy.pause_timers() + _ = len(legacy_workers) if isinstance(legacy_workers, list) else 0 except Exception as e: errors.append(f"legacy: {e}") logger.warning( @@ -695,27 +690,74 @@ def register_queen_lifecycle_tools( if colony is None and legacy is None: return json.dumps({"error": "No runtime on this session."}) - total_stopped = stopped_unified + stopped_legacy + cancelled: list[str] = [] + cancelling: list[str] = [] + + # 3. Stop legacy runtime executions with per-stream cancellation so a + # still-alive task keeps the worker in "cancelling" instead of being + # reported as fully stopped too early. + if legacy is not None: + try: + for graph_id in legacy.list_graphs(): + reg = legacy.get_graph_registration(graph_id) + if reg is None: + continue + + for _ep_id, stream in reg.streams.items(): + for executor in stream._active_executors.values(): + for node in executor.node_registry.values(): + if hasattr(node, "signal_shutdown"): + node.signal_shutdown() + if hasattr(node, "cancel_current_turn"): + node.cancel_current_turn() + + for exec_id in list(stream.active_execution_ids): + try: + outcome = await stream.cancel_execution(exec_id, reason=reason) + if outcome == "cancelled": + cancelled.append(exec_id) + elif outcome == "cancelling": + cancelling.append(exec_id) + except Exception as e: + errors.append(f"legacy-cancel:{exec_id}: {e}") + logger.warning("Failed to cancel %s: %s", exec_id, e) + + legacy.pause_timers() + except Exception as e: + errors.append(f"legacy-runtime: {e}") + logger.warning( + "stop_worker: failed to inspect legacy runtime executions", + exc_info=True, + ) + + total_stopped = stopped_unified + len(cancelled) logger.info( - "stop_worker: stopped %d workers (unified=%d, legacy=%d). reason=%s", - total_stopped, + "stop_worker: status=%s (unified=%d, cancelled=%d, cancelling=%d). reason=%s", + "cancelling" if cancelling else "stopped" if total_stopped else "no_active_executions", stopped_unified, - stopped_legacy, + len(cancelled), + len(cancelling), reason, ) return json.dumps( { - "status": "stopped", + "status": ("cancelling" if cancelling else "stopped" if total_stopped else "no_active_executions"), "workers_stopped": total_stopped, "unified_stopped": stopped_unified, - "legacy_stopped": stopped_legacy, + "legacy_stopped": len(cancelled), + "cancelled": cancelled, + "cancelling": cancelling, "timers_paused": legacy is not None, "reason": reason, "errors": errors if errors else None, } ) + def _stop_result_allows_phase_transition(stop_result: str) -> tuple[dict, bool]: + result = json.loads(stop_result) + return result, result.get("status") != "cancelling" + _stop_tool = Tool( name="stop_worker", description=( @@ -746,8 +788,8 @@ def register_queen_lifecycle_tools( # (and its SUBAGENT_REPORT still fires — explicit reports set right # before the stop are preserved). - _RUN_PARALLEL_DEFAULT_TIMEOUT = 600.0 # soft timeout (10 min) - _RUN_PARALLEL_HARD_TIMEOUT_CAP = 3600.0 # absolute safety-net cap (1 hour) + _RUN_PARALLEL_DEFAULT_TIMEOUT = 600.0 # soft timeout (10 min) + _RUN_PARALLEL_HARD_TIMEOUT_CAP = 3600.0 # absolute safety-net cap (1 hour) def _compute_hard_timeout(soft: float) -> float: """Default hard cutoff: max(4× soft, soft + 600), capped at 3600s.""" @@ -798,7 +840,20 @@ def register_queen_lifecycle_tools( # Hard ceiling on a single fan-out call. A runaway queen requesting # thousands of parallel workers would starve memory and drown the # event loop; reject early with a clear error instead. - _RUN_PARALLEL_HARD_CAP = 64 + # Laptop-safe default (8); override via HIVE_RUN_PARALLEL_HARD_CAP. + _RUN_PARALLEL_HARD_CAP = 8 + _cap_env = os.environ.get("HIVE_RUN_PARALLEL_HARD_CAP") + if _cap_env: + try: + _parsed = int(_cap_env) + if _parsed > 0: + _RUN_PARALLEL_HARD_CAP = _parsed + except ValueError: + logger.warning( + "Invalid HIVE_RUN_PARALLEL_HARD_CAP=%r; using default %d", + _cap_env, + _RUN_PARALLEL_HARD_CAP, + ) if len(tasks) > _RUN_PARALLEL_HARD_CAP: return json.dumps( { @@ -971,8 +1026,7 @@ def register_queen_lifecycle_tools( if _colony_db_path: _pinned = sum(1 for tid in _enqueued_task_ids if tid) logger.info( - "run_parallel_workers: attached progress_db context to " - "%d spawn(s) (colony_id=%s, %d pinned task_ids)", + "run_parallel_workers: attached progress_db context to %d spawn(s) (colony_id=%s, %d pinned task_ids)", len(normalised), _colony_id, _pinned, @@ -992,9 +1046,7 @@ def register_queen_lifecycle_tools( if phase_state is not None: try: await phase_state.switch_to_working() - _update_meta_json( - session_manager, manager_session_id, {"phase": "working"} - ) + _update_meta_json(session_manager, manager_session_id, {"phase": "working"}) except Exception as exc: logger.warning( "run_parallel_workers: phase transition to 'working' failed (non-fatal): %s", @@ -1005,9 +1057,7 @@ def register_queen_lifecycle_tools( # it injects a "wrap up" message to every still-active worker # without an explicit report; at hard, it force-stops the stragglers. soft_timeout = timeout if timeout is not None else _RUN_PARALLEL_DEFAULT_TIMEOUT - hard_timeout_effective = ( - hard_timeout if hard_timeout is not None else _compute_hard_timeout(soft_timeout) - ) + hard_timeout_effective = hard_timeout if hard_timeout is not None else _compute_hard_timeout(soft_timeout) if hard_timeout_effective <= soft_timeout: hard_timeout_effective = soft_timeout + 60.0 # enforce at least a 60s grace try: @@ -1126,29 +1176,34 @@ def register_queen_lifecycle_tools( # --- create_colony --------------------------------------------------------- # - # Forks the current queen session into a colony. Requires the queen - # to have ALREADY AUTHORED a skill folder capturing what she learned - # during this session (using her write_file / edit_file tools), and - # pass the folder path to this tool. The tool validates the skill - # folder (SKILL.md exists, frontmatter has the required ``name`` + - # ``description`` fields, directory name matches frontmatter name), - # then forks. If the skill lives outside ``~/.hive/skills/`` the - # tool copies it in so the new colony's worker will discover it on - # its first skill scan. + # Forks the current queen session into a colony. The queen passes + # the skill content INLINE as tool arguments (skill_name, + # skill_description, skill_body, and optional skill_files for + # supporting scripts/references). The tool materializes the skill + # folder under ``~/.hive/colonies/{colony_name}/.hive/skills/{name}/`` + # itself — colony-scoped, discovered as project scope by the + # colony's worker and invisible to every other colony on the + # machine — then forks. # - # This is the codified version of the user's instruction: + # Why inline instead of a pre-authored folder path: earlier versions + # required the queen to write SKILL.md with her own write_file tool + # before calling create_colony. That leaked the harness's + # read-before-write invariant onto a queen-owned artifact — if a + # skill of the same name already existed the queen hit a generic + # "refusing to overwrite" error and didn't know how to recover. By + # inlining the content we make colony creation a single atomic + # operation with domain-level semantics: the queen owns her skill + # namespace inside the colony, so calling create_colony with an + # existing name simply replaces the old skill (her latest content + # wins). # - # "When the queen agent needs to create a colony, it needs to - # write down whatever it just learned from the current session - # as an agent skill and put it in the ~/.hive/skills folder." - # - # Two-step flow for the queen LLM: - # - # 1. Author the skill with write_file (or a sequence of writes - # for scripts/references/assets subdirs) — she already knows - # the format via the writing-hive-skills default skill. - # 2. Call create_colony(colony_name, task, skill_path) pointing - # at the folder she just wrote. + # Why colony-scoped instead of user-scoped: an earlier version + # materialized the folder at ``~/.hive/skills/{name}/``. That made + # every colony on the machine see every colony-specific skill via + # user-scope discovery — a worker in colony A could be offered + # colony B's hyper-specific skill during selection. Writing into + # the colony's own project dir kills that leak while still keeping + # re-runs idempotent. import re as _re import shutil as _shutil @@ -1156,152 +1211,144 @@ def register_queen_lifecycle_tools( _COLONY_NAME_RE = _re.compile(r"^[a-z0-9_]+$") _SKILL_NAME_RE = _re.compile(r"^[a-z0-9-]+$") - def _validate_and_install_skill(skill_path: str) -> tuple[Path | None, str | None]: - """Validate an authored skill folder and ensure it lives under ~/.hive/skills/. + def _materialize_skill_folder( + *, + skill_name: str, + skill_description: str, + skill_body: str, + skill_files: list[dict] | None, + colony_dir: Path, + ) -> tuple[Path | None, str | None, bool]: + """Write a skill folder under ``{colony_dir}/.hive/skills/{name}/`` from inline content. - Returns ``(installed_path, error)``. On success ``error`` is - ``None`` and ``installed_path`` is the final location under - ``~/.hive/skills/{name}/``. On failure ``installed_path`` is - ``None`` and ``error`` is a human-readable reason suitable for - returning to the queen as a JSON error payload. + The skill is scoped to a single colony: ``SkillDiscovery`` scans + ``{project_root}/.hive/skills/`` as project-scope, and the + colony's worker uses ``project_root = colony_dir`` — so only + that colony's workers see it, not every colony on the machine. + We deliberately avoid ``~/.hive/skills/`` here because that + directory is scanned as user scope and leaks into every agent. + + Returns ``(installed_path, error, replaced)``. On success + ``error`` is ``None`` and ``installed_path`` is the final + location; ``replaced`` is ``True`` when an existing skill with + the same name was overwritten. On failure ``installed_path`` is + ``None``, ``error`` is a human-readable reason, and + ``replaced`` is ``False``. """ - if not skill_path or not isinstance(skill_path, str): - return None, "skill_path must be a non-empty string" + name = (skill_name or "").strip() if isinstance(skill_name, str) else "" + if not name: + return None, "skill_name is required", False + if not _SKILL_NAME_RE.match(name): + return None, (f"skill_name '{name}' must match [a-z0-9-] pattern"), False + if name.startswith("-") or name.endswith("-") or "--" in name: + return None, (f"skill_name '{name}' has leading/trailing/consecutive hyphens"), False + if len(name) > 64: + return None, f"skill_name '{name}' exceeds 64 chars", False - src = Path(skill_path).expanduser().resolve() - if not src.exists(): - return None, f"skill_path does not exist: {src}" - if not src.is_dir(): - return None, f"skill_path must be a directory, got file: {src}" + desc = (skill_description or "").strip() if isinstance(skill_description, str) else "" + if not desc: + return None, "skill_description is required", False + if len(desc) > 1024: + return None, "skill_description must be 1–1024 chars", False + # Frontmatter descriptions must stay on a single line because + # our frontmatter parser is line-oriented and the downstream + # skill loader expects ``description:`` to resolve to one value. + if "\n" in desc or "\r" in desc: + return None, "skill_description must be a single line (no newlines)", False - skill_md = src / "SKILL.md" - if not skill_md.is_file(): - return None, f"skill_path has no SKILL.md at {skill_md}" - - # Parse the frontmatter to pull out the name and verify - # description exists. We don't need a full YAML parser — the - # writing-hive-skills protocol is rigid enough that a line-by-line - # scan of the first frontmatter block suffices for validation. - try: - content = skill_md.read_text(encoding="utf-8") - except OSError as e: - return None, f"failed to read SKILL.md: {e}" - - if not content.startswith("---"): - return None, "SKILL.md missing opening '---' frontmatter marker" - after_open = content.split("---", 2) - if len(after_open) < 3: - return None, "SKILL.md missing closing '---' frontmatter marker" - frontmatter_text = after_open[1] - - fm_name: str | None = None - fm_description: str | None = None - for raw_line in frontmatter_text.splitlines(): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - if line.startswith("name:"): - fm_name = line.split(":", 1)[1].strip().strip('"').strip("'") - elif line.startswith("description:"): - fm_description = line.split(":", 1)[1].strip().strip('"').strip("'") - - if not fm_name: - return None, "SKILL.md frontmatter missing 'name' field" - if not fm_description: - return None, "SKILL.md frontmatter missing 'description' field" - if not (1 <= len(fm_description) <= 1024): - return None, "SKILL.md 'description' must be 1–1024 chars" - if not _SKILL_NAME_RE.match(fm_name): - return None, (f"SKILL.md 'name' field '{fm_name}' must match [a-z0-9-] pattern") - if fm_name.startswith("-") or fm_name.endswith("-") or "--" in fm_name: - return None, (f"SKILL.md 'name' '{fm_name}' has leading/trailing/consecutive hyphens") - if len(fm_name) > 64: - return None, f"SKILL.md 'name' '{fm_name}' exceeds 64 chars" - - # The directory basename should match the frontmatter name — - # this is the writing-hive-skills convention. We ENFORCE it - # because the skill loader uses dir names as identity. - if src.name != fm_name: - return None, ( - f"skill directory name '{src.name}' does not match " - f"SKILL.md frontmatter name '{fm_name}'. Rename the " - "folder or fix the frontmatter." + body = skill_body if isinstance(skill_body, str) else "" + if not body.strip(): + return ( + None, + ( + "skill_body is required — the operational procedure the " + "colony worker needs to run this job unattended" + ), + False, ) - # Install into ~/.hive/skills/{name}/ if not already there. - target_root = Path.home() / ".hive" / "skills" - target = target_root / fm_name + # Optional supporting files (scripts/, references/, assets/…). + # Each entry: {"path": "", "content": ""}. + normalized_files: list[tuple[Path, str]] = [] + if skill_files: + if not isinstance(skill_files, list): + return None, "skill_files must be an array", False + for entry in skill_files: + if not isinstance(entry, dict): + return None, "each skill_files entry must be an object with 'path' and 'content'", False + rel_raw = entry.get("path") + content = entry.get("content") + if not isinstance(rel_raw, str) or not rel_raw.strip(): + return None, "skill_files entry missing non-empty 'path'", False + if not isinstance(content, str): + return None, f"skill_files entry '{rel_raw}' missing string 'content'", False + rel_stripped = rel_raw.strip() + # Normalize a leading ``./`` but do NOT strip bare ``/`` — + # an absolute path should be rejected, not silently relativized. + if rel_stripped.startswith("./"): + rel_stripped = rel_stripped[2:] + rel_path = Path(rel_stripped) + if rel_stripped.startswith("/") or rel_path.is_absolute() or ".." in rel_path.parts: + return None, (f"skill_files path '{rel_raw}' must be relative and inside the skill folder"), False + if rel_path.as_posix() == "SKILL.md": + return None, ("skill_files must not contain SKILL.md — pass skill_body instead"), False + normalized_files.append((rel_path, content)) + + target_root = colony_dir / ".hive" / "skills" + target = target_root / name try: target_root.mkdir(parents=True, exist_ok=True) except OSError as e: - return None, f"failed to create skills root: {e}" - - try: - if src.resolve() == target.resolve(): - # Already in the right place — nothing to do. - return target, None - except OSError: - pass + return None, f"failed to create skills root: {e}", False + replaced = False try: if target.exists(): - # Overwrite existing — the queen is explicitly creating - # a new colony for this version, so her authored skill - # wins over any prior version. copytree with - # dirs_exist_ok handles subdirs (scripts/, references/, - # assets/) but does NOT delete files removed in the - # new version. For a clean overwrite we rmtree first. + # Queen is re-creating a skill under the same name — + # her latest content wins. rmtree first so stale files + # from a prior version don't linger alongside the new + # ones (copytree with dirs_exist_ok would merge them). + replaced = True _shutil.rmtree(target) - _shutil.copytree(src, target) - except OSError as e: - return None, f"failed to install skill into {target}: {e}" + target.mkdir(parents=True, exist_ok=False) - # Cleanup the source directory after a successful install so - # the authored skill doesn't linger as debris in the agent - # workspace (or — pre-sandbox-split — in the hive git - # checkout). Only removes paths that are OUTSIDE - # ``~/.hive/skills/`` so we never nuke the canonical install - # target or user-owned skill dirs. - try: - src_resolved = src.resolve() - skills_root_resolved = target_root.resolve() - try: - src_resolved.relative_to(skills_root_resolved) - _under_skills_root = True - except ValueError: - _under_skills_root = False - if not _under_skills_root: - _shutil.rmtree(src_resolved) - logger.info( - "create_colony: cleaned up authored skill source at %s " - "(installed to %s)", - src_resolved, - target, - ) - except OSError as e: - logger.warning( - "create_colony: failed to clean up skill source at %s (non-fatal): %s", - src, - e, - ) + body_norm = body.rstrip() + "\n" + skill_md_text = f"---\nname: {name}\ndescription: {desc}\n---\n\n{body_norm}" + (target / "SKILL.md").write_text(skill_md_text, encoding="utf-8") - return target, None + for rel_path, file_content in normalized_files: + full_path = target / rel_path + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(file_content, encoding="utf-8") + except OSError as e: + return None, f"failed to write skill folder {target}: {e}", False + + return target, None, replaced async def create_colony( *, colony_name: str, task: str, - skill_path: str, + skill_name: str, + skill_description: str, + skill_body: str, + skill_files: list[dict] | None = None, tasks: list[dict] | None = None, ) -> str: - """Create a colony after installing a pre-authored skill folder. + """Create a colony and materialize its skill folder in one atomic call. - File-system only: copies the queen session into a new colony - directory and writes ``worker.json`` with the task baked in. - NOTHING RUNS after fork. The user navigates to the colony when - they're ready to start the worker — at that point the worker - reads the task from ``worker.json`` and the skill from - ``~/.hive/skills/`` and starts informed. + The queen passes skill content inline: ``skill_name``, + ``skill_description``, ``skill_body``, and optional + ``skill_files`` (supporting scripts/references). The tool + writes ``~/.hive/colonies/{colony_name}/.hive/skills/{skill_name}/`` + (colony-scoped, only this colony's workers see it), then forks + the queen session into that colony directory and stores the + task in ``worker.json``. NOTHING RUNS after fork. + + If a skill of the same name already exists inside this colony, + it is overwritten — the queen owns her skill namespace inside + the colony, and calling create_colony with an existing name + means "my latest content wins." When *tasks* is provided, each entry is seeded into the colony's ``progress.db`` task queue in a single transaction. @@ -1319,27 +1366,43 @@ def register_queen_lifecycle_tools( {"error": ("colony_name must be lowercase alphanumeric with underscores (e.g. 'honeycomb_research').")} ) - installed_skill, skill_err = _validate_and_install_skill(skill_path) + # Pre-create the colony dir so the skill can be materialized + # INSIDE it (project scope, colony-local). fork_session_into_colony + # keys "is_new" off worker.json rather than the dir itself, so + # pre-creating here does not wrongly flag fresh colonies as "old". + colony_dir = Path.home() / ".hive" / "colonies" / cn + try: + colony_dir.mkdir(parents=True, exist_ok=True) + except OSError as e: + return json.dumps({"error": f"failed to create colony dir {colony_dir}: {e}"}) + + installed_skill, skill_err, skill_replaced = _materialize_skill_folder( + skill_name=skill_name, + skill_description=skill_description, + skill_body=skill_body, + skill_files=skill_files, + colony_dir=colony_dir, + ) if skill_err is not None: return json.dumps( { "error": skill_err, "hint": ( - "Author the skill folder first using write_file " - "(and edit_file for follow-ups). The folder must " - "contain a SKILL.md with YAML frontmatter " - "{name, description} — see your " - "writing-hive-skills default skill for the " - "format. Then call create_colony again with " - "skill_path pointing at that folder." + "Provide skill_name (lowercase [a-z0-9-], ≤64 chars), " + "skill_description (single line, 1–1024 chars), and " + "skill_body (the operational procedure the colony " + "worker needs to run unattended: API endpoints, " + "auth, gotchas, example requests, pre-baked " + "queries). Use skill_files for optional " + "scripts/references." ), } ) logger.info( - "create_colony: installed skill from %s → %s", - skill_path, + "create_colony: materialized skill at %s (replaced=%s)", installed_skill, + skill_replaced, ) # Fork the queen session into the colony directory. The fork @@ -1397,6 +1460,7 @@ def register_queen_lifecycle_tools( "is_new": fork_result.get("is_new", True), "skill_installed": str(installed_skill), "skill_name": installed_skill.name if installed_skill else None, + "skill_replaced": skill_replaced, "task": (task or "").strip(), }, ) @@ -1416,6 +1480,7 @@ def register_queen_lifecycle_tools( "is_new": fork_result.get("is_new", True), "skill_installed": str(installed_skill), "skill_name": installed_skill.name if installed_skill else None, + "skill_replaced": skill_replaced, "db_path": fork_result.get("db_path"), "tasks_seeded": len(fork_result.get("task_ids") or []), } @@ -1435,33 +1500,25 @@ def register_queen_lifecycle_tools( "Do NOT use this just because you learned something " "reusable; if the user wants results right now in this " "chat, use run_parallel_workers instead.\n\n" - "Before forking, you author a Hive Skill folder capturing " - "the operational procedure the colony worker needs to run " - "unattended, and pass its path to this tool. The tool " - "validates the skill folder (SKILL.md present, frontmatter " - "name+description valid, directory name matches frontmatter " - "name), installs it under ~/.hive/skills/{name}/ if it's " - "not already there, and then forks the session.\n\n" + "ATOMIC CALL: you pass the skill content INLINE as " + "arguments (skill_name, skill_description, skill_body, " + "optional skill_files). The tool writes the folder at " + "~/.hive/colonies/{colony_name}/.hive/skills/{skill_name}/ " + "— scoped to THIS colony only (project scope); no other " + "colony on the machine can see it. Do NOT write the folder " + "yourself with write_file; folders hand-authored at " + "~/.hive/skills/ are user-scoped and LEAK to every colony. " + "If a skill of the same name already exists under this " + "colony, it is replaced by your latest content (you own " + "your skill namespace inside the colony).\n\n" "NOTHING RUNS AFTER FORK. This tool is file-system only: " - "it copies the queen session into a new colony directory " - "and writes worker.json with the task baked in. No worker " - "is started. The user navigates to the new colony when " - "they're ready to begin actual work (or wires up a " - "trigger) — at that point the worker reads the task from " - "worker.json and the skill you wrote here, and starts " - "informed instead of clueless.\n\n" - "TWO-STEP FLOW:\n\n" - " 1. Use write_file (plus edit_file / list_directory as " - " needed) to create a skill folder. The folder must " - " contain a SKILL.md with YAML frontmatter {name, " - " description} and a markdown body. Optional subdirs: " - " scripts/, references/, assets/. See your " - " writing-hive-skills default skill for the spec. We " - " recommend authoring it directly at " - " ~/.hive/skills/{skill-name}/SKILL.md so no copy is " - " needed.\n" - " 2. Call create_colony(colony_name, task, skill_path) " - " pointing at the folder you just wrote.\n\n" + "it writes the skill folder, copies the queen session " + "into a new colony directory, and stores the task in " + "worker.json. No worker is started. The user navigates to " + "the new colony when they're ready (or wires up a " + "trigger); at that point the worker reads the task from " + "worker.json and the skill from ~/.hive/skills/, and " + "starts informed instead of clueless.\n\n" "WHY THE SKILL IS REQUIRED: a fresh worker running " "unattended has zero memory of your chat with the user. " "Whatever you figured out during this session — API auth " @@ -1476,7 +1533,8 @@ def register_queen_lifecycle_tools( "conventions you settled on, and pre-baked " "queries/commands. Write it as if onboarding a new " "engineer who has never seen this system. Realistic " - "target: 300–2000 chars of body." + "target: 300–2000 chars of body. See your " + "writing-hive-skills default skill for the spec." ), parameters={ "type": "object", @@ -1503,18 +1561,68 @@ def register_queen_lifecycle_tools( "request." ), }, - "skill_path": { + "skill_name": { "type": "string", "description": ( - "Path to a pre-authored skill folder containing " - "SKILL.md. May be absolute or ~-expanded. The " - "directory basename MUST match the SKILL.md " - "frontmatter 'name' field. If the path is " - "outside ~/.hive/skills/ the folder is copied " - "in. Example: '~/.hive/skills/honeycomb-api-" - "protocol'." + "Identifier for the skill folder. Lowercase " + "[a-z0-9-], no leading/trailing/consecutive " + "hyphens, ≤64 chars. Becomes the directory " + "under ~/.hive/colonies//.hive/" + "skills/ and the frontmatter 'name' field. " + "Example: 'honeycomb-api-protocol'. Reusing " + "an existing name within this colony replaces " + "that skill." ), }, + "skill_description": { + "type": "string", + "description": ( + "One-line summary of when the skill applies, " + "1–1024 chars, no newlines. Becomes the " + "frontmatter 'description' field that drives " + "skill discovery. Example: 'How to query the " + "HoneyComb staging API for ticker, pool, and " + "trade data. Covers auth, pagination, pool " + "detail shape. Use when fetching market " + "data.'" + ), + }, + "skill_body": { + "type": "string", + "description": ( + "Markdown body of SKILL.md — the operational " + "procedure the colony worker needs to run " + "unattended. API endpoints with example " + "requests, auth flow, response shapes, " + "gotchas, pre-baked queries/commands. " + "300–2000 chars is the realistic target. Do " + "NOT include the '---' frontmatter markers; " + "the tool wraps your body with frontmatter " + "built from skill_name and skill_description." + ), + }, + "skill_files": { + "type": "array", + "description": ( + "Optional supporting files for the skill " + "folder (e.g. scripts/, references/, " + "assets/). Each entry is {path, content}: " + "'path' is a RELATIVE path inside the skill " + "folder (no leading slash, no '..', not " + "SKILL.md); 'content' is the file text. Use " + "this when the worker needs a runnable " + "script, a long reference document, or a " + "fixture alongside SKILL.md." + ), + "items": { + "type": "object", + "properties": { + "path": {"type": "string"}, + "content": {"type": "string"}, + }, + "required": ["path", "content"], + }, + }, "tasks": { "type": "array", "description": ( @@ -1567,7 +1675,13 @@ def register_queen_lifecycle_tools( }, }, }, - "required": ["colony_name", "task", "skill_path"], + "required": [ + "colony_name", + "task", + "skill_name", + "skill_description", + "skill_body", + ], }, ) registry.register( @@ -1598,9 +1712,7 @@ def register_queen_lifecycle_tools( """ cn = (colony_name or "").strip() if not _COLONY_NAME_RE.match(cn): - return json.dumps( - {"error": "colony_name must be lowercase alphanumeric with underscores"} - ) + return json.dumps({"error": "colony_name must be lowercase alphanumeric with underscores"}) from pathlib import Path as _Path @@ -1701,10 +1813,7 @@ def register_queen_lifecycle_tools( }, }, "payload": { - "description": ( - "Optional task-specific parameters. Stored as " - "JSON in the 'payload' column." - ), + "description": ("Optional task-specific parameters. Stored as JSON in the 'payload' column."), }, "priority": { "type": "integer", @@ -1738,18 +1847,23 @@ def register_queen_lifecycle_tools( inject config adjustments, or escalate to building/planning. """ stop_result = await stop_worker() + result, can_transition = _stop_result_allows_phase_transition(stop_result) - if phase_state is not None: + if phase_state is not None and can_transition: await phase_state.switch_to_reviewing() - _update_meta_json(session_manager, manager_session_id, {"phase": "editing"}) + _update_meta_json(session_manager, manager_session_id, {"phase": "reviewing"}) - result = json.loads(stop_result) - result["phase"] = "editing" - result["message"] = ( - "Worker stopped. You are now in editing phase. " - "You can re-run with run_agent_with_input(task), tweak config " - "with inject_message, or escalate to building/planning." - ) + if can_transition: + result["phase"] = "reviewing" + result["message"] = ( + "Worker stopped. You are now in reviewing phase. " + "Review the latest results and decide whether to re-run, " + "edit the agent, or move into planning." + ) + else: + result["message"] = ( + "Stop requested, but the worker is still shutting down. Phase will not change until shutdown completes." + ) return json.dumps(result) _switch_editing_tool = Tool( @@ -1768,6 +1882,965 @@ def register_queen_lifecycle_tools( ) tools_registered += 1 + # --- stop_worker_and_review -------------------------------------------------- + + async def stop_worker_and_review() -> str: + """Stop the loaded graph and switch to building phase for editing the agent.""" + stop_result = await stop_worker() + result, can_transition = _stop_result_allows_phase_transition(stop_result) + + # Switch to building phase + if phase_state is not None and can_transition: + await phase_state.switch_to_building() + _update_meta_json(session_manager, manager_session_id, {"phase": "building"}) + + if can_transition: + result["phase"] = "building" + result["message"] = ( + "Graph stopped. You are now in building phase. " + "Use your coding tools to modify the agent, then call " + "load_built_agent(path) to stage it again." + ) + else: + result["message"] = ( + "Stop requested, but the worker is still shutting down. Phase will not change until shutdown completes." + ) + # Nudge the queen to start coding instead of blocking for user input. + if can_transition and phase_state is not None and phase_state.inject_notification: + await phase_state.inject_notification( + "[PHASE CHANGE] Switched to BUILDING phase. Start implementing the changes now." + ) + return json.dumps(result) + + _stop_edit_tool = Tool( + name="stop_worker_and_review", + description=( + "Stop the running graph and switch to building phase. " + "Use this when you need to modify the agent's code, nodes, or configuration. " + "After editing, call load_built_agent(path) to reload and run." + ), + parameters={"type": "object", "properties": {}}, + ) + registry.register("stop_worker_and_review", _stop_edit_tool, lambda inputs: stop_worker_and_review()) + tools_registered += 1 + + # --- stop_worker_and_plan (Running/Staging → Planning) --------------------- + + async def stop_worker_and_plan() -> str: + """Stop the loaded graph and switch to planning phase for diagnosis.""" + stop_result = await stop_worker() + result, can_transition = _stop_result_allows_phase_transition(stop_result) + + # Switch to planning phase + if phase_state is not None and can_transition: + await phase_state.switch_to_planning(source="tool") + _update_meta_json(session_manager, manager_session_id, {"phase": "planning"}) + + if can_transition: + result["phase"] = "planning" + result["message"] = ( + "Graph stopped. You are now in planning phase. " + "Diagnose the issue using read-only tools (checkpoints, logs, sessions), " + "discuss a fix plan with the user, then call " + "initialize_and_build_agent() to implement the fix." + ) + else: + result["message"] = ( + "Stop requested, but the worker is still shutting down. Phase will not change until shutdown completes." + ) + return json.dumps(result) + + _stop_plan_tool = Tool( + name="stop_worker_and_plan", + description=( + "Stop the graph and switch to planning phase for diagnosis. " + "Use this when you need to investigate an issue before fixing it. " + "After diagnosis, call initialize_and_build_agent() to switch to building." + ), + parameters={"type": "object", "properties": {}}, + ) + registry.register("stop_worker_and_plan", _stop_plan_tool, lambda inputs: stop_worker_and_plan()) + tools_registered += 1 + + # --- replan_agent (Building → Planning) ----------------------------------- + + async def replan_agent() -> str: + """Switch from building back to planning phase. + Only use when the user explicitly asks to re-plan.""" + if phase_state is not None: + if phase_state.phase != "building": + return json.dumps({"error": f"Cannot replan: currently in {phase_state.phase} phase."}) + + # Carry forward the current draft: restore original (pre-dissolution) + # draft so the queen can edit it in planning, rather than starting + # from scratch. + if phase_state.original_draft_graph is not None: + phase_state.draft_graph = phase_state.original_draft_graph + phase_state.original_draft_graph = None + phase_state.flowchart_map = None + phase_state.build_confirmed = False + + await phase_state.switch_to_planning(source="tool") + + # Re-emit draft so frontend shows the flowchart in planning mode + bus = phase_state.event_bus + if bus is not None and phase_state.draft_graph is not None: + try: + await bus.publish( + AgentEvent( + type=EventType.CUSTOM, + stream_id="queen", + data={"event": "draft_updated", **phase_state.draft_graph}, + ) + ) + except Exception: + logger.warning("Failed to re-emit draft during replan", exc_info=True) + + has_draft = phase_state is not None and phase_state.draft_graph is not None + return json.dumps( + { + "status": "replanning", + "phase": "planning", + "has_previous_draft": has_draft, + "message": ( + "Switched to PLANNING phase. Coding tools removed. " + + ( + "The previous draft flowchart has been restored (with " + "decision and sub-agent nodes intact). Call save_agent_draft() " + "to update the design, then confirm_and_build() when ready." + if has_draft + else "Discuss the new design with the user." + ) + ), + } + ) + + _replan_tool = Tool( + name="replan_agent", + description=( + "Switch from building back to planning phase. " + "Use when the user wants to change integrations, swap tools, " + "rethink the flow, or discuss design changes before building them." + ), + parameters={"type": "object", "properties": {}}, + ) + registry.register("replan_agent", _replan_tool, lambda inputs: replan_agent()) + tools_registered += 1 + + # --- save_agent_draft (Planning phase — declarative preview) ---------------- + # so the frontend can render the graph during planning (before any code). + + def _dissolve_planning_nodes( + draft: dict, + ) -> tuple[dict, dict[str, list[str]]]: + """Convert planning-only nodes into runtime-compatible structures. + + Two kinds of planning-only nodes are dissolved: + + **Decision nodes** (flowchart diamonds): + 1. Merging the decision clause into the predecessor node's success_criteria. + 2. Rewiring the decision's yes/no outgoing edges as on_success/on_failure + edges from the predecessor. + 3. Removing the decision node from the graph. + + **Sub-agent / browser nodes** (node_type == "gcu" or flowchart_type == "browser"): + 1. Adding the sub-agent node's ID to the predecessor's sub_agents list. + 2. Removing the sub-agent node and its connecting edge. + 3. Sub-agent nodes must not have outgoing edges (they are leaf delegates). + + Returns (converted_draft, flowchart_map) where flowchart_map maps + runtime node IDs → list of original draft node IDs they absorbed. + """ + import copy as _copy + + nodes: list[dict] = _copy.deepcopy(draft.get("nodes", [])) + edges: list[dict] = _copy.deepcopy(draft.get("edges", [])) + + # Index helpers + node_by_id: dict[str, dict] = {n["id"]: n for n in nodes} + + def _incoming(nid: str) -> list[dict]: + return [e for e in edges if e["target"] == nid] + + def _outgoing(nid: str) -> list[dict]: + return [e for e in edges if e["source"] == nid] + + # Identify decision nodes + decision_ids = [n["id"] for n in nodes if n.get("flowchart_type") == "decision"] + + # Track which draft nodes each runtime node absorbed + absorbed: dict[str, list[str]] = {} # runtime_id → [draft_ids...] + + # Process decisions in node-list order (topological for linear graphs) + for d_id in decision_ids: + d_node = node_by_id.get(d_id) + if d_node is None: + continue # already removed by a prior dissolution + + in_edges = _incoming(d_id) + out_edges = _outgoing(d_id) + + # Classify outgoing edges into yes/no branches + yes_edge: dict | None = None + no_edge: dict | None = None + + for oe in out_edges: + lbl = (oe.get("label") or "").lower().strip() + cond = (oe.get("condition") or "").lower().strip() + + if lbl in ("yes", "true", "pass") or cond == "on_success": + yes_edge = oe + elif lbl in ("no", "false", "fail") or cond == "on_failure": + no_edge = oe + + # Fallback: if exactly 2 outgoing and couldn't classify, assign by order + if len(out_edges) == 2 and (yes_edge is None or no_edge is None): + if yes_edge is None and no_edge is None: + yes_edge, no_edge = out_edges[0], out_edges[1] + elif yes_edge is None: + yes_edge = [e for e in out_edges if e is not no_edge][0] + else: + no_edge = [e for e in out_edges if e is not yes_edge][0] + + # Decision clause: prefer decision_clause, fall back to description/name + clause = (d_node.get("decision_clause") or d_node.get("description") or d_node.get("name") or d_id).strip() + + predecessors = [node_by_id[e["source"]] for e in in_edges if e["source"] in node_by_id] + + if not predecessors: + # Decision at start: convert to regular process node + d_node["flowchart_type"] = "process" + fc_meta = FLOWCHART_TYPES["process"] + d_node["flowchart_shape"] = fc_meta["shape"] + d_node["flowchart_color"] = fc_meta["color"] + if not d_node.get("success_criteria"): + d_node["success_criteria"] = clause + # Rewire outgoing edges to on_success/on_failure + if yes_edge: + yes_edge["condition"] = "on_success" + if no_edge: + no_edge["condition"] = "on_failure" + absorbed[d_id] = absorbed.get(d_id, [d_id]) + continue + + # Dissolve: merge into each predecessor + for pred in predecessors: + pid = pred["id"] + + # Merge decision clause into predecessor's success_criteria + existing = (pred.get("success_criteria") or "").strip() + if existing: + pred["success_criteria"] = f"{existing}; then evaluate: {clause}" + else: + pred["success_criteria"] = clause + + # Remove the edge from predecessor → decision + edges[:] = [e for e in edges if not (e["source"] == pid and e["target"] == d_id)] + + # Wire predecessor → yes/no targets + edge_counter = len(edges) + if yes_edge: + edges.append( + { + "id": f"edge-dissolved-{edge_counter}", + "source": pid, + "target": yes_edge["target"], + "condition": "on_success", + "description": yes_edge.get("description", ""), + "label": yes_edge.get("label", "Yes"), + } + ) + edge_counter += 1 + if no_edge: + edges.append( + { + "id": f"edge-dissolved-{edge_counter}", + "source": pid, + "target": no_edge["target"], + "condition": "on_failure", + "description": no_edge.get("description", ""), + "label": no_edge.get("label", "No"), + } + ) + + # Record absorption + prev_absorbed = absorbed.get(pid, [pid]) + if d_id not in prev_absorbed: + prev_absorbed.append(d_id) + absorbed[pid] = prev_absorbed + + # Remove decision node and all its edges + edges[:] = [e for e in edges if e["source"] != d_id and e["target"] != d_id] + nodes[:] = [n for n in nodes if n["id"] != d_id] + del node_by_id[d_id] + + # Build complete flowchart_map (identity for non-absorbed nodes) + flowchart_map: dict[str, list[str]] = {} + for n in nodes: + nid = n["id"] + flowchart_map[nid] = absorbed.get(nid, [nid]) + + # Rebuild terminal_nodes (decision targets may have changed). + sources = {e["source"] for e in edges} + all_ids = {n["id"] for n in nodes} + terminal_ids = all_ids - sources + if not terminal_ids and nodes: + terminal_ids = {nodes[-1]["id"]} + + converted = dict(draft) + converted["nodes"] = nodes + converted["edges"] = edges + converted["terminal_nodes"] = sorted(terminal_ids) + converted["entry_node"] = nodes[0]["id"] if nodes else "" + + return converted, flowchart_map + + async def save_agent_draft( + *, + agent_name: str, + goal: str, + nodes: list[dict], + edges: list[dict] | None = None, + description: str = "", + success_criteria: list[str] | None = None, + constraints: list[str] | None = None, + terminal_nodes: list[str] | None = None, + ) -> str: + """Save a declarative draft of the agent graph during planning. + + This creates a lightweight, visual-only graph for the user to review. + No executable code is generated. Nodes need only an id, name, and + description. Tools, input/output keys, and system prompts are optional + metadata hints — they will be fully specified during the building phase. + + Each node is classified into a classical flowchart component type + (start, terminal, process, decision, io, subprocess, browser, manual) + with a unique color. The queen can override auto-detection by setting + flowchart_type explicitly on a node. + """ + # ── Gate: require at least 2 rounds of user questions ───────── + if phase_state is not None and phase_state.phase == "planning" and phase_state.planning_ask_rounds < 2: + return json.dumps( + { + "error": ( + "You haven't asked enough questions yet. You have only " + f"asked {phase_state.planning_ask_rounds} round(s) of " + "questions — at least 2 are required before saving a " + "draft. Think deeper and ask more practical questions " + "to fully understand the user's requirements before " + "designing the agent graph." + ) + } + ) + + # ── Gate: require at least 5 nodes for a meaningful graph ───── + if len(nodes) < 5: + return json.dumps( + { + "error": ( + f"Draft only has {len(nodes)} node(s) — at least 5 are " + "required for a meaningful agent graph. Think deeper and " + "ask more practical questions to fully understand the " + "user's requirements, then design a more thorough graph." + ) + } + ) + + # Loose validation: each node needs at minimum an id + validated_nodes = [] + for i, n in enumerate(nodes): + if not isinstance(n, dict): + return json.dumps({"error": f"Node {i} must be a dict, got {type(n).__name__}"}) + node_id = n.get("id", "").strip() + if not node_id: + return json.dumps({"error": f"Node {i} is missing 'id'"}) + validated_nodes.append( + { + "id": node_id, + "name": n.get("name", node_id.replace("-", " ").replace("_", " ").title()), + "description": n.get("description", ""), + "node_type": n.get("node_type", "event_loop"), + # Optional business-logic hints (not validated yet) + "tools": n.get("tools", []), + "input_keys": n.get("input_keys", []), + "output_keys": n.get("output_keys", []), + "success_criteria": n.get("success_criteria", ""), + # Decision nodes: the yes/no question to evaluate + "decision_clause": n.get("decision_clause", ""), + # Explicit flowchart override (preserved for classification) + "flowchart_type": n.get("flowchart_type", ""), + } + ) + + # Check for duplicate node IDs + seen_ids: set[str] = set() + for n in validated_nodes: + if n["id"] in seen_ids: + return json.dumps({"error": f"Duplicate node id '{n['id']}'"}) + seen_ids.add(n["id"]) + + validated_edges = [] + if edges: + node_ids = {n["id"] for n in validated_nodes} + for i, e in enumerate(edges): + if not isinstance(e, dict): + return json.dumps({"error": f"Edge {i} must be a dict"}) + src = e.get("source", "") + tgt = e.get("target", "") + if src and src not in node_ids: + return json.dumps({"error": f"Edge {i} source '{src}' references unknown node"}) + if tgt and tgt not in node_ids: + return json.dumps({"error": f"Edge {i} target '{tgt}' references unknown node"}) + validated_edges.append( + { + "id": e.get("id", f"edge-{i}"), + "source": src, + "target": tgt, + "condition": e.get("condition", "on_success"), + "description": e.get("description", ""), + "label": e.get("label", ""), + } + ) + + topology_corrections: list[str] = [] + + # ── Validate graph connectivity ───────────────────────────── + # Every node must be reachable from the entry node. Disconnected + # subgraphs indicate a broken design — remove unreachable nodes + # and report them so the queen can fix the draft. + if validated_nodes: + entry_id = validated_nodes[0]["id"] + # Build undirected adjacency from edges + _adj: dict[str, set[str]] = {n["id"]: set() for n in validated_nodes} + for e in validated_edges: + s, t = e["source"], e["target"] + if s in _adj and t in _adj: + _adj[s].add(t) + _adj[t].add(s) + # BFS from entry + visited: set[str] = set() + queue = [entry_id] + while queue: + cur = queue.pop() + if cur in visited: + continue + visited.add(cur) + for nb in _adj.get(cur, ()): + if nb not in visited: + queue.append(nb) + unreachable = {n["id"] for n in validated_nodes} - visited + if unreachable: + for uid in sorted(unreachable): + logger.warning( + "Node '%s' is unreachable from entry node '%s' — removing it from the draft.", + uid, + entry_id, + ) + topology_corrections.append( + f"Node '{uid}' is disconnected from the graph " + f"(unreachable from entry node '{entry_id}') — " + f"removed. Connect it to the flow or assign it " + f"as a sub-agent of an existing node." + ) + validated_edges[:] = [ + e for e in validated_edges if e["source"] not in unreachable and e["target"] not in unreachable + ] + validated_nodes[:] = [n for n in validated_nodes if n["id"] not in unreachable] + + # Determine terminal nodes: explicit list, or nodes with no outgoing edges. + # Sub-agent nodes are leaf helpers, not endpoints — exclude them. + sa_ids: set[str] = set() + for n in validated_nodes: + for sa_id in n.get("sub_agents") or []: + sa_ids.add(sa_id) + terminal_ids: set[str] = set(terminal_nodes or []) - sa_ids + if not terminal_ids: + sources = {e["source"] for e in validated_edges} + all_ids = {n["id"] for n in validated_nodes} + terminal_ids = all_ids - sources - sa_ids + # If all nodes have outgoing edges (loop graph), mark the last as terminal + if not terminal_ids and validated_nodes: + terminal_ids = {validated_nodes[-1]["id"]} + + # Classify each node into a flowchart component type with color + total = len(validated_nodes) + for i, node in enumerate(validated_nodes): + fc_type = classify_flowchart_node( + node, + i, + total, + validated_edges, + terminal_ids, + ) + fc_meta = FLOWCHART_TYPES[fc_type] + node["flowchart_type"] = fc_type + node["flowchart_shape"] = fc_meta["shape"] + node["flowchart_color"] = fc_meta["color"] + + draft = { + "agent_name": agent_name.strip(), + "goal": goal.strip(), + "description": description.strip(), + "success_criteria": success_criteria or [], + "constraints": constraints or [], + "nodes": validated_nodes, + "edges": validated_edges, + "entry_node": validated_nodes[0]["id"] if validated_nodes else "", + "terminal_nodes": sorted(terminal_ids), + # Color legend for the frontend + "flowchart_legend": { + fc_type: {"shape": meta["shape"], "color": meta["color"]} for fc_type, meta in FLOWCHART_TYPES.items() + }, + } + + bus = getattr(session, "event_bus", None) + is_building = phase_state is not None and phase_state.phase == "building" + + if phase_state is not None: + if is_building: + # During building: re-draft updates the flowchart in place. + # Dissolve planning-only nodes immediately (no confirm gate). + import copy as _copy + + phase_state.original_draft_graph = _copy.deepcopy(draft) + converted, fmap = _dissolve_planning_nodes(draft) + phase_state.draft_graph = converted + phase_state.flowchart_map = fmap + # Do NOT reset build_confirmed — we're already building. + # Persist to agent folder + save_path = getattr(session, "worker_path", None) + if save_path is None: + # Worker not loaded yet — resolve from draft name + draft_name = draft.get("agent_name", "") + if draft_name: + from framework.config import COLONIES_DIR + + candidate = COLONIES_DIR / draft_name + if candidate.is_dir(): + save_path = candidate + save_flowchart_file( + save_path, + phase_state.original_draft_graph, + fmap, + ) + else: + # During planning: store raw draft, await user confirmation. + phase_state.draft_graph = draft + phase_state.build_confirmed = False + + # Emit events so the frontend can render + if bus is not None: + if is_building: + await bus.publish( + AgentEvent( + type=EventType.CUSTOM, + stream_id="queen", + data={ + "event": "draft_updated", + **(phase_state.draft_graph if phase_state else draft), + }, + ) + ) + await bus.publish( + AgentEvent( + type=EventType.CUSTOM, + stream_id="queen", + data={ + "event": "flowchart_updated", + "map": phase_state.flowchart_map if phase_state else None, + "original_draft": phase_state.original_draft_graph if phase_state else draft, + }, + ) + ) + else: + await bus.publish( + AgentEvent( + type=EventType.CUSTOM, + stream_id="queen", + data={"event": "draft_updated", **draft}, + ) + ) + + dissolution_info = {} + if is_building and phase_state is not None and phase_state.original_draft_graph: + orig_count = len(phase_state.original_draft_graph.get("nodes", [])) + conv_count = len(phase_state.draft_graph.get("nodes", [])) + dissolution_info = { + "planning_nodes_dissolved": orig_count - conv_count, + "flowchart_map": phase_state.flowchart_map, + } + + correction_warning = "" + if topology_corrections: + correction_warning = ( + " WARNING — your draft had topology errors that were " + "auto-corrected: " + + "; ".join(topology_corrections) + + " Review the corrected flowchart and do NOT repeat " + "this pattern. GCU nodes are ALWAYS leaf sub-agents." + ) + + if is_building: + msg = ( + "Draft flowchart updated during building. " + "Planning-only nodes dissolved automatically. " + "The user can see the updated flowchart. " + "Continue building — no re-confirmation needed." + correction_warning + ) + else: + msg = ( + "Draft graph saved and sent to the visualizer. " + "The user can now see the color-coded flowchart. " + "Present this design to the user and get their approval. " + "When the user confirms, call confirm_and_build() to proceed." + correction_warning + ) + + result: dict = { + "status": "draft_saved", + "agent_name": draft["agent_name"], + "node_count": len(validated_nodes), + "edge_count": len(validated_edges), + "node_types": {n["id"]: n["flowchart_type"] for n in validated_nodes}, + **dissolution_info, + "message": msg, + } + if topology_corrections: + result["topology_corrections"] = topology_corrections + return json.dumps(result) + + _draft_tool = Tool( + name="save_agent_draft", + description=( + "Save a declarative draft of the agent graph as a color-coded flowchart. " + "Usable in PLANNING (creates draft for user review) and BUILDING " + "(updates the flowchart in place — planning-only nodes are dissolved " + "automatically without re-confirmation). " + "Each node is auto-classified into a classical flowchart type " + "(start, terminal, process, decision, io, subprocess, browser, manual) " + "with unique colors. No code is generated. " + "Planning-only types (decision, browser/GCU) are dissolved at confirm/build time: " + "decision nodes merge into predecessor's success_criteria with yes/no edges; " + "browser/GCU nodes merge into predecessor's sub_agents list as leaf delegates." + ), + parameters={ + "type": "object", + "properties": { + "agent_name": { + "type": "string", + "description": "Snake_case name for the agent (e.g. 'research_agent')", + }, + "goal": { + "type": "string", + "description": "High-level goal description for the agent", + }, + "description": { + "type": "string", + "description": "Brief description of what the agent does", + }, + "nodes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": {"type": "string", "description": "Kebab-case node identifier"}, + "name": {"type": "string", "description": "Human-readable name"}, + "description": { + "type": "string", + "description": "What this node does (business logic)", + }, + "node_type": { + "type": "string", + "enum": ["event_loop", "gcu"], + "description": "Node type (default: event_loop)", + }, + "flowchart_type": { + "type": "string", + "enum": [ + "start", + "terminal", + "process", + "decision", + "io", + "document", + "database", + "subprocess", + "browser", + ], + "description": ( + "Flowchart symbol type. Auto-detected if omitted. " + "start (sage green stadium), terminal (dusty red stadium), " + "process (blue-gray rect), decision (amber diamond), " + "io (purple parallelogram), document (steel blue wavy rect), " + "database (teal cylinder), subprocess (cyan subroutine), " + "browser (deep blue hexagon — for GCU/browser " + "sub-agents; must be a leaf node)" + ), + }, + "tools": { + "type": "array", + "items": {"type": "string"}, + "description": "Planned tools (hints, not validated yet)", + }, + "input_keys": { + "type": "array", + "items": {"type": "string"}, + "description": "Expected input buffer keys (hints)", + }, + "output_keys": { + "type": "array", + "items": {"type": "string"}, + "description": "Expected output buffer keys (hints)", + }, + "success_criteria": { + "type": "string", + "description": "What success looks like for this node", + }, + "sub_agents": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "IDs of GCU/browser sub-agent nodes managed by this node. " + "At build time, sub-agent nodes are dissolved into this list. " + "Set this on the PARENT node — e.g. the orchestrator that " + "delegates to GCU leaves. Visual delegation edges are " + "synthesized automatically." + ), + }, + "decision_clause": { + "type": "string", + "description": ( + "For decision nodes only: the yes/no question to " + "evaluate (e.g. 'Is amount > $100?'). Used during " + "dissolution to set the predecessor's success_criteria." + ), + }, + }, + "required": ["id"], + }, + "description": "List of nodes with at minimum an id", + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "properties": { + "source": {"type": "string"}, + "target": {"type": "string"}, + "condition": { + "type": "string", + "enum": [ + "always", + "on_success", + "on_failure", + "conditional", + "llm_decide", + ], + }, + "description": {"type": "string"}, + "label": { + "type": "string", + "description": ("Short edge label shown on the flowchart (e.g. 'Yes', 'No', 'Retry')"), + }, + }, + "required": ["source", "target"], + }, + "description": "Connections between nodes", + }, + "terminal_nodes": { + "type": "array", + "items": {"type": "string"}, + "description": ("Node IDs that are terminal (end) nodes. Auto-detected from edges if omitted."), + }, + "success_criteria": { + "type": "array", + "items": {"type": "string"}, + "description": "Agent-level success criteria", + }, + "constraints": { + "type": "array", + "items": {"type": "string"}, + "description": "Agent-level constraints", + }, + }, + "required": ["agent_name", "goal", "nodes"], + }, + ) + registry.register( + "save_agent_draft", + _draft_tool, + lambda inputs: save_agent_draft(**inputs), + ) + tools_registered += 1 + + # --- confirm_and_build (Planning → Building gate) ------------------------- + # Explicit user confirmation is required before transitioning from planning + # to building. This tool records that confirmation and proceeds. + + async def confirm_and_build(*, agent_name: str | None = None) -> str: + """Confirm the draft, create agent directory, and transition to building. + + This tool should ONLY be called after the user has explicitly approved + the draft graph design via ask_user. It creates the agent directory and + transitions to BUILDING phase. The queen then writes agent.json directly. + """ + if phase_state is None: + return json.dumps({"error": "Phase state not available."}) + + if phase_state.phase != "planning": + return json.dumps({"error": f"Cannot confirm_and_build: currently in {phase_state.phase} phase."}) + + if phase_state.draft_graph is None: + return json.dumps( + { + "error": ( + "No draft graph saved. Call save_agent_draft() first to create " + "a draft, present it to the user, and get their approval." + ) + } + ) + + phase_state.build_confirmed = True + + # Preserve original draft for flowchart display during runtime, + # then dissolve planning-only nodes (decision + browser/GCU) into + # runtime-compatible structures. + import copy as _copy + + original_nodes = phase_state.draft_graph.get("nodes", []) + # Compute dissolution first, then assign all three atomically so that + # a failure in _dissolve_planning_nodes doesn't leave partial state. + original_copy = _copy.deepcopy(phase_state.draft_graph) + converted, fmap = _dissolve_planning_nodes(phase_state.draft_graph) + phase_state.original_draft_graph = original_copy + phase_state.draft_graph = converted + phase_state.flowchart_map = fmap + + # Create agent folder early so flowchart and agent_path are available + # throughout the entire BUILDING phase. + _agent_name = agent_name or phase_state.draft_graph.get("agent_name", "").strip() + if _agent_name: + from framework.config import COLONIES_DIR + + _agent_folder = COLONIES_DIR / _agent_name + _agent_folder.mkdir(parents=True, exist_ok=True) + save_flowchart_file(_agent_folder, original_copy, fmap) + phase_state.agent_path = str(_agent_folder) + _update_meta_json( + session_manager, + manager_session_id, + { + "agent_path": str(_agent_folder), + "agent_name": _agent_name.replace("_", " ").title(), + }, + ) + + dissolved_count = len(original_nodes) - len(converted.get("nodes", [])) + decision_count = sum(1 for n in original_nodes if n.get("flowchart_type") == "decision") + subagent_count = sum( + 1 for n in original_nodes if n.get("flowchart_type") == "browser" or n.get("node_type") == "gcu" + ) + + dissolution_parts = [] + if decision_count: + dissolution_parts.append(f"{decision_count} decision node(s) dissolved into predecessor criteria") + if subagent_count: + dissolution_parts.append(f"{subagent_count} sub-agent node(s) dissolved into predecessor sub_agents") + + # Transition to BUILDING phase + await phase_state.switch_to_building(source="tool") + _update_meta_json(session_manager, manager_session_id, {"phase": "building"}) + phase_state.build_confirmed = False + + # No injection here -- the return message tells the queen what to do. + # Injecting would queue a BUILDING message that drains AFTER the queen + # may have already moved to STAGING via load_built_agent. + + return json.dumps( + { + "status": "confirmed", + "phase": "building", + "agent_name": _agent_name, + "agent_path": str(_agent_folder), + "planning_nodes_dissolved": dissolved_count, + "flowchart_map": fmap, + "message": ( + "Design confirmed and directory created. " + + ("; ".join(dissolution_parts) + ". " if dissolution_parts else "") + + f"Now write the complete agent config to {_agent_folder}/agent.json " + "using write_file(). Include all system prompts, tools, edges, and goal." + ), + } + ) + + _confirm_tool = Tool( + name="confirm_and_build", + description=( + "Confirm the draft graph design, create agent directory, and transition to building phase. " + "ONLY call this after the user has explicitly approved the design via ask_user. " + "After confirmation, write the complete agent.json using write_file()." + ), + parameters={ + "type": "object", + "properties": { + "agent_name": { + "type": "string", + "description": "Snake_case name for the agent (e.g. 'linkedin_outreach'). " + "If omitted, uses the name from save_agent_draft().", + }, + }, + }, + ) + registry.register( + "confirm_and_build", + _confirm_tool, + lambda inputs: confirm_and_build( + agent_name=inputs.get("agent_name"), + ), + ) + tools_registered += 1 + + # --- stop_worker (Running → Staging) -------------------------------------- + + async def stop_worker_to_staging() -> str: + """Stop the running graph and switch to staging phase. + + After stopping, ask the user whether they want to: + 1. Re-run the agent with new input → call run_agent_with_input(task) + 2. Edit the agent code → call stop_worker_and_review() to go to building phase + """ + stop_result = await stop_worker() + result, can_transition = _stop_result_allows_phase_transition(stop_result) + + # Switch to staging phase + if phase_state is not None and can_transition: + await phase_state.switch_to_staging() + _update_meta_json(session_manager, manager_session_id, {"phase": "staging"}) + + if can_transition: + result["phase"] = "staging" + result["message"] = ( + "Graph stopped. You are now in staging phase. " + "Ask the user: would they like to re-run with new input, " + "or edit the agent code?" + ) + else: + result["message"] = ( + "Stop requested, but the worker is still shutting down. " + "Stay in the current phase until shutdown completes." + ) + return json.dumps(result) + + _stop_worker_tool = Tool( + name="stop_worker", + description=( + "Stop the running graph and switch to staging phase. " + "After stopping, ask the user whether they want to re-run " + "with new input or edit the agent code." + ), + parameters={"type": "object", "properties": {}}, + ) + registry.register("stop_worker", _stop_worker_tool, lambda inputs: stop_worker_to_staging()) + tools_registered += 1 # --- get_worker_status ----------------------------------------------------- diff --git a/core/frontend/src/api/client.ts b/core/frontend/src/api/client.ts index d9fed260..f042a5f8 100644 --- a/core/frontend/src/api/client.ts +++ b/core/frontend/src/api/client.ts @@ -12,12 +12,13 @@ export class ApiError extends Error { async function request(path: string, options: RequestInit = {}): Promise { const url = `${API_BASE}${path}`; + const isFormData = options.body instanceof FormData; + const headers: Record = isFormData + ? {} // Let browser set Content-Type with boundary for multipart + : { "Content-Type": "application/json", ...options.headers as Record }; const response = await fetch(url, { ...options, - headers: { - "Content-Type": "application/json", - ...options.headers, - }, + headers, }); if (!response.ok) { @@ -52,4 +53,6 @@ export const api = { method: "PATCH", body: body ? JSON.stringify(body) : undefined, }), + upload: (path: string, formData: FormData) => + request(path, { method: "POST", body: formData }), }; diff --git a/core/frontend/src/api/colonyData.ts b/core/frontend/src/api/colonyData.ts new file mode 100644 index 00000000..0b2545a3 --- /dev/null +++ b/core/frontend/src/api/colonyData.ts @@ -0,0 +1,80 @@ +import { api } from "./client"; + +/** A SQLite cell value, constrained to JSON-serialisable types that + * Python maps into sqlite3 param placeholders without surprises. */ +export type CellValue = string | number | boolean | null; + +export interface ColumnInfo { + name: string; + /** SQLite declared type (e.g. "TEXT", "INTEGER"). May be empty string. */ + type: string; + notnull: boolean; + /** >0 means part of the primary key (ordinal position). 0 = not PK. */ + pk: number; + dflt_value: string | null; +} + +export interface TableOverview { + name: string; + columns: ColumnInfo[]; + row_count: number; + primary_key: string[]; +} + +export interface TableRowsResponse { + table: string; + columns: ColumnInfo[]; + primary_key: string[]; + rows: Record[]; + total: number; + limit: number; + offset: number; +} + +export interface UpdateRowRequest { + /** Primary key column(s) → value(s). All PK columns must be present. */ + pk: Record; + /** Column(s) → new value(s). Cannot include PK columns. */ + updates: Record; +} + +export const colonyDataApi = { + /** List user tables in the colony's progress.db with row counts. + * + * Routed by colony directory name (not session) because progress.db + * is per-colony — one DB serves every session for that colony, and + * the data is reachable even when no session is live. */ + listTables: (colonyName: string) => + api.get<{ tables: TableOverview[] }>( + `/colonies/${encodeURIComponent(colonyName)}/data/tables`, + ), + + /** Paginated rows for a table. Server enforces limit ≤ 500. */ + listRows: ( + colonyName: string, + table: string, + opts: { + limit?: number; + offset?: number; + orderBy?: string | null; + orderDir?: "asc" | "desc"; + } = {}, + ) => { + const params = new URLSearchParams(); + if (opts.limit != null) params.set("limit", String(opts.limit)); + if (opts.offset != null) params.set("offset", String(opts.offset)); + if (opts.orderBy) params.set("order_by", opts.orderBy); + if (opts.orderDir) params.set("order_dir", opts.orderDir); + const qs = params.toString(); + return api.get( + `/colonies/${encodeURIComponent(colonyName)}/data/tables/${encodeURIComponent(table)}/rows${qs ? `?${qs}` : ""}`, + ); + }, + + /** Update a single row by primary key. Returns {updated: 0|1}. */ + updateRow: (colonyName: string, table: string, body: UpdateRowRequest) => + api.patch<{ updated: number }>( + `/colonies/${encodeURIComponent(colonyName)}/data/tables/${encodeURIComponent(table)}/rows`, + body, + ), +}; diff --git a/core/frontend/src/api/colonyWorkers.ts b/core/frontend/src/api/colonyWorkers.ts index 5aa4440d..0a5973ad 100644 --- a/core/frontend/src/api/colonyWorkers.ts +++ b/core/frontend/src/api/colonyWorkers.ts @@ -87,17 +87,19 @@ export const colonyWorkersApi = { listTools: (sessionId: string) => api.get<{ tools: ColonyTool[] }>(`/sessions/${sessionId}/colony/tools`), - /** Snapshot of progress.db tasks + steps, optionally filtered by worker_id. */ - progressSnapshot: (sessionId: string, workerId?: string) => { + /** Snapshot of progress.db tasks + steps, optionally filtered by + * worker_id. Routed by colony directory name (not session) because + * progress.db is per-colony. */ + progressSnapshot: (colonyName: string, workerId?: string) => { const qs = workerId ? `?worker_id=${encodeURIComponent(workerId)}` : ""; return api.get( - `/sessions/${sessionId}/colony/progress/snapshot${qs}`, + `/colonies/${encodeURIComponent(colonyName)}/progress/snapshot${qs}`, ); }, /** Build the URL for the live progress SSE stream. */ - progressStreamUrl: (sessionId: string, workerId?: string): string => { + progressStreamUrl: (colonyName: string, workerId?: string): string => { const qs = workerId ? `?worker_id=${encodeURIComponent(workerId)}` : ""; - return `/api/sessions/${sessionId}/colony/progress/stream${qs}`; + return `/api/colonies/${encodeURIComponent(colonyName)}/progress/stream${qs}`; }, }; diff --git a/core/frontend/src/api/config.ts b/core/frontend/src/api/config.ts index f514a034..b8b4eb9a 100644 --- a/core/frontend/src/api/config.ts +++ b/core/frontend/src/api/config.ts @@ -64,4 +64,10 @@ export const configApi = { about, ...(theme ? { theme } : {}), }), + + uploadAvatar: (file: File) => { + const fd = new FormData(); + fd.append("avatar", file); + return api.upload<{ avatar_url: string }>("/config/profile/avatar", fd); + }, }; diff --git a/core/frontend/src/api/prompts.ts b/core/frontend/src/api/prompts.ts new file mode 100644 index 00000000..f655962c --- /dev/null +++ b/core/frontend/src/api/prompts.ts @@ -0,0 +1,19 @@ +import { api } from "./client"; + +export interface CustomPrompt { + id: string; + title: string; + category: string; + content: string; + custom: true; +} + +export const promptsApi = { + list: () => api.get<{ prompts: CustomPrompt[] }>("/prompts"), + + create: (title: string, category: string, content: string) => + api.post("/prompts", { title, category, content }), + + delete: (promptId: string) => + api.delete<{ deleted: string }>(`/prompts/${promptId}`), +}; diff --git a/core/frontend/src/api/queens.ts b/core/frontend/src/api/queens.ts index 575a702f..35e57dea 100644 --- a/core/frontend/src/api/queens.ts +++ b/core/frontend/src/api/queens.ts @@ -31,6 +31,13 @@ export const queensApi = { updateProfile: (queenId: string, updates: Partial) => api.patch(`/queen/${queenId}/profile`, updates), + /** Upload queen avatar image. */ + uploadAvatar: (queenId: string, file: File) => { + const fd = new FormData(); + fd.append("avatar", file); + return api.upload<{ avatar_url: string }>(`/queen/${queenId}/avatar`, fd); + }, + /** Get or create a persistent session for a queen. */ getOrCreateSession: (queenId: string, initialPrompt?: string, initialPhase?: string) => api.post(`/queen/${queenId}/session`, { diff --git a/core/frontend/src/components/AppHeader.tsx b/core/frontend/src/components/AppHeader.tsx index d4b586c6..6bf3c834 100644 --- a/core/frontend/src/components/AppHeader.tsx +++ b/core/frontend/src/components/AppHeader.tsx @@ -1,11 +1,31 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useLocation } from "react-router-dom"; import { useColony } from "@/context/ColonyContext"; import { useHeaderActions } from "@/context/HeaderActionsContext"; +import { useModel } from "@/context/ModelContext"; import { getQueenForAgent } from "@/lib/colony-registry"; -import { Crown, KeyRound, Network } from "lucide-react"; +import { Crown, KeyRound, Network, ChevronDown } from "lucide-react"; import SettingsModal from "@/components/SettingsModal"; -import ModelSwitcher from "@/components/ModelSwitcher"; + +function UserAvatarButton({ initials, onClick, avatarVersion }: { initials: string; onClick: () => void; avatarVersion: number }) { + const [hasAvatar, setHasAvatar] = useState(true); + const url = `/api/config/profile/avatar?v=${avatarVersion}`; + // Reset hasAvatar when version changes (new upload) + useEffect(() => setHasAvatar(true), [avatarVersion]); + return ( + + ); +} interface AppHeaderProps { onOpenQueenProfile?: (queenId: string) => void; @@ -13,11 +33,23 @@ interface AppHeaderProps { export default function AppHeader({ onOpenQueenProfile }: AppHeaderProps) { const location = useLocation(); - const { colonies, queens, queenProfiles, userProfile } = useColony(); + const { colonies, queens, queenProfiles, userProfile, userAvatarVersion } = useColony(); const { actions } = useHeaderActions(); + const { currentModel, currentProvider, availableModels, activeSubscription, subscriptions } = useModel(); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsSection, setSettingsSection] = useState<"profile" | "byok">("profile"); + // Derive active model display label + const activeSubInfo = activeSubscription + ? subscriptions.find((s) => s.id === activeSubscription) + : null; + const modelsProvider = activeSubInfo?.provider || currentProvider; + const models = availableModels[modelsProvider] || []; + const currentModelInfo = models.find((m) => m.id === currentModel); + const modelLabel = currentModelInfo + ? currentModelInfo.label.split(" - ")[0] + : currentModel || "No model"; + // Derive page title + icon from current route const colonyMatch = location.pathname.match(/^\/colony\/(.+)/); const queenMatch = location.pathname.match(/^\/queen\/(.+)/); @@ -95,24 +127,24 @@ export default function AppHeader({ onOpenQueenProfile }: AppHeaderProps) { )}
{actions} - { + + { setSettingsSection("profile"); setSettingsOpen(true); }} - className="w-7 h-7 rounded-full bg-primary/15 flex items-center justify-center hover:bg-primary/25 transition-colors" - title="Profile settings" - > - - {initials || "U"} - - + />
diff --git a/core/frontend/src/components/ChatPanel.tsx b/core/frontend/src/components/ChatPanel.tsx index b3244ea7..1ef3f12b 100644 --- a/core/frontend/src/components/ChatPanel.tsx +++ b/core/frontend/src/components/ChatPanel.tsx @@ -27,14 +27,15 @@ export interface ContextUsageEntry { import MarkdownContent from "@/components/MarkdownContent"; import QuestionWidget from "@/components/QuestionWidget"; import MultiQuestionWidget from "@/components/MultiQuestionWidget"; -import { useColony } from "@/context/ColonyContext"; import { useQueenProfile } from "@/context/QueenProfileContext"; +import { useColonyWorkers } from "@/context/ColonyWorkersContext"; import ParallelSubagentBubble, { type SubagentGroup, } from "@/components/ParallelSubagentBubble"; import { formatMessageTime, formatDayDividerLabel, + workerIdFromStreamId, } from "@/lib/chat-helpers"; export interface ChatMessage { @@ -111,6 +112,14 @@ interface ChatPanelProps { contextUsage?: Record; /** One-shot composer prefill. Applied to the textarea whenever the value changes. */ initialDraft?: string | null; + /** Queen profile this panel is attached to. When provided, clicking a + * queen avatar/name opens that queen's profile panel directly — + * no fragile name-based lookup against ``queenProfiles``. Nullable + * to tolerate pages that render the panel before the queen is + * resolved (e.g. new-chat bootstrap). */ + queenProfileId?: string | null; + /** Queen ID — used to display the queen's avatar photo in messages */ + queenId?: string; } const queenColor = "hsl(45,95%,58%)"; @@ -310,10 +319,13 @@ function InlineAskUserBubble({ onSend, queenPhase, showQueenPhaseBadge = true, + queenProfileId, + queenAvatarUrl, }: { msg: ChatMessage; payload: AskUserInlinePayload; activeThread: string; + queenAvatarUrl?: string | null; onSend: ( message: string, thread: string, @@ -321,6 +333,7 @@ function InlineAskUserBubble({ ) => void; queenPhase?: "independent" | "working" | "reviewing"; showQueenPhaseBadge?: boolean; + queenProfileId?: string | null; }) { const [state, setState] = useState<"pending" | "submitted" | "dismissed">( "pending", @@ -338,6 +351,8 @@ function InlineAskUserBubble({ msg={msg} queenPhase={queenPhase} showQueenPhaseBadge={showQueenPhaseBadge} + queenProfileId={queenProfileId} + queenAvatarUrl={queenAvatarUrl} /> ); } @@ -346,14 +361,26 @@ function InlineAskUserBubble({ const color = getColor(msg.agent, msg.role); const thread = msg.thread || activeThread; - const { queenProfiles } = useColony(); const { openQueenProfile } = useQueenProfile(); - const queenProfileId = isQueen - ? queenProfiles.find((q) => q.name === msg.agent)?.id ?? null - : null; - const handleQueenClick = queenProfileId - ? () => openQueenProfile(queenProfileId) + const { openColonyWorkers } = useColonyWorkers(); + const resolvedQueenProfileId = isQueen ? queenProfileId ?? null : null; + const handleQueenClick = resolvedQueenProfileId + ? () => openQueenProfile(resolvedQueenProfileId) : undefined; + const workerId = + !isQueen && msg.role === "worker" + ? workerIdFromStreamId(msg.streamId) + : null; + const handleWorkerClick = + msg.role === "worker" + ? () => openColonyWorkers(workerId ?? undefined) + : undefined; + const handleAvatarClick = handleQueenClick ?? handleWorkerClick; + const avatarTitle = handleQueenClick + ? `View ${msg.agent}'s profile` + : handleWorkerClick + ? "Open worker in colony sidebar" + : undefined; const handleSingle = (answer: string) => { setState("submitted"); @@ -374,17 +401,17 @@ function InlineAskUserBubble({ return (
{isQueen ? ( - + ) : ( )} @@ -439,26 +466,49 @@ function InlineAskUserBubble({ ); } +function QueenAvatarIcon({ url, size }: { url: string | null; size: number }) { + const [ok, setOk] = useState(!!url); + const dim = size === 9 ? "w-9 h-9" : "w-7 h-7"; + if (ok && url) { + return setOk(false)} />; + } + return ; +} + const MessageBubble = memo( function MessageBubble({ msg, queenPhase, showQueenPhaseBadge = true, + queenProfileId, + queenAvatarUrl, }: { msg: ChatMessage; queenPhase?: "independent" | "working" | "reviewing"; showQueenPhaseBadge?: boolean; + queenProfileId?: string | null; + queenAvatarUrl?: string | null; }) { const isUser = msg.type === "user"; const isQueen = msg.role === "queen"; const color = getColor(msg.agent, msg.role); - // Resolve queen profile ID so clicking avatar/name opens the profile panel - const { queenProfiles } = useColony(); + // Clicking a queen avatar/name opens the queen profile panel. The + // owning page passes its queenProfileId down — we don't fall back + // to a name-match against ``queenProfiles`` because display names + // aren't unique or stable (colony chat uses static QUEEN_REGISTRY + // labels, queen-dm uses user-editable profile names; matching by + // name silently breaks when the profile is renamed or not listed). const { openQueenProfile } = useQueenProfile(); - const queenProfileId = isQueen - ? queenProfiles.find((q) => q.name === msg.agent)?.id ?? null - : null; + const { openColonyWorkers } = useColonyWorkers(); + const resolvedQueenProfileId = isQueen ? queenProfileId ?? null : null; + // Worker messages: clicking the avatar opens the Colony Workers + // sidebar, pre-selecting this worker when its uuid is embedded in + // the streamId (parallel fan-out case). + const workerId = + !isQueen && msg.role === "worker" + ? workerIdFromStreamId(msg.streamId) + : null; if (msg.type === "run_divider") { return ( @@ -554,24 +604,34 @@ const MessageBubble = memo( ); } - const handleQueenClick = queenProfileId - ? () => openQueenProfile(queenProfileId) + const handleQueenClick = resolvedQueenProfileId + ? () => openQueenProfile(resolvedQueenProfileId) : undefined; + const handleWorkerClick = + msg.role === "worker" + ? () => openColonyWorkers(workerId ?? undefined) + : undefined; + const handleAvatarClick = handleQueenClick ?? handleWorkerClick; + const avatarTitle = handleQueenClick + ? `View ${msg.agent}'s profile` + : handleWorkerClick + ? "Open worker in colony sidebar" + : undefined; return (
{isQueen ? ( - + ) : ( )} @@ -649,6 +709,8 @@ export default function ChatPanel({ contextUsage, supportsImages = true, initialDraft, + queenProfileId, + queenId, }: ChatPanelProps) { const [input, setInput] = useState(""); const [pendingImages, setPendingImages] = useState([]); @@ -659,6 +721,7 @@ export default function ChatPanel({ const textareaRef = useRef(null); const fileInputRef = useRef(null); const lastAppliedDraftRef = useRef(undefined); + const queenAvatarUrl = queenId ? `/api/queen/${queenId}/avatar` : null; useEffect(() => { if (!initialDraft || initialDraft === lastAppliedDraftRef.current) return; @@ -1074,6 +1137,8 @@ export default function ChatPanel({ onSend={onSend} queenPhase={queenPhase} showQueenPhaseBadge={showQueenPhaseBadge} + queenProfileId={queenProfileId} + queenAvatarUrl={queenAvatarUrl} />
); @@ -1084,6 +1149,8 @@ export default function ChatPanel({ msg={msg} queenPhase={queenPhase} showQueenPhaseBadge={showQueenPhaseBadge} + queenProfileId={queenProfileId} + queenAvatarUrl={queenAvatarUrl} />
); @@ -1093,14 +1160,14 @@ export default function ChatPanel({ {(isWaiting || (disabled && threadMessages.length === 0)) && (
- +
diff --git a/core/frontend/src/components/ColonyWorkersPanel.tsx b/core/frontend/src/components/ColonyWorkersPanel.tsx index 0e588a75..606e920c 100644 --- a/core/frontend/src/components/ColonyWorkersPanel.tsx +++ b/core/frontend/src/components/ColonyWorkersPanel.tsx @@ -24,18 +24,30 @@ import { type ProgressStep, type WorkerSummary, } from "@/api/colonyWorkers"; +import { + colonyDataApi, + type CellValue, + type TableOverview, + type TableRowsResponse, +} from "@/api/colonyData"; import { workersApi } from "@/api/workers"; import { sessionsApi } from "@/api/sessions"; import { cronToLabel } from "@/lib/graphUtils"; import type { GraphNode } from "@/components/graph-types"; import { useColonyWorkers } from "@/context/ColonyWorkersContext"; +import { DataGrid, type SortDir } from "@/components/data-grid"; interface ColonyWorkersPanelProps { sessionId: string; + /** Colony directory name (e.g. ``linkedin_honeycomb_messaging``) for + * the colony-scoped progress + data endpoints. ``null`` when the + * attached session isn't bound to a colony — those tabs render + * empty rather than fire requests with an invalid name. */ + colonyName: string | null; onClose: () => void; } -type TabKey = "skills" | "tools" | "sessions" | "triggers"; +type TabKey = "skills" | "tools" | "sessions" | "triggers" | "data"; function statusClasses(status: string): string { const s = status.toLowerCase(); @@ -74,9 +86,19 @@ function fmtIso(ts: string | null | undefined): string { export default function ColonyWorkersPanel({ sessionId, + colonyName, onClose, }: ColonyWorkersPanelProps) { const [tab, setTab] = useState("sessions"); + const { focusWorkerId } = useColonyWorkers(); + + // When an external caller (e.g. clicking a worker avatar in chat) + // requests focus on a specific worker, jump to the Sessions tab so + // the pre-select in SessionsTab is visible. The actual select + + // focus-clear happens inside SessionsTab. + useEffect(() => { + if (focusWorkerId) setTab("sessions"); + }, [focusWorkerId]); // ── Resizable width (mirrors QueenProfilePanel) ───────────────────── const MIN_WIDTH = 280; @@ -142,13 +164,17 @@ export default function ColonyWorkersPanel({ setTab("triggers")} label="Triggers" /> setTab("skills")} label="Skills" /> setTab("tools")} label="Tools" /> + setTab("data")} label="Data" />
- {tab === "sessions" && } + {tab === "sessions" && ( + + )} {tab === "triggers" && } {tab === "skills" && } {tab === "tools" && } + {tab === "data" && }
); @@ -459,13 +485,34 @@ function ToolGroup({ label, items }: { label: string; items: ColonyTool[] }) { // ── Sessions tab ─────────────────────────────────────────────────────── -function SessionsTab({ sessionId }: { sessionId: string }) { +function SessionsTab({ + sessionId, + colonyName, +}: { + sessionId: string; + colonyName: string | null; +}) { const [workers, setWorkers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selected, setSelected] = useState(null); const [stoppingId, setStoppingId] = useState(null); const [stoppingAll, setStoppingAll] = useState(false); + const { focusWorkerId, setFocusWorkerId } = useColonyWorkers(); + + // Consume focus requests from avatar clicks in chat. Wait for the + // initial fetch before deciding so a click that arrives before the + // workers list has loaded still resolves. If the requested id is + // present we drill into its detail view; if it's aged out we swallow + // the request silently. Either way we clear the focus so it isn't + // re-applied on every re-render. + useEffect(() => { + if (!focusWorkerId || loading) return; + if (workers.some((w) => w.worker_id === focusWorkerId)) { + setSelected(focusWorkerId); + } + setFocusWorkerId(null); + }, [focusWorkerId, workers, loading, setFocusWorkerId]); const refresh = useCallback(() => { setLoading(true); @@ -555,7 +602,7 @@ function SessionsTab({ sessionId }: { sessionId: string }) { if (selected) { return ( setSelected(null)} @@ -984,15 +1031,372 @@ function Section({ label, children }: { label: string; children: React.ReactNode ); } +// ── Data tab (airtable-style view of progress.db tables) ────────────── + +/** Table-list refresh cadence. Slower than the row poll because the + * overview only drives the row-count chips; the operator doesn't care + * if the count lags the live data by a few seconds. */ +const TABLES_POLL_MS = 5000; + +function DataTab({ colonyName }: { colonyName: string | null }) { + const [tables, setTables] = useState([]); + const [selected, setSelected] = useState(null); + const [loadingTables, setLoadingTables] = useState(true); + const [tablesError, setTablesError] = useState(null); + + const refreshTables = useCallback( + (opts: { silent?: boolean } = {}) => { + if (!colonyName) { + setTables([]); + setLoadingTables(false); + return Promise.resolve(); + } + if (!opts.silent) { + setLoadingTables(true); + setTablesError(null); + } + return colonyDataApi + .listTables(colonyName) + .then((r) => { + setTables(r.tables); + // Auto-select the first table when none chosen yet so the user + // lands on data instead of an empty picker. + setSelected((cur) => cur ?? r.tables[0]?.name ?? null); + if (opts.silent) setTablesError(null); + }) + .catch((e) => { + // Only surface errors on user-initiated loads; silent polls + // stay quiet and the next tick retries. + if (!opts.silent) setTablesError(e?.message ?? "Failed to load tables"); + }) + .finally(() => { + if (!opts.silent) setLoadingTables(false); + }); + }, + [colonyName], + ); + + useEffect(() => { + refreshTables(); + }, [refreshTables]); + + // Background poll for row-count freshness. Skipped when the browser + // tab is hidden — there's no point burning DB reads for a view the + // user isn't watching. + useEffect(() => { + const id = setInterval(() => { + if (typeof document !== "undefined" && document.hidden) return; + void refreshTables({ silent: true }); + }, TABLES_POLL_MS); + return () => clearInterval(id); + }, [refreshTables]); + + if (!colonyName) { + return ( +

+ This session isn't bound to a colony yet — no progress.db to view. +

+ ); + } + + return ( +
+ {tablesError && ( +
+ {tablesError} +
+ )} + + {loadingTables && tables.length === 0 ? ( +
+
+
+ ) : tables.length === 0 ? ( +

+ No tables in progress.db (or the colony has no DB yet). +

+ ) : ( + <> + {/* Table picker — chips so we avoid a heavier select dropdown + in the narrow sidebar. Row counts hint at scale before the + user clicks in. */} +
+ {tables.map((t) => ( + + ))} +
+ +

+ Live view — edits write directly to progress.db. A running worker + may not notice until its next DB read. +

+ + {selected && ( + { + // Row counts can change via cascading triggers or NULL→value + // edits; re-pull so the chip stays truthful. + void refreshTables(); + }} + /> + )} + + )} +
+ ); +} + +/** Page size for the Data tab grid. 100 is a sweet spot for the narrow + * sidebar — big enough that most real-world tables render in one page, + * small enough to keep edits responsive. */ +const DATA_PAGE_SIZE = 100; + +/** Row-poll cadence. 2.5s balances "feels live" against server load + * and our edit/poll race window. Shorter intervals amplify the + * chance of a poll landing during a PATCH roundtrip. */ +const ROWS_POLL_MS = 2500; + +/** Returns true if the user is actively editing any cell inside the + * grid — we sniff for a focused textarea. The alternative (bubbling + * editing state up from every EditableCell) would force the grid + * prop to track a counter. DOM inspection is simpler and — since the + * grid is self-contained under `root` — equally reliable. */ +function isEditingInside(root: HTMLElement | null): boolean { + if (!root) return false; + const active = document.activeElement; + return !!active && root.contains(active) && active.tagName === "TEXTAREA"; +} + +/** Shallow-merge new rows on top of the previous page *by primary + * key*. Reuses unchanged row-object references so React can skip + * re-rendering those ``s — important when the user has the grid + * scrolled horizontally and we don't want jank at every poll. */ +function mergeRowsByPk( + prev: TableRowsResponse, + next: TableRowsResponse, +): TableRowsResponse { + if (prev.primary_key.length === 0) return next; + const prevByKey = new Map>(); + for (const r of prev.rows) { + prevByKey.set(prev.primary_key.map((p) => String(r[p] ?? "")).join("|"), r); + } + const rows = next.rows.map((r) => { + const key = next.primary_key.map((p) => String(r[p] ?? "")).join("|"); + const old = prevByKey.get(key); + if (!old) return r; + // Same key AND all columns identical → reuse the previous object + // so React's reference check skips re-rendering. + for (const col of Object.keys(r)) { + if (r[col] !== old[col]) return r; + } + return old; + }); + return { ...next, rows }; +} + +function TableView({ + colonyName, + table, + onAnyEdit, +}: { + colonyName: string; + table: string; + onAnyEdit: () => void; +}) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [offset, setOffset] = useState(0); + const [orderBy, setOrderBy] = useState(null); + const [orderDir, setOrderDir] = useState("asc"); + + // Request-id guard. Any in-flight request with a stale id is + // discarded on return. Bumped on (a) every new request-start and + // (b) successful edits, so a poll that started *before* a PATCH + // cannot land *after* it and rollback the new value. + const reqIdRef = useRef(0); + const gridRef = useRef(null); + + const fetchOnce = useCallback( + (opts: { silent: boolean }) => { + const myId = ++reqIdRef.current; + if (!opts.silent) { + setLoading(true); + setError(null); + } + colonyDataApi + .listRows(colonyName, table, { + limit: DATA_PAGE_SIZE, + offset, + orderBy, + orderDir, + }) + .then((next) => { + // Discard stale responses — sort/offset changed, edit + // happened, or a subsequent poll started. + if (myId !== reqIdRef.current) return; + setData((prev) => (prev ? mergeRowsByPk(prev, next) : next)); + if (opts.silent) setError(null); + }) + .catch((e) => { + if (myId !== reqIdRef.current) return; + // Silent polls swallow errors; the next tick retries. User- + // initiated loads surface so the operator sees the failure. + if (!opts.silent) setError(e?.message ?? "Failed to load rows"); + }) + .finally(() => { + if (!opts.silent && myId === reqIdRef.current) setLoading(false); + }); + }, + [colonyName, table, offset, orderBy, orderDir], + ); + + // Initial + on-parameter-change load (user-initiated, shows spinner). + useEffect(() => { + fetchOnce({ silent: false }); + }, [fetchOnce]); + + // Background polling. Pauses when (a) the browser tab is hidden — + // no point spending DB reads on an unwatched panel, and (b) the + // user is mid-edit — a silent re-fetch would reorder rows or reset + // the draft under their cursor. + useEffect(() => { + const id = setInterval(() => { + if (typeof document !== "undefined" && document.hidden) return; + if (isEditingInside(gridRef.current)) return; + fetchOnce({ silent: true }); + }, ROWS_POLL_MS); + return () => clearInterval(id); + }, [fetchOnce]); + + // Reset paging when switching tables (key prop on TableView takes care + // of full unmount; this covers the sort-change case). + useEffect(() => { + setOffset(0); + }, [orderBy, orderDir]); + + const handleSort = useCallback((col: string | null, dir: SortDir) => { + setOrderBy(col); + setOrderDir(dir); + }, []); + + const handleEdit = useCallback( + async (pk: Record, column: string, newValue: CellValue) => { + await colonyDataApi.updateRow(colonyName, table, { + pk, + updates: { [column]: newValue }, + }); + // Bump the request-id so any poll that started before the PATCH + // (and is about to return with pre-edit data) is discarded — + // otherwise the grid would briefly revert the cell. + reqIdRef.current++; + // Optimistic patch of the local cache so the grid reflects the + // edit instantly without a full re-fetch flash. + setData((prev) => { + if (!prev) return prev; + const rows = prev.rows.map((r) => { + const matches = prev.primary_key.every((p) => r[p] === pk[p]); + return matches ? { ...r, [column]: newValue } : r; + }); + return { ...prev, rows }; + }); + onAnyEdit(); + }, + [colonyName, table, onAnyEdit], + ); + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!data) { + return ( +
+
+
+ ); + } + + const pageEnd = Math.min(data.offset + data.rows.length, data.total); + const canPrev = data.offset > 0; + const canNext = pageEnd < data.total; + + return ( +
+ +
+ + + + {data.total === 0 + ? "0 rows" + : `${data.offset + 1}–${pageEnd} of ${data.total.toLocaleString()}`} + + +
+ + +
+
+
+ ); +} + // ── Worker detail view (inside Sessions tab) ─────────────────────────── function WorkerDetail({ - sessionId, + colonyName, worker, workerId, onBack, }: { - sessionId: string; + colonyName: string | null; worker: WorkerSummary | null | undefined; workerId: string; onBack: () => void; @@ -1052,20 +1456,20 @@ function WorkerDetail({ {isHistorical ? ( ) : ( - + )}
); } function LiveWorkerProgress({ - sessionId, + colonyName, workerId, }: { - sessionId: string; + colonyName: string | null; workerId: string; }) { - const { snapshot, streamState, error } = useProgressStream(sessionId, workerId); + const { snapshot, streamState, error } = useProgressStream(colonyName, workerId); return ( <>
@@ -1201,7 +1605,7 @@ function ProgressView({ snapshot }: { snapshot: ProgressSnapshot }) { // ── Hook: live progress via SSE ──────────────────────────────────────── -function useProgressStream(sessionId: string, workerId: string) { +function useProgressStream(colonyName: string | null, workerId: string) { const [snapshot, setSnapshot] = useState({ tasks: [], steps: [] }); const [streamState, setStreamState] = useState<"connecting" | "open" | "closed" | "error">( "connecting", @@ -1213,7 +1617,14 @@ function useProgressStream(sessionId: string, workerId: string) { setError(null); setStreamState("connecting"); - const url = colonyWorkersApi.progressStreamUrl(sessionId, workerId); + // Skip the SSE connection entirely if the session isn't bound to a + // colony — we'd just hit a 400 on every reconnect attempt. + if (!colonyName) { + setStreamState("closed"); + return; + } + + const url = colonyWorkersApi.progressStreamUrl(colonyName, workerId); const es = new EventSource(url); es.addEventListener("open", () => setStreamState("open")); @@ -1256,7 +1667,7 @@ function useProgressStream(sessionId: string, workerId: string) { es.close(); setStreamState("closed"); }; - }, [sessionId, workerId]); + }, [colonyName, workerId]); return { snapshot, streamState, error }; } diff --git a/core/frontend/src/components/ModelSwitcher.tsx b/core/frontend/src/components/ModelSwitcher.tsx index e234a703..9bfe5540 100644 --- a/core/frontend/src/components/ModelSwitcher.tsx +++ b/core/frontend/src/components/ModelSwitcher.tsx @@ -1,7 +1,8 @@ import { useState, useRef, useEffect } from "react"; -import { ChevronDown, Check, Settings, ThumbsUp } from "lucide-react"; +import { ChevronDown, Check, Settings, ThumbsUp, AlertCircle } from "lucide-react"; import { useModel, LLM_PROVIDERS } from "@/context/ModelContext"; import type { ModelOption } from "@/api/config"; +import { ApiError } from "@/api/client"; interface ModelSwitcherProps { onOpenSettings?: () => void; @@ -22,6 +23,7 @@ export default function ModelSwitcher({ onOpenSettings }: ModelSwitcherProps) { } = useModel(); const [open, setOpen] = useState(false); + const [error, setError] = useState(null); const ref = useRef(null); // Close on click outside @@ -55,26 +57,30 @@ export default function ModelSwitcher({ onOpenSettings }: ModelSwitcherProps) { ); const handleSelectApiKey = async (provider: string, modelId: string) => { - setOpen(false); + setError(null); try { await setModel(provider, modelId); + setOpen(false); } catch (err) { - console.error("Failed to switch model:", err); + const msg = err instanceof ApiError ? err.message : "Failed to switch model"; + setError(msg); } }; const handleSelectSubscription = async (subscriptionId: string) => { - setOpen(false); + setError(null); try { await activateSubscription(subscriptionId); + setOpen(false); } catch (err) { - console.error("Failed to activate subscription:", err); + const msg = err instanceof ApiError ? err.message : "Failed to activate subscription"; + setError(msg); } }; - // Get detected but inactive subscriptions - const availableSubscriptions = subscriptions.filter( - (sub) => detectedSubscriptions.has(sub.id) && activeSubscription !== sub.id + // All detected subscriptions (active ones shown with checkmark) + const detectedSubs = subscriptions.filter( + (sub) => detectedSubscriptions.has(sub.id) ); const recommendedIcon = ( @@ -89,12 +95,12 @@ export default function ModelSwitcher({ onOpenSettings }: ModelSwitcherProps) { ); - const hasAnyProvider = apiKeyProviders.length > 0 || availableSubscriptions.length > 0 || activeSubInfo; + const hasAnyProvider = apiKeyProviders.length > 0 || detectedSubs.length > 0; return (
- ))} -
- )} - - {/* API key provider models */} {!hasAnyProvider ? (

No providers available. Add an API key or subscription.

) : ( - apiKeyProviders.length > 0 && ( -
-

- API Key Providers -

- {apiKeyProviders.map((provider) => ( -
-

- {provider.name} -

- {(availableModels[provider.id] || []).map( - (model: ModelOption) => { - const isActive = - currentProvider === provider.id && - currentModel === model.id && - !activeSubscription; - return ( - - ); - }, - )} -
- ))} -
- ) + <> + {/* Subscriptions */} + {detectedSubs.length > 0 && ( +
+

+ Subscriptions +

+ {detectedSubs.map((sub) => { + const isActive = activeSubscription === sub.id; + return ( + + ); + })} +
+ )} + + {/* API Keys */} + {apiKeyProviders.length > 0 && ( +
+

+ API Keys +

+ {apiKeyProviders.map((provider) => ( +
+

+ {provider.name} +

+ {(availableModels[provider.id] || []).map( + (model: ModelOption) => { + const isActive = + currentProvider === provider.id && + currentModel === model.id && + !activeSubscription; + return ( + + ); + }, + )} +
+ ))} +
+ )} + )}
+ {/* Validation error */} + {error && ( +
+ +

{error}

+
+ )} + {/* Footer link */} {onOpenSettings && (
diff --git a/core/frontend/src/components/ParallelSubagentBubble.tsx b/core/frontend/src/components/ParallelSubagentBubble.tsx index 55ad167e..0a1211ce 100644 --- a/core/frontend/src/components/ParallelSubagentBubble.tsx +++ b/core/frontend/src/components/ParallelSubagentBubble.tsx @@ -3,6 +3,7 @@ import { ChevronDown, ChevronUp, Cpu } from "lucide-react"; import type { ChatMessage, ContextUsageEntry } from "@/components/ChatPanel"; import MarkdownContent from "@/components/MarkdownContent"; import { cssVar } from "@/lib/graphUtils"; +import { useColonyWorkers } from "@/context/ColonyWorkersContext"; // --------------------------------------------------------------------------- // Shared helpers @@ -317,6 +318,7 @@ const ParallelSubagentBubble = memo( const [expanded, setExpanded] = useState(false); const [zoomedIdx, setZoomedIdx] = useState(null); const mux = useMuxColors(); + const { openColonyWorkers } = useColonyWorkers(); // Labels with instance numbers for duplicates const labels: string[] = (() => { @@ -371,16 +373,21 @@ const ParallelSubagentBubble = memo( return (
- {/* Left icon */} -
openColonyWorkers()} + aria-label="Open colony workers sidebar" + title="Open colony workers sidebar" + className="flex-shrink-0 w-7 h-7 rounded-xl flex items-center justify-center mt-1 transition-opacity hover:opacity-80 cursor-pointer" style={{ backgroundColor: `${workerColor}18`, border: `1.5px solid ${workerColor}35`, }} > -
+
{/* Header */} diff --git a/core/frontend/src/components/QueenProfilePanel.tsx b/core/frontend/src/components/QueenProfilePanel.tsx index 28bd756e..c329cb93 100644 --- a/core/frontend/src/components/QueenProfilePanel.tsx +++ b/core/frontend/src/components/QueenProfilePanel.tsx @@ -1,15 +1,9 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { NavLink, useLocation, useNavigate } from "react-router-dom"; -import { - X, - MessageSquare, - Crown, - ChevronRight, - Briefcase, - Award, -} from "lucide-react"; +import { X, MessageSquare, Crown, ChevronRight, Briefcase, Award, Pencil, Check, Loader2, Camera } from "lucide-react"; import { useColony } from "@/context/ColonyContext"; import { queensApi, type QueenProfile } from "@/api/queens"; +import { compressImage } from "@/lib/image-utils"; import type { Colony } from "@/types/colony"; interface QueenProfilePanelProps { @@ -18,31 +12,106 @@ interface QueenProfilePanelProps { onClose: () => void; } -export default function QueenProfilePanel({ - queenId, - colonies, - onClose, -}: QueenProfilePanelProps) { +function SectionHeader({ children, onEdit }: { children: React.ReactNode; onEdit?: () => void }) { + return ( +
+

{children}

+ {onEdit && ( + + )} +
+ ); +} + +export default function QueenProfilePanel({ queenId, colonies, onClose }: QueenProfilePanelProps) { const navigate = useNavigate(); const location = useLocation(); - const { queenProfiles } = useColony(); + const { queenProfiles, refresh } = useColony(); const summary = queenProfiles.find((q) => q.id === queenId); const [profile, setProfile] = useState(null); const [loading, setLoading] = useState(true); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + + // Avatar state + const [avatarUrl, setAvatarUrl] = useState(null); + const [uploadingAvatar, setUploadingAvatar] = useState(false); + const fileInputRef = useRef(null); + + // Edit form state + const [editName, setEditName] = useState(""); + const [editTitle, setEditTitle] = useState(""); + const [editSummary, setEditSummary] = useState(""); + const [editSkills, setEditSkills] = useState(""); + const [editAchievement, setEditAchievement] = useState(""); - // Hide the "Message {name}" button when we're already in this queen's PM. const alreadyInQueenPm = location.pathname === `/queen/${queenId}`; useEffect(() => { setLoading(true); setProfile(null); - queensApi - .getProfile(queenId) - .then(setProfile) - .catch(() => {}) - .finally(() => setLoading(false)); + setEditing(false); + // Set avatar URL with cache buster + setAvatarUrl(`/api/queen/${queenId}/avatar?t=${Date.now()}`); + queensApi.getProfile(queenId).then(setProfile).catch(() => {}).finally(() => setLoading(false)); }, [queenId]); + const startEditing = () => { + if (!profile) return; + setEditName(profile.name); + setEditTitle(profile.title); + setEditSummary(profile.summary || ""); + setEditSkills(profile.skills || ""); + setEditAchievement(profile.signature_achievement || ""); + setEditing(true); + }; + + const cancelEditing = () => setEditing(false); + + const handleSave = async () => { + setSaving(true); + try { + const updated = await queensApi.updateProfile(queenId, { + name: editName.trim(), + title: editTitle.trim(), + summary: editSummary.trim(), + skills: editSkills.trim(), + signature_achievement: editAchievement.trim(), + }); + setProfile(updated); + setEditing(false); + refresh(); + } catch (err) { + console.error("Failed to save profile:", err); + } finally { + setSaving(false); + } + }; + + const handleAvatarClick = () => fileInputRef.current?.click(); + + const handleAvatarUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + // Reset input so same file can be re-selected + e.target.value = ""; + + if (!file.type.startsWith("image/")) return; + + setUploadingAvatar(true); + try { + const compressed = await compressImage(file); + await queensApi.uploadAvatar(queenId, compressed); + setAvatarUrl(`/api/queen/${queenId}/avatar?t=${Date.now()}`); + } catch (err) { + console.error("Failed to upload avatar:", err); + } finally { + setUploadingAvatar(false); + } + }; + const name = profile?.name ?? summary?.name ?? "Queen"; const title = profile?.title ?? summary?.title ?? ""; @@ -79,9 +148,42 @@ export default function QueenProfilePanel({ document.body.style.userSelect = "none"; }, [width]); + const inputCls = "w-full bg-muted/30 border border-border/50 rounded-lg px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-1 focus:ring-primary/40"; + const textareaCls = `${inputCls} resize-none`; + + const avatarElement = ( +
+
+ {avatarUrl ? ( + {name} setAvatarUrl(null)} + /> + ) : ( + {name.charAt(0)} + )} +
+ + +
+ ); + return (
-
@@ -108,70 +207,93 @@ export default function QueenProfilePanel({
- ) : ( - <> - {/* Avatar + name + title */} -
-
- - {name.charAt(0)} - -
-

- {name} -

-

{title}

+ ) : editing ? ( + /* ── Edit Mode ──────────────────────────────────────────── */ +
+ {/* Avatar */} +
+ {avatarElement} +
+ +
+ + setEditName(e.target.value)} className={inputCls} /> +
+ +
+ + setEditTitle(e.target.value)} className={inputCls} /> +
+ +
+ +